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:
@@ -390,6 +390,8 @@ class RetryMW:
|
|||||||
cached_prompt_tokens=result.cached_prompt_tokens,
|
cached_prompt_tokens=result.cached_prompt_tokens,
|
||||||
model_reported=result.model_reported,
|
model_reported=result.model_reported,
|
||||||
reasoning_tokens=result.reasoning_tokens,
|
reasoning_tokens=result.reasoning_tokens,
|
||||||
|
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判
|
||||||
|
thinking_observation=result.thinking_observation,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _emit(
|
async def _emit(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from polygateway.thinking import (
|
|||||||
ThinkingCapability,
|
ThinkingCapability,
|
||||||
ThinkingUnsupportedError,
|
ThinkingUnsupportedError,
|
||||||
get_capability,
|
get_capability,
|
||||||
|
observe_thinking,
|
||||||
resolve_thinking,
|
resolve_thinking,
|
||||||
)
|
)
|
||||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
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)
|
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
||||||
self._reject_empty_completion(content, source)
|
self._reject_empty_completion(content, source)
|
||||||
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
||||||
|
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content=content,
|
content=content,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
@@ -470,7 +472,12 @@ class OpenAICompatTransport:
|
|||||||
raw={"usage": sink.get("usage")},
|
raw={"usage": sink.get("usage")},
|
||||||
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
||||||
model_reported=_coerce_model_reported(sink.get("model")),
|
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(
|
def _check_done(
|
||||||
@@ -544,6 +551,7 @@ class OpenAICompatTransport:
|
|||||||
)
|
)
|
||||||
self._reject_empty_completion(content, source)
|
self._reject_empty_completion(content, source)
|
||||||
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
||||||
|
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content=content,
|
content=content,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
@@ -555,7 +563,12 @@ class OpenAICompatTransport:
|
|||||||
raw={"usage": body.get("usage")},
|
raw={"usage": body.get("usage")},
|
||||||
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
||||||
model_reported=_coerce_model_reported(body.get("model")),
|
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:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
@@ -226,6 +226,15 @@ class LLMResponse:
|
|||||||
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`,
|
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`,
|
||||||
故下游判据须为 `in (None, 0)`,写 `== 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)
|
@dataclass(frozen=True)
|
||||||
class ChatRequest:
|
class ChatRequest:
|
||||||
@@ -321,6 +330,11 @@ class TransportResult:
|
|||||||
cached_prompt_tokens: int | None = None
|
cached_prompt_tokens: int | None = None
|
||||||
model_reported: str | None = None
|
model_reported: str | None = None
|
||||||
reasoning_tokens: int | None = None
|
reasoning_tokens: int | None = None
|
||||||
|
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||||
|
"""本次调用"推理是否真的发生"的裁定(issue #16/#17),由 transport 组装时填。
|
||||||
|
|
||||||
|
默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默
|
||||||
|
时,不该替上游做出"没推理"这个它从未做过的声明。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from polygateway.transports.openai_compat import (
|
|||||||
_iter_sse_deltas,
|
_iter_sse_deltas,
|
||||||
_sse_data_payload,
|
_sse_data_payload,
|
||||||
)
|
)
|
||||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation
|
||||||
|
|
||||||
|
|
||||||
def _source(**overrides):
|
def _source(**overrides):
|
||||||
@@ -471,6 +471,81 @@ class TestReasoningTokens:
|
|||||||
assert result.reasoning_tokens is None
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingObservationVerdict:
|
||||||
|
"""issue #16/#17: 两条组装路径都必须裁定"推理到底发生没发生"。
|
||||||
|
|
||||||
|
流式与非流式各测一遍是刻意的——只填一条路径正是本 issue 的根因形态:
|
||||||
|
库在其中一条路径上悄悄给出了不同的可观测性,下游无从分辨。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _reasoning_usage(self, reasoning):
|
||||||
|
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
|
||||||
|
|
||||||
|
async def test_stream_reasoning_content_is_observed(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(reasoning="想一下"), _chunk(content="ok"), _chunk(usage=_USAGE)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
async def test_stream_without_any_signal_is_unknown(self):
|
||||||
|
"""无正文、无 details: 库不知道,就如实说不知道。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
async def test_stream_zero_reasoning_tokens_is_absent(self):
|
||||||
|
"""上游明确上报 0 才算 ABSENT——这是唯一的"确实没推理"证据。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.thinking_observation is ThinkingObservation.ABSENT
|
||||||
|
|
||||||
|
async def test_non_stream_reasoning_content_is_observed(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42", "reasoning_content": "想一下"}}],
|
||||||
|
"usage": _USAGE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
async def test_non_stream_without_any_signal_is_unknown(self):
|
||||||
|
"""M3 非流式实测形态: 推理已计费却既不回传正文也不回传 details。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
async def test_non_stream_zero_reasoning_tokens_is_absent(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42"}}],
|
||||||
|
"usage": self._reasoning_usage(0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.thinking_observation is ThinkingObservation.ABSENT
|
||||||
|
|
||||||
|
|
||||||
class TestNonStreamFastPath:
|
class TestNonStreamFastPath:
|
||||||
async def test_non_stream_parses_message(self):
|
async def test_non_stream_parses_message(self):
|
||||||
def handler(request):
|
def handler(request):
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from polygateway.types import (
|
|||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
ThinkingObservation,
|
||||||
TransportResult,
|
TransportResult,
|
||||||
)
|
)
|
||||||
from tests.contracts.conftest import FakeClock
|
from tests.contracts.conftest import FakeClock
|
||||||
@@ -225,6 +226,29 @@ class TestObservabilityPassthrough:
|
|||||||
assert resp.cached_prompt_tokens is None and resp.model_reported is None
|
assert resp.cached_prompt_tokens is None and resp.model_reported is None
|
||||||
assert resp.reasoning_tokens is None
|
assert resp.reasoning_tokens is None
|
||||||
|
|
||||||
|
async def test_thinking_observation_reaches_the_response(self):
|
||||||
|
"""issue #16/#17: 裁定归 transport,中间件只透传,不得在途中改判。"""
|
||||||
|
result = TransportResult(
|
||||||
|
content="ok",
|
||||||
|
thinking="想一下",
|
||||||
|
prompt_tokens=10,
|
||||||
|
completion_tokens=5,
|
||||||
|
usage_source="measured",
|
||||||
|
ttft_ms=12.0,
|
||||||
|
max_inter_token_ms=3.0,
|
||||||
|
raw={},
|
||||||
|
thinking_observation=ThinkingObservation.OBSERVED,
|
||||||
|
)
|
||||||
|
mw, *_ = _harness([_src("a")], [result])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
async def test_unjudged_transport_result_stays_unknown(self):
|
||||||
|
"""不裁定的 transport(如 OCR)透传出来仍是 UNKNOWN,不被默认成 ABSENT。"""
|
||||||
|
mw, *_ = _harness([_src("a")], [_ok()])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
class TestRetryAndFailover:
|
class TestRetryAndFailover:
|
||||||
async def test_transient_switches_source_then_succeeds(self):
|
async def test_transient_switches_source_then_succeeds(self):
|
||||||
|
|||||||
@@ -88,6 +88,30 @@ class TestLLMResponse:
|
|||||||
assert filled.model_reported == "MiniMax-Text-01-250321"
|
assert filled.model_reported == "MiniMax-Text-01-250321"
|
||||||
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
|
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
|
||||||
|
|
||||||
|
def test_thinking_observation_defaults_to_unknown(self):
|
||||||
|
"""issue #16/#17: 默认必须是 UNKNOWN——"没信号"不得被伪装成"没推理"。
|
||||||
|
|
||||||
|
默认值取 ABSENT 会让每个不填该字段的构造点(测试 fake、其他 transport)
|
||||||
|
都在替上游做一个它没做过的声明,那正是本 issue 要消灭的静默错觉。
|
||||||
|
"""
|
||||||
|
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||||
|
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
filled = LLMResponse(
|
||||||
|
"c",
|
||||||
|
"t",
|
||||||
|
"m",
|
||||||
|
"p",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
"cid",
|
||||||
|
thinking_observation=ThinkingObservation.OBSERVED,
|
||||||
|
)
|
||||||
|
assert filled.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
def test_frozen(self):
|
def test_frozen(self):
|
||||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
@@ -264,6 +288,8 @@ class TestAuxTypes:
|
|||||||
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
|
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
|
||||||
assert s.cached_prompt_tokens is None and s.model_reported is None
|
assert s.cached_prompt_tokens is None and s.model_reported is None
|
||||||
assert s.reasoning_tokens is None
|
assert s.reasoning_tokens is None
|
||||||
|
# issue #16/#17: 不裁定的 transport 只能说"不知道",不能替上游说"没推理"
|
||||||
|
assert s.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
class TestOcrTypes:
|
class TestOcrTypes:
|
||||||
|
|||||||
Reference in New Issue
Block a user