feat: carry the reasoning verdict through to LLMResponse

Both assembly paths fill it, streaming and non-streaming alike. Filling
only one is exactly the divergence this issue exposed: M3 returns
reasoning prose over SSE and nothing at all over the plain endpoint, so
a verdict computed on one path says nothing about the other.

The field defaults to UNKNOWN on both TransportResult and LLMResponse.
A transport that does not judge should not get to declare absence on
the provider's behalf, and a default that stays silent is the only one
that cannot lie.
This commit is contained in:
2026-08-26 00:03:28 -04:00
parent 59d2e442e6
commit 8c5c23ae72
6 changed files with 157 additions and 3 deletions
+2
View File
@@ -390,6 +390,8 @@ class RetryMW:
cached_prompt_tokens=result.cached_prompt_tokens,
model_reported=result.model_reported,
reasoning_tokens=result.reasoning_tokens,
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判
thinking_observation=result.thinking_observation,
)
async def _emit(
+15 -2
View File
@@ -28,6 +28,7 @@ from polygateway.thinking import (
ThinkingCapability,
ThinkingUnsupportedError,
get_capability,
observe_thinking,
resolve_thinking,
)
from polygateway.transports._http_errors import compose_message, summarize_body
@@ -459,6 +460,7 @@ class OpenAICompatTransport:
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
return TransportResult(
content=content,
thinking=thinking,
@@ -470,7 +472,12 @@ 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")),
reasoning_tokens=reasoning_tokens,
# 两条组装路径必须同口径裁定: 只在一条路径上给结论,下游就得靠
# "这次是不是流式"去猜可观测性,那正是 issue #16/#17 的根因形态
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
)
def _check_done(
@@ -544,6 +551,7 @@ class OpenAICompatTransport:
)
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
return TransportResult(
content=content,
thinking=thinking,
@@ -555,7 +563,12 @@ 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")),
reasoning_tokens=reasoning_tokens,
# 本路径的裁定多半落 UNKNOWN(M3 实测: 推理已计费却正文与 details 双
# 缺)。如实标记"观测不到",好过让下游误读成"没推理"
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
)
async def aclose(self) -> None:
+14
View File
@@ -226,6 +226,15 @@ class LLMResponse:
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`,
故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。"""
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
"""本次调用"推理是否真的发生"的三态裁定(issue #16/#17)。
`UNKNOWN` = **本次无任何信号,判不出来**,**不是**"没推理"——把两者折叠
是 `reasoning_tokens=None` 制造的老歧义。典型来源: 非流式路径下部分模型
推理已计费却既不回传正文也不回传 `completion_tokens_details`(MiniMax-M3
实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。
要判"确实没推理"只认 `ABSENT`(上游明确上报 0)。"""
@dataclass(frozen=True)
class ChatRequest:
@@ -321,6 +330,11 @@ class TransportResult:
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
"""本次调用"推理是否真的发生"的裁定(issue #16/#17),由 transport 组装时填。
默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默
时,不该替上游做出"没推理"这个它从未做过的声明。"""
@dataclass(frozen=True)