diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 7cff61f..5c4c7d4 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -395,6 +395,9 @@ class RetryMW: reasoning_tokens=result.reasoning_tokens, # 裁定归 transport(它才见得到原始信号),本层只搬运不改判 thinking_observation=result.thinking_observation, + # 同理: 实际档由做注入的那一层裁定(`nearest` 映射后与请求档分叉), + # 本层若"顺手"改读 request.reasoning_effort,记的就是从未发出过的档 + applied_effort=result.applied_effort, ) async def _emit( diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index d688aed..45079fc 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -575,7 +575,7 @@ def _warn_unregistered( def reconcile_thinking( *, - enable_thinking: bool | None, + effort: Effort | None, observation: ThinkingObservation, capability: ThinkingCapability | None, model: str, @@ -585,6 +585,17 @@ def reconcile_thinking( 能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的 表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。 + **判据是档位而非布尔**(2026-09-05,设计 §4.3): `Effort.NONE` 走"要求关闭" + 一支,其余任何档走"要求开启"一支,`None`(不表态)仍沉默。判据必须写成 + `is Effort.NONE` 的**身份比较**——它的取值是非空串 `"none"`,任何靠真值性 + 的写法(`if not effort`)都恒为假,会把每个强度档送进关闭分支,告警方向整个 + 颠倒。传入的应是**实际发出去**的那一档(`nearest` 映射后与请求档分叉), + 否则文案会说一个从未发出过的档。 + + **不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3/§11 第 1 + 条): 二者没有可判定的函数关系(实测同一档 rt 在 8~56 之间跳),拿它报警必然 + 是噪声,而噪声等于没有告警。该问题归 §11 的压测,不进库。 + **只判定、不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容, 而不必去解析日志格式;节流也才能留在握有实例状态的 transport 里。 @@ -593,24 +604,26 @@ def reconcile_thinking( 与遥测落地,处置权归下游。 """ # Phase 1: 调用方不表态 —— 没提要求就无从谈"违背" - if enable_thinking is None: + if effort is None: return None # Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警 # 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警 - if enable_thinking is False: + if effort is Effort.NONE: if observation is not ThinkingObservation.OBSERVED: return None return _off_but_observed(model, capability) - # Phase 3: 要求开启 —— ABSENT 是正面证伪,UNKNOWN 是"看不见",两者文案不可混 + # Phase 3: 要求开启(含 auto 与各强度档)—— ABSENT 是正面证伪,UNKNOWN 是 + # "看不见",两者文案不可混。文案写出**是哪一档**: transport 的节流键正按档 + # 分离,文案不分档的话,两条告警长得一模一样,看的人分不出是哪一档出的问题 if observation is ThinkingObservation.ABSENT: return ( - f"模型 {model!r} 的 enable_thinking=True 未生效: 已注入开启参数," + f"模型 {model!r} 的 reasoning_effort={effort.value!r} 未生效: 已注入开启参数," f"上游却明确上报本次未推理(reasoning_tokens=0)" ) if observation is ThinkingObservation.UNKNOWN: return ( - f"模型 {model!r} 的 enable_thinking=True 无法确认是否生效: 已注入开启参数," - f"但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。" + f"模型 {model!r} 的 reasoning_effort={effort.value!r} 无法确认是否生效: " + f"已注入开启参数,但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。" f"若走的是非流式路径,推理内容可能已计费却不回传" ) return None @@ -624,12 +637,12 @@ def _off_but_observed(model: str, capability: ThinkingCapability | None) -> str: """ if capability is None: return ( - f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生," + f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生," f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。" f"请实测后用 register_capability 登记其真实能力" ) return ( - f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生," + f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生," f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。" f"能力表可能已过期——请复测后用 register_capability 更新登记" ) diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index d2e6ad9..207fcaf 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import re import time +from dataclasses import replace from typing import TYPE_CHECKING, Any import httpx @@ -333,7 +334,7 @@ class OpenAICompatTransport: # 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略, # 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制; # 分开维护的理由是语义,不是碰撞) - self._warned_mismatches: set[tuple[str, str, bool | None]] = set() + self._warned_mismatches: set[tuple[str, str, Effort | None]] = set() self._client_factory = client_factory or _default_client_factory self._clients: dict[str, httpx.AsyncClient] = {} @@ -353,7 +354,13 @@ class OpenAICompatTransport: stream: bool, overlay: dict[str, Any], reasoning_effort: Effort | None, - ) -> dict[str, Any]: + ) -> tuple[dict[str, Any], Effort | None]: + """组装请求体,并交回本次**实际**发出去的档位(`None` = 未表态,不注入)。 + + 返回二元组而非只返回 payload: 实际档在 `nearest` 映射后与请求档分叉,而 + 除本函数外没有第二处知道映射结果——不交出去,遥测就只能事后再算一遍, + 算出来必是请求档。 + """ payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream} if stream: payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款) @@ -364,25 +371,26 @@ class OpenAICompatTransport: self._warned_models.add(source.model) # 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。 # 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错" - payload.update( - resolve_thinking( - profile, - capability, - effective_effort( - request_effort=reasoning_effort, - source_effort=source.reasoning_effort, - enable_thinking=source.enable_thinking, - ), - model=source.model, - fallback=source.effort_fallback, - warn_unregistered=first_time, - ).payload + resolution = resolve_thinking( + profile, + capability, + effective_effort( + request_effort=reasoning_effort, + source_effort=source.reasoning_effort, + enable_thinking=source.enable_thinking, + ), + model=source.model, + # 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确 + # 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出) + fallback=source.effort_fallback, + warn_unregistered=first_time, ) + payload.update(resolution.payload) # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级 # overlay(含结构化注入)在后覆盖之。两行不可调换 payload.update(source.extra_body) payload.update(overlay) - return payload + return payload, resolution.applied_effort async def complete( self, @@ -401,7 +409,7 @@ class OpenAICompatTransport: """ profile = get_provider(source.provider, registry=self._registry) try: - payload = self._build_payload( + payload, applied_effort = self._build_payload( messages=messages, source=source, profile=profile, @@ -432,27 +440,37 @@ class OpenAICompatTransport: except httpx.TransportError as exc: # VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8) raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc - # 此处是唯一同时握有请求方向与响应结果的地方,对账只能落在这里 + # 实际发出去的档只有 `_build_payload` 知道,而组装 TransportResult 的两条 + # 路径都在更深一层。在此唯一汇合点补齐,好过给两条路径各加一个参数——那正是 + # 遥测那边被明令禁止的"复制参数列表"形态,两条路径迟早只改一条 + result = replace(result, applied_effort=applied_effort) + # 此处是唯一同时握有请求档位与响应结果的地方,对账只能落在这里 self._warn_on_thinking_mismatch(source, result) return result def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None: - """声明与观测矛盾即 warning;按 (source, model, direction) 节流,同组合只喊一次。 + """声明与观测矛盾即 warning;按 (source, model, 实际档位) 节流,同组合只喊一次。 - 三段缺一不可。**方向**: 同一模型的开、关两档是两个独立的矛盾。**源名**: - 多源多账号是本库的核心场景,同一 model 跨 N 个源是常态,而每个源背后是 - 独立的账号/网关,一个源的行为不代表另一个——漏掉源名,5 个源里第一个出 - 问题的喊完一次,其余四个永久静音。逐次调用刷屏会把告警变成噪声,噪声等于 - 没有告警。 + 三段缺一不可。**档位**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个 + 键会让第二个永久静音(旧版拿 `enable_thinking` 当第三段,而档位根本不经过 + 那个字段,于是同一模型的所有档共用一个键)。**源名**: 多源多账号是本库的 + 核心场景,同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,一个 + 源的行为不代表另一个——漏掉源名,5 个源里第一个出问题的喊完一次,其余四个 + 永久静音。逐次调用刷屏会把告警变成噪声,噪声等于没有告警。 + + 档位取 `result.applied_effort`(真正发出去的那一档)而非请求档: `nearest` + 映射后二者分叉,而对账问的是"我发出去的要求有没有被满足"——拿一个从未发出 + 过的档去对账,文案与键都指向了一次不存在的请求。被映射到同一档的两个请求 + 因此共用一个键,这正是它们该有的关系(同一条实际要求,同一个矛盾)。 **先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串, 而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。 """ - key = (source.name, source.model, source.enable_thinking) + key = (source.name, source.model, result.applied_effort) if key in self._warned_mismatches: return message = reconcile_thinking( - enable_thinking=source.enable_thinking, + effort=result.applied_effort, observation=result.thinking_observation, capability=get_capability(source.model, table=self._capabilities), model=source.model, diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 7eba116..2857d8e 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -326,6 +326,16 @@ class LLMResponse: 实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。 要判"确实没推理"只认 `ABSENT`(上游明确上报 0)。""" + applied_effort: Effort | None = None + """本次调用**真正发出去**的推理档位(issue #20);`None` = 调用方未表态。 + + 与 `ChatRequest.reasoning_effort`(请求档)可能分叉: 源上配了 + `EFFORT_FALLBACK=nearest` 时,请求 `medium` 而模型只有 low/high/max,实际发 + 出的是 `low`。遥测按本字段分组,记请求档会把整行挂在一个从未发出过的档下。 + + `None` 不是"没推理": 库不表态时也不推定模型自己的默认档——"没看见"不许说成 + "发生了"(同 `thinking_observation` 的 `UNKNOWN` 一脉)。""" + @dataclass(frozen=True) class ChatRequest: @@ -438,6 +448,13 @@ class TransportResult: 默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默 时,不该替上游做出"没推理"这个它从未做过的声明。""" + applied_effort: Effort | None = None + """本次调用真正发出去的推理档位(issue #20),由做注入的 transport 填。 + + 只有做了注入的那一层知道它: `nearest` 映射后请求档与实际档分叉(请求 + `medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是 + "未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。""" + @dataclass(frozen=True) class SourceConfig: diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 95b8cbe..a92c7c0 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -312,6 +312,52 @@ class TestEffortFallbackWiring: assert captured == [] # 请求根本没发出去 +class TestAppliedTierReachesTheCaller: + """`LLMResponse.applied_effort` 报的是**真正发出去的**那一档(计划 T8-5)。 + + 对下游是新能力(它终于能知道这次跑在哪档),对遥测是前置条件: 记请求档会让 + 按档分组的压测把整行挂在一个从未发出过的档下,而那种数据错得看不出来。 + """ + + async def test_response_carries_the_mapped_tier(self): + """glm-5.3 无 `medium`: 开了 nearest 后实际跑的是 low,响应必须这么说。""" + source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest") + async with _client(sources=[source]) as client: + resp = await client.chat( + [{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM + ) + assert resp.applied_effort is Effort.LOW + + async def test_no_statement_leaves_the_field_none(self): + async with _client() as client: + resp = await client.chat([{"role": "user", "content": "hi"}]) + assert resp.applied_effort is None + + +class TestUnsupportedTierIsRefusedNotRetried: + """档位不可满足 = 请求本身的问题: 报 `RequestRejectedError`,不重试、不伤熔断。 + + 重试与换源都不会让它变对(设计 §10),而把它计进熔断更糟——一次配置错误会 + 把一个健康的源关掉,拖垮与推理无关的所有调用。 + """ + + async def test_tier_error_never_reaches_the_gateway_or_the_breaker(self): + sent = [] + + def handler(request): + sent.append(request) + return _sse() + + # 阈值取 1: 只要这次失败被计进熔断,门当场开路,断言立刻可见 + gate = InMemoryGate(config=BreakerConfig(1, 60.0, 120.0)) + source = _source(name="zp", provider="zhipu", model="glm-5.3") + async with _client(sources=[source], handler=handler, breaker=gate) as client: + with pytest.raises(RequestRejectedError, match="无法关闭推理"): + await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.NONE) + assert sent == [], "请求根本不该发出去: 档位不可满足在组装期就已判定" + assert (await gate.try_enter("zp", "w")).allowed, "配置错误不得计入熔断失败" + + class TestRequestTierNormalization: """`chat(reasoning_effort=...)` 是公共入口,裸字符串必须在此归一(issue #20)。 @@ -515,7 +561,6 @@ class TestModelFingerprint: expected = "qwen-max|" + hashlib.sha256(mark.encode("utf-8")).hexdigest() assert build_model_fingerprint([_source(extra_body={"temperature": 0})]) == expected - def test_declared_tier_fingerprint_is_a_golden(self): """配了档位那一侧的指纹字面量也要钉死: 它变了就是该源整段缓存冷启动。 diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 3fcae73..01ee5eb 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -23,7 +23,7 @@ from polygateway.transports.openai_compat import ( _iter_sse_deltas, _sse_data_payload, ) -from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation +from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation def _source(**overrides): @@ -629,6 +629,60 @@ class TestThinkingReconciliation: hits = [m for m in messages if "MiniMax-M3" in m] assert len(hits) == 2, f"两个方向各应告警一次,实得 {len(hits)} 次" + async def test_each_tier_of_one_model_earns_its_own_warning(self): + """同一源同一模型的两个强度档是**两个独立的矛盾**,不得共用一个节流键。 + + 节流键沿用旧的 `enable_thinking` 三态时,两次请求的键逐字相同(都是 + `None`——档位根本不经过那个字段),于是 `max` 档的矛盾被 `low` 档那次 + 永久静音。档位化后 low 与 max 各喊一次,重复的 low 仍只喊一次。 + """ + transport = _transport_for(self._zero_signal) + source = _source(name="zp", provider="zhipu", model="glm-5.3") + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + await _complete(transport, source, reasoning_effort=Effort.LOW) + await _complete(transport, source, reasoning_effort=Effort.LOW) + await _complete(transport, source, reasoning_effort=Effort.MAX) + finally: + logger.remove(sink_id) + hits = [m for m in messages if "glm-5.3" in m] + assert len(hits) == 2, f"low 与 max 应各告警一次,实得 {len(hits)} 次" + + def _zero_signal(self, request): + """零推理信号的成功响应 → UNKNOWN,与"要求开启"矛盾(M3 实测形态)。""" + return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE)) + + +class TestAppliedTierLeavesTheTransport: + """本次**实际**发出去的档必须随 TransportResult 上浮(设计 §4.1 / 计划 T8-5)。 + + 不上浮就只能由遥测自己再算一遍请求档,而 `nearest` 映射后两者不同——压测 + 要按档分组的那一列会挂在一个从未真正发出过的档下,且错得看不出来。 + """ + + def _ok(self, request): + return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE)) + + async def test_result_carries_the_mapped_tier_not_the_requested_one(self): + """glm-5.3 只有 low/high/max: 请求 `medium`,实际发出的是 `low`。""" + transport = _transport_for(self._ok) + source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest") + result = await _complete(transport, source, reasoning_effort=Effort.MEDIUM) + assert result.applied_effort is Effort.LOW + + async def test_result_carries_the_tier_that_was_asked_for_when_supported(self): + transport = _transport_for(self._ok) + source = _source(provider="zhipu", model="glm-5.3") + result = await _complete(transport, source, reasoning_effort=Effort.MAX) + assert result.applied_effort is Effort.MAX + + async def test_no_statement_stays_none(self): + """不表态时库既不注入也不推定模型默认档——"没看见"不许说成"发生了"。""" + transport = _transport_for(self._ok) + result = await _complete(transport, _source()) + assert result.applied_effort is None + class TestNonStreamFastPath: async def test_non_stream_parses_message(self): diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index 4a7c852..96d566d 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -288,6 +288,33 @@ class TestObservabilityPassthrough: resp = await mw(_REQ) assert resp.thinking_observation is ThinkingObservation.UNKNOWN + async def test_applied_tier_reaches_the_response(self): + """issue #20: 实际发出的档由 transport 裁定,本层只搬运。 + + 搬运这一步漏掉,`LLMResponse.applied_effort` 恒为 None,而遥测正是从这个 + 字段取"这一行跑在哪档"——整列会静默地全是 NULL。 + """ + 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={}, + applied_effort=Effort.HIGH, + ) + mw, *_ = _harness([_src("a")], [result]) + resp = await mw(_REQ) + assert resp.applied_effort is Effort.HIGH + + async def test_unstated_tier_stays_none(self): + """不表态的调用不得被填成某个档: 那等于替调用方声称它做过一个选择。""" + mw, *_ = _harness([_src("a")], [_ok()]) + resp = await mw(_REQ) + assert resp.applied_effort is None + class TestRetryAndFailover: async def test_transient_switches_source_then_succeeds(self): diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 54934ef..3057d96 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -445,10 +445,14 @@ class TestResolveThinking: class TestReconcileThinking: - """声明 × 观测对账(设计 §5): 矛盾出文案,不表态出 None。 + """声明 × 观测对账(设计 §4.3): 矛盾出文案,不表态出 None。 文案本身是被断言对象——判定与日志分离正是为此: 告警内容可直接比对,不必 去解析日志格式。 + + 判据自 2026-09-05 起是**档位**而非布尔(设计 §4.3): `Effort.NONE` 走"要求 + 关闭"一支,其余档走"要求开启"一支。档位化不是换个参数名——文案里写的是本次 + 真正发出去的那一档,而 transport 的节流键正按它分离,两者必须同源。 """ _CAP = ThinkingCapability( @@ -458,7 +462,7 @@ class TestReconcileThinking: def test_off_but_observed_with_a_registered_capability_blames_the_table(self): """已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。""" msg = reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.OBSERVED, capability=self._CAP, model="MiniMax-M3", @@ -471,7 +475,7 @@ class TestReconcileThinking: def test_off_but_observed_unregistered_never_claims_a_table_entry(self): """未登记模型没有"能力表声称"这回事——说它就是撒谎。""" msg = reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.OBSERVED, capability=None, model="MiniMax-M9", @@ -483,13 +487,13 @@ class TestReconcileThinking: def test_registered_and_unregistered_wordings_differ(self): registered = reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.OBSERVED, capability=self._CAP, model="MiniMax-M3", ) unregistered = reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.OBSERVED, capability=None, model="MiniMax-M3", @@ -500,7 +504,7 @@ class TestReconcileThinking: def test_on_but_absent_is_a_contradiction(self, capability): """上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。""" msg = reconcile_thinking( - enable_thinking=True, + effort=Effort.AUTO, observation=ThinkingObservation.ABSENT, capability=capability, model="qwen3.7-plus", @@ -512,7 +516,7 @@ class TestReconcileThinking: def test_on_but_unknown_admits_it_cannot_confirm(self, capability): """issue #17 的诚实版本: 明说"我注入了,但我看不见结果"。""" msg = reconcile_thinking( - enable_thinking=True, + effort=Effort.AUTO, observation=ThinkingObservation.UNKNOWN, capability=capability, model="MiniMax-M3", @@ -529,7 +533,7 @@ class TestReconcileThinking: """ assert ( reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.ABSENT, capability=self._CAP, model="qwen3.7-plus", @@ -541,7 +545,7 @@ class TestReconcileThinking: """UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。""" assert ( reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.UNKNOWN, capability=self._CAP, model="MiniMax-M3", @@ -557,7 +561,7 @@ class TestReconcileThinking: """调用方不表态,就无从谈"违背"。""" assert ( reconcile_thinking( - enable_thinking=None, + effort=None, observation=observation, capability=self._CAP, model="MiniMax-M3", @@ -568,7 +572,7 @@ class TestReconcileThinking: def test_on_and_observed_is_exactly_what_was_asked_for(self): assert ( reconcile_thinking( - enable_thinking=True, + effort=Effort.AUTO, observation=ThinkingObservation.OBSERVED, capability=self._CAP, model="MiniMax-M3", @@ -576,6 +580,56 @@ class TestReconcileThinking: is None ) + @pytest.mark.parametrize("effort", [Effort.LOW, Effort.HIGH, Effort.MAX]) + def test_a_strength_tier_is_an_on_request_not_an_off_one(self, effort): + """强度档必须走"要求开启"一支: 观测到推理正是它要的结果,不得报警。 + + 判据写成真值性(`if not effort`)会在这里翻车——`Effort.NONE` 的取值是 + 非空串 `"none"`,恒为真;那种写法会把每一个强度档都送进"要求关闭"分支, + 于是"想了"被当成矛盾,而"没想"反倒沉默,告警方向整个颠倒。 + """ + assert ( + reconcile_thinking( + effort=effort, + observation=ThinkingObservation.OBSERVED, + capability=ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "构造"), + model="glm-5.3", + ) + is None + ) + + def test_the_wording_names_the_tier_that_was_asked_for(self): + """文案要写出**本次这一档**: 节流键按档分离,文案不分档就看不出是哪一档。""" + low = reconcile_thinking( + effort=Effort.LOW, + observation=ThinkingObservation.ABSENT, + capability=None, + model="glm-5.3", + ) + max_ = reconcile_thinking( + effort=Effort.MAX, + observation=ThinkingObservation.ABSENT, + capability=None, + model="glm-5.3", + ) + assert low is not None and max_ is not None + assert "low" in low and "max" in max_ + assert low != max_ + + def test_none_and_observed_is_the_issue_20_contradiction(self): + """请求 `none` 却观测到推理 —— issue #20 要恢复的那条报警,判据是**档位相等**。 + + 与上一条互为对照: 同样是 OBSERVED,`none` 必须喊、强度档必须沉默。把分支 + 条件写反(`is not Effort.NONE`)会让这两条同时红,单有一条则抓不住。 + """ + msg = reconcile_thinking( + effort=Effort.NONE, + observation=ThinkingObservation.OBSERVED, + capability=None, + model="glm-5.3", + ) + assert msg is not None and "none" in msg + class TestEffortVocabulary: """八档封闭词汇(设计 §3.1);`auto` 不可省——9 个纯开关型模型无强度档可填。"""