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:
2026-08-02 05:55:37 -04:00
parent e5871cccd2
commit 89ff916bc8
13 changed files with 159 additions and 6 deletions
@@ -177,6 +177,25 @@ def _coerce_cached_tokens(usage: Any) -> int | None:
return cached
def _coerce_reasoning_tokens(usage: Any) -> int | None:
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。
与 `_coerce_cached_tokens` 逐条同构(两者是 OpenAI 兼容 usage 里对称的一对):
`0` 如实保留、负数与非整数归 None、`bool` 显式排除。差别只在语义——本字段
的 None 是"**本次调用**未上报"而非"该源不上报": 中转在上游不返回 usage 时
会本地补算并整体替换 usage 对象,把 details 一并吃掉(findings §4c)。
"""
if not isinstance(usage, dict):
return None
details = usage.get("completion_tokens_details")
if not isinstance(details, dict):
return None
reasoning = details.get("reasoning_tokens")
if isinstance(reasoning, bool) or not isinstance(reasoning, int) or reasoning < 0:
return None
return reasoning
def _coerce_model_reported(value: Any) -> str | None:
"""取响应体的 model 字段(issue #3);非 str 或空白串一律 None,收口时去空白。
@@ -400,6 +419,7 @@ class OpenAICompatTransport:
raw={"usage": sink.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
model_reported=_coerce_model_reported(sink.get("model")),
reasoning_tokens=_coerce_reasoning_tokens(sink.get("usage")),
)
def _check_done(
@@ -484,6 +504,7 @@ class OpenAICompatTransport:
raw={"usage": body.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
model_reported=_coerce_model_reported(body.get("model")),
reasoning_tokens=_coerce_reasoning_tokens(body.get("usage")),
)
async def aclose(self) -> None: