From 8c5c23ae7266ed3f12ce8f5a78f3698599fcf91d Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 26 Aug 2026 00:03:28 -0400 Subject: [PATCH] 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. --- src/polygateway/middleware/retry.py | 2 + src/polygateway/transports/openai_compat.py | 17 ++++- src/polygateway/types.py | 14 ++++ tests/unit/test_openai_compat.py | 77 ++++++++++++++++++++- tests/unit/test_retry.py | 24 +++++++ tests/unit/test_types.py | 26 +++++++ 6 files changed, 157 insertions(+), 3 deletions(-) diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 0e9b00c..91f6c6d 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -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( diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 9014a22..aab2e46 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -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: diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 7045544..47ed4e8 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -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) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 01f2845..cd7838d 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -22,7 +22,7 @@ from polygateway.transports.openai_compat import ( _iter_sse_deltas, _sse_data_payload, ) -from polygateway.types import ChatRequest, LLMResponse, SourceConfig +from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation def _source(**overrides): @@ -471,6 +471,81 @@ class TestReasoningTokens: 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: async def test_non_stream_parses_message(self): def handler(request): diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index 8f83f19..c11a939 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -27,6 +27,7 @@ from polygateway.types import ( GlobalLimits, RetryPolicy, SourceConfig, + ThinkingObservation, TransportResult, ) 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.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: async def test_transient_switches_source_then_succeeds(self): diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 544eee0..e7322da 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -88,6 +88,30 @@ class TestLLMResponse: assert filled.model_reported == "MiniMax-Text-01-250321" 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): resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid") with pytest.raises(dataclasses.FrozenInstanceError): @@ -264,6 +288,8 @@ class TestAuxTypes: # issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动) assert s.cached_prompt_tokens is None and s.model_reported is None assert s.reasoning_tokens is None + # issue #16/#17: 不裁定的 transport 只能说"不知道",不能替上游说"没推理" + assert s.thinking_observation is ThinkingObservation.UNKNOWN class TestOcrTypes: