feat: collect reasoning_tokens from the provider usage payload (issue #6)
Reasoning tokens are already counted inside completion_tokens, so the cost total was never wrong -- what was missing is the attribution: how much of a call was spent thinking rather than answering. LLMResponse and TransportResult each gain a trailing reasoning_tokens field, and the telemetry port grows from 21 to 22 columns with the new column appended in both backends so fresh and migrated schemas keep the same physical order. None means this particular call did not report the field, not that the source never reports it: a relay that falls back to a local tokenizer replaces the whole usage object and drops completion_tokens_details. Downstream checks must therefore read "in (None, 0)"; no provider was observed reporting a literal zero.
This commit is contained in:
@@ -43,6 +43,7 @@ _EXPECTED_COLUMNS = [
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
]
|
||||
|
||||
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
||||
@@ -107,6 +108,7 @@ async def _record_minimal(
|
||||
"cached_prompt_tokens": None,
|
||||
"model_reported": None,
|
||||
"sampling": None,
|
||||
"reasoning_tokens": None,
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
|
||||
@@ -394,6 +394,81 @@ class TestObservabilityFields:
|
||||
assert set(result.raw) == {"usage"}
|
||||
|
||||
|
||||
class TestReasoningTokens:
|
||||
"""issue #6: 推理消耗的输出 token,与 issue #3 的 cached_tokens 对称。
|
||||
|
||||
实测三家供应商在"未推理"时是整个 completion_tokens_details 缺失,无人上报
|
||||
0;且中转在上游不返回 usage 时会本地补算并吃掉该对象。故 None 的语义是
|
||||
"本次调用未上报",不是"该源不上报"(findings §4c)。
|
||||
"""
|
||||
|
||||
def _reasoning_usage(self, reasoning):
|
||||
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
|
||||
|
||||
async def test_stream_reads_reasoning_tokens(self):
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(7)))
|
||||
|
||||
result = await _complete(_transport_for(handler), _source())
|
||||
assert result.reasoning_tokens == 7
|
||||
|
||||
async def test_non_stream_reads_reasoning_tokens(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{"message": {"content": "42"}}],
|
||||
"usage": self._reasoning_usage(7),
|
||||
},
|
||||
)
|
||||
|
||||
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||
assert result.reasoning_tokens == 7
|
||||
|
||||
async def test_zero_reasoning_tokens_is_a_real_zero(self):
|
||||
"""0(上报了且确实没推理)与 None(本次未上报)必须可区分。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
|
||||
|
||||
result = await _complete(_transport_for(handler), _source())
|
||||
assert result.reasoning_tokens == 0
|
||||
|
||||
async def test_usage_without_details_is_none(self):
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||
|
||||
result = await _complete(_transport_for(handler), _source())
|
||||
assert result.reasoning_tokens is None
|
||||
|
||||
@pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}])
|
||||
async def test_malformed_reasoning_tokens_degrade_to_none(self, bad):
|
||||
"""`True` 必须排除: Python 里 isinstance(True, int) 为真。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(bad)))
|
||||
|
||||
result = await _complete(_transport_for(handler), _source())
|
||||
assert result.reasoning_tokens is None
|
||||
|
||||
async def test_details_not_a_dict_is_none(self):
|
||||
def handler(request):
|
||||
usage = {**_USAGE, "completion_tokens_details": "oops"}
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=usage))
|
||||
|
||||
result = await _complete(_transport_for(handler), _source())
|
||||
assert result.reasoning_tokens is None
|
||||
|
||||
async def test_salvage_path_records_none_not_zero(self):
|
||||
"""打捞路径拿不到 usage 帧: 记 None(未知)而非 0(确定没推理)。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), done=False)
|
||||
|
||||
result = await _complete(_transport_for(handler), _source(missing_done="salvage"))
|
||||
assert result.reasoning_tokens is None
|
||||
|
||||
|
||||
class TestNonStreamFastPath:
|
||||
async def test_non_stream_parses_message(self):
|
||||
def handler(request):
|
||||
|
||||
@@ -117,6 +117,7 @@ class _DummyRecorder:
|
||||
cached_prompt_tokens,
|
||||
model_reported,
|
||||
sampling,
|
||||
reasoning_tokens,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
|
||||
@@ -209,11 +209,13 @@ class TestObservabilityPassthrough:
|
||||
raw={},
|
||||
cached_prompt_tokens=64,
|
||||
model_reported="MiniMax-Text-01-250321",
|
||||
reasoning_tokens=7,
|
||||
)
|
||||
mw, *_ = _harness([_src("a")], [result])
|
||||
resp = await mw(_REQ)
|
||||
assert resp.cached_prompt_tokens == 64
|
||||
assert resp.model_reported == "MiniMax-Text-01-250321"
|
||||
assert resp.reasoning_tokens == 7
|
||||
# model 仍是配置别名: 真实版本是旁证,不顶替溯源主字段
|
||||
assert resp.model == "m"
|
||||
|
||||
@@ -221,6 +223,7 @@ class TestObservabilityPassthrough:
|
||||
mw, *_ = _harness([_src("a")], [_ok()])
|
||||
resp = await mw(_REQ)
|
||||
assert resp.cached_prompt_tokens is None and resp.model_reported is None
|
||||
assert resp.reasoning_tokens is None
|
||||
|
||||
|
||||
class TestRetryAndFailover:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""遥测子系统测试: SQLiteRecorder(21 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
|
||||
"""遥测子系统测试: SQLiteRecorder(22 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
@@ -39,6 +39,7 @@ _EXPECTED_COLUMNS = [
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
]
|
||||
|
||||
|
||||
@@ -102,6 +103,7 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
||||
"cached_prompt_tokens": None,
|
||||
"model_reported": None,
|
||||
"sampling": None,
|
||||
"reasoning_tokens": None,
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
@@ -158,6 +160,22 @@ class TestSQLiteRecorder:
|
||||
assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL
|
||||
assert rows["c-none"] is None
|
||||
|
||||
async def test_reasoning_tokens_column_round_trip(self, tmp_path):
|
||||
"""issue #6: 7 / 0 / None 三种值各自如实落库,0 与 NULL 不得混同。"""
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
await _record_minimal(recorder, call_id="r-some", reasoning_tokens=7)
|
||||
await _record_minimal(recorder, call_id="r-zero", reasoning_tokens=0)
|
||||
await _record_minimal(recorder, call_id="r-none", reasoning_tokens=None)
|
||||
recorder.close()
|
||||
rows = dict(
|
||||
sqlite3.connect(tmp_path / "t.db")
|
||||
.execute("SELECT call_id, reasoning_tokens FROM llm_calls")
|
||||
.fetchall()
|
||||
)
|
||||
assert rows["r-some"] == 7
|
||||
assert rows["r-zero"] == 0 # 上报了且确实没推理
|
||||
assert rows["r-none"] is None # 本次调用未上报
|
||||
|
||||
async def test_sampling_column_round_trips(self, tmp_path):
|
||||
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
@@ -284,6 +302,7 @@ class TestPostgresBackfillDiscipline:
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
]
|
||||
|
||||
def _recorder(self, conn):
|
||||
@@ -384,11 +403,12 @@ class TestEmitterObservabilityFields:
|
||||
source=_source(),
|
||||
call_id="cid-1",
|
||||
latency_ms=42,
|
||||
response=_resp(cached_prompt_tokens=64, model_reported="m-real"),
|
||||
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
|
||||
error=None,
|
||||
)
|
||||
assert rec.rows[0]["cached_prompt_tokens"] == 64
|
||||
assert rec.rows[0]["model_reported"] == "m-real"
|
||||
assert rec.rows[0]["reasoning_tokens"] == 7
|
||||
|
||||
async def test_failed_attempt_has_no_provider_facts(self):
|
||||
rec = _MemoryRecorder()
|
||||
@@ -402,16 +422,19 @@ class TestEmitterObservabilityFields:
|
||||
)
|
||||
assert rec.rows[0]["cached_prompt_tokens"] is None
|
||||
assert rec.rows[0]["model_reported"] is None
|
||||
assert rec.rows[0]["reasoning_tokens"] is None
|
||||
|
||||
async def test_cache_hit_replays_the_recorded_values(self):
|
||||
"""决策 B1: 命中行原样回放,故命中率统计必须带 WHERE cache_hit = false。"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec).emit_cache_hit(
|
||||
request=_REQ, response=_resp(cached_prompt_tokens=64, model_reported="m-real")
|
||||
request=_REQ,
|
||||
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
|
||||
)
|
||||
row = rec.rows[0]
|
||||
assert row["cache_hit"] is True
|
||||
assert row["cached_prompt_tokens"] == 64 and row["model_reported"] == "m-real"
|
||||
assert row["reasoning_tokens"] == 7 # 与 cached 同口径原样回放
|
||||
|
||||
async def test_terminal_failure_records_none(self):
|
||||
rec = _MemoryRecorder()
|
||||
@@ -420,6 +443,7 @@ class TestEmitterObservabilityFields:
|
||||
)
|
||||
assert rec.rows[0]["cached_prompt_tokens"] is None
|
||||
assert rec.rows[0]["model_reported"] is None
|
||||
assert rec.rows[0]["reasoning_tokens"] is None
|
||||
|
||||
|
||||
class TestEmitterSamplingColumn:
|
||||
|
||||
@@ -54,6 +54,7 @@ class TestLLMResponse:
|
||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||
assert resp.cached_prompt_tokens is None
|
||||
assert resp.model_reported is None
|
||||
assert resp.reasoning_tokens is None # issue #6: 本次调用未上报
|
||||
filled = LLMResponse(
|
||||
"c",
|
||||
"t",
|
||||
@@ -68,9 +69,11 @@ class TestLLMResponse:
|
||||
"cid",
|
||||
cached_prompt_tokens=0,
|
||||
model_reported="MiniMax-Text-01-250321",
|
||||
reasoning_tokens=0,
|
||||
)
|
||||
assert filled.cached_prompt_tokens == 0 # 真实零命中,不得与 None 混同
|
||||
assert filled.model_reported == "MiniMax-Text-01-250321"
|
||||
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
|
||||
|
||||
def test_frozen(self):
|
||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||
@@ -247,6 +250,7 @@ class TestAuxTypes:
|
||||
assert s.raw["id"] == "x"
|
||||
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
|
||||
assert s.cached_prompt_tokens is None and s.model_reported is None
|
||||
assert s.reasoning_tokens is None
|
||||
|
||||
|
||||
class TestOcrTypes:
|
||||
|
||||
Reference in New Issue
Block a user