From 1307a02b929c6ff9716d17bd2779380b6fa04c9f Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 26 Aug 2026 02:37:24 -0400 Subject: [PATCH] fix: close the failure modes review found in the new code Three of them were the same shape as the bug this branch exists to fix: something goes wrong, the library swallows it, and the caller is left with a number that means the opposite of what happened. The throttle key had no source in it. Five sources on one model is the normal case here, so the first one to break would warn once and silence the other four for the life of the process, and the message never said which gateway to look at. An unknown verdict in a cached entry threw away the whole response. The rehydrator tolerates unknown fields but not unknown values of a known field, so two library versions sharing a Redis would each invalidate the other's entries: halved hit rate, and the only log line says the cache rebuild failed. A purely observational field should not be able to void a response whose content is intact. Normalising for telemetry now degrades instead of raising, both for a bare string and for a value outside the domain. Either one used to reach the same except and cost the whole row, which is exactly how 1.3.0 lost nineteen calls without anyone noticing. --- src/polygateway/middleware/cache.py | 30 ++++++++++-- src/polygateway/middleware/telemetry.py | 37 +++++++++++++-- src/polygateway/thinking.py | 6 ++- src/polygateway/transports/openai_compat.py | 34 ++++++++++---- tests/e2e/test_thinking_live.py | 25 ++++++++++ tests/unit/test_cache.py | 39 ++++++++++++++-- tests/unit/test_openai_compat.py | 41 ++++++++++++++-- tests/unit/test_telemetry.py | 52 +++++++++++++++++++++ tests/unit/test_thinking.py | 36 +++++++++++++- 9 files changed, 270 insertions(+), 30 deletions(-) diff --git a/src/polygateway/middleware/cache.py b/src/polygateway/middleware/cache.py index 52f0fa1..2e386bf 100644 --- a/src/polygateway/middleware/cache.py +++ b/src/polygateway/middleware/cache.py @@ -28,6 +28,31 @@ _KEY_PREFIX = "pgw:cache:" _RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)} +def _coerce_observation(raw: Any) -> ThinkingObservation: + """缓存里的三态取值 → 枚举;域外取值降级为 `UNKNOWN`,**不作废整条缓存**。 + + 方向选择的理由: `_rehydrate` 对 JSON 里的**新字段**已经是宽容的(先按 + `_RESPONSE_FIELDS` 过滤),对同一字段的**新取值**却不该是致命的。真实场景是 + 多个项目共用一个 Redis,先升级的那个写入了本版没有的取值,未升级的项目若把 + 这些条目判成未命中,就会每次真打网关、随后覆写回旧值,两个版本互相打对方的 + 缓存(表现是命中率莫名腰斩,而通用的"重建失败"文案给不出任何线索)。一个纯 + 可观测性字段不该有能力废掉内容完好的缓存响应——"整条作废"留给真正破坏内容 + 完整性的失败(JSON 坏了、结构化重建不过)。 + + 降级到 `UNKNOWN` 而不是别的态: 它的语义恰好就是"本次判不出来",对一个本库 + 读不懂的取值,这是唯一诚实的说法。 + """ + try: + return ThinkingObservation(raw) + except ValueError: + logger.warning( + "缓存条目的 thinking_observation 取值 {!r} 不在本版取值域内(多半由更新版本的" + "进程写入),已降级为 UNKNOWN;响应内容照常复活——可观测性字段不作废缓存", + raw, + ) + return ThinkingObservation.UNKNOWN + + def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。 @@ -134,10 +159,9 @@ class CacheMW: structured_data = self._rebuild_structured(fields.get("content", ""), request) # JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉 # (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False); - # 键缺失即升级前写入的旧条目,交给 dataclass 默认值。域外值抛 - # ValueError,由下方 except 吞成"按未命中回源"——降级方向正确。 + # 键缺失即升级前写入的旧条目,交给 dataclass 默认值 if "thinking_observation" in fields: - fields["thinking_observation"] = ThinkingObservation(fields["thinking_observation"]) + fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"]) fields.update( cache_hit=True, latency_ms=0, diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index 04251de..b330199 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -55,6 +55,31 @@ def _canonical_meta_json(meta: Mapping[str, Any]) -> str: return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False) +def _normalize_observation(raw: object) -> str: + """三态裁定 → 落库用的裸 str;不是枚举也不在取值域时降级为 `unknown` 并告警。 + + **不写 `raw.value`**: `LLMResponse` 是无运行时校验的 frozen dataclass,下游 + (尤其迁移期的测试替身)写 `LLMResponse(..., thinking_observation="observed")` + 完全自然、`==` 比较照常成立,而 `.value` 会当场抛 `AttributeError`,被 `_record` + 的 `except Exception` 吞成一条泛化 warning —— 丢的不是这一列,是**整行**,而 + "遥测必录"是铁律。 + + 域外取值同样只降级不抛: 直接 `ThinkingObservation(raw)` 会抛 `ValueError`, + 落到同一个 `except` 上、同样丢整行,那只修好了裸 str 一半(口误值对测试替身 + 一样自然)。降级到 `unknown` 是诚实的——库确实判不出这个取值的含义,而单独 + 一条点名取值的 warning 保证它不被掩盖(P5 不许默认值掩盖错误)。 + """ + try: + return ThinkingObservation(raw).value + except ValueError: + logger.warning( + "thinking_observation 取值 {!r} 不在取值域内,本行降级记为 unknown" + "(其余列照常落库);调用方应传 ThinkingObservation 成员", + raw, + ) + return ThinkingObservation.UNKNOWN.value + + def _cap_text(text: str, cap: int | None) -> str: """超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。""" if cap is None or len(text) <= cap: @@ -285,7 +310,9 @@ class TelemetryEmitter: model_reported: str | None, sampling: str | None, reasoning_tokens: int | None, - # issue #16: 枚举形态进来,取 `.value` 后才下沉(归一化同样在本方法内收口) + # issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。 + # 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation` + # 仍按外部输入防御——违约的代价不该是丢掉整行遥测 thinking_observation: ThinkingObservation, # issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库) tenant_id: str | None, @@ -339,10 +366,10 @@ class TelemetryEmitter: # 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行 tenant_id=tenant_id or "", meta=_canonical_meta_json(meta), - # 取 `.value` 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数 - # 编码对子类不保证接受,而遥测写失败只降级成一条 warning——不会当场 - # 炸,只会让 Postgres 那一路悄悄少一列数据 - thinking_observation=thinking_observation.value, + # 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不 + # 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让 + # Postgres 那一路悄悄少一列数据 + thinking_observation=_normalize_observation(thinking_observation), ) except asyncio.CancelledError: raise diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 36c17d0..7806d8c 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -32,7 +32,11 @@ def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> Thinking """ if thinking.strip(): return ThinkingObservation.OBSERVED - if reasoning_tokens is None: + # 负数与 None 同档: `ABSENT` 是"上游明确上报未推理"这个最强的正面结论,坏 + # 数据给不出它。当前 transport 已在边界把负数归 None,这里仍要自己闭合——本 + # 函数对外承诺"外部输入校验后使用",第二个 transport 直接填该值时,漏判会 + # 给出一个方向相反的强结论(P5) + if reasoning_tokens is None or reasoning_tokens < 0: return ThinkingObservation.UNKNOWN return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index bc34bb6..01c879c 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -322,9 +322,12 @@ class OpenAICompatTransport: # 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。 # 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律 self._warned_models: set[str] = set() - # 对账告警独立节流,**不复用** `_warned_models`: 那个 set 的语义是"未登记 - # 能力已告警过",两件事共用一个开关会互相压制——一方喊过就把另一方静音 - self._warned_mismatches: set[tuple[str, bool | None]] = set() + # 对账告警独立节流,**不复用** `_warned_models`: 两者语义不同(那个 set 记 + # 的是"未登记能力已告警过",这个记的是"某源某方向的矛盾已告警过"),共用 + # 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略, + # 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制; + # 分开维护的理由是语义,不是碰撞) + self._warned_mismatches: set[tuple[str, str, bool | None]] = set() self._client_factory = client_factory or _default_client_factory self._clients: dict[str, httpx.AsyncClient] = {} @@ -410,11 +413,20 @@ class OpenAICompatTransport: return result def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None: - """声明与观测矛盾即 warning;按 (model, direction) 节流,同组合只喊一次。 + """声明与观测矛盾即 warning;按 (source, model, direction) 节流,同组合只喊一次。 - 节流键必须含方向: 同一模型的开、关两档是两个独立的矛盾,合并键会让先出现 - 的那一档把另一档永久静音。逐次调用刷屏会把告警变成噪声,噪声等于没有告警。 + 三段缺一不可。**方向**: 同一模型的开、关两档是两个独立的矛盾。**源名**: + 多源多账号是本库的核心场景,同一 model 跨 N 个源是常态,而每个源背后是 + 独立的账号/网关,一个源的行为不代表另一个——漏掉源名,5 个源里第一个出 + 问题的喊完一次,其余四个永久静音。逐次调用刷屏会把告警变成噪声,噪声等于 + 没有告警。 + + **先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串, + 而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。 """ + key = (source.name, source.model, source.enable_thinking) + if key in self._warned_mismatches: + return message = reconcile_thinking( enable_thinking=source.enable_thinking, observation=result.thinking_observation, @@ -423,11 +435,13 @@ class OpenAICompatTransport: ) if message is None: return - key = (source.model, source.enable_thinking) - if key in self._warned_mismatches: - return self._warned_mismatches.add(key) - logger.warning(message) + # 源名拼在调用点而不是加进 `reconcile_thinking` 的签名: 那是纯判定函数, + # 输入只该含判定依据(声明/观测/能力/模型),源名是**定位信息**,进不了判据。 + # 单参数传入 loguru: 文案里带 `thinking:{type:disabled}` 这类字面花括号 + # (能力表 evidence),将来有人给这行加个格式化参数就会炸在成功调用的返回 + # 路径上(与 telemetry/sqlite.py 的缺列告警同一先例) + logger.warning("源 {} —— {}", source.name, message) async def embed( self, *, texts: list[str], source: SourceConfig, call_id: str diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py index b146915..db35e85 100644 --- a/tests/e2e/test_thinking_live.py +++ b/tests/e2e/test_thinking_live.py @@ -413,6 +413,31 @@ class TestOtherProviders: ) assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}" + async def test_qwen_enabled_is_observed(self): + """设计 §14 验收: qwen 开启档必须裁定为 `OBSERVED`,不是 `UNKNOWN`。 + + 本条是三态裁定的**跨供应商对照组**: MiniMax 这一路两个信号都可能缺失 + (非流式档整片 `UNKNOWN`),若只按它调判据,很容易把"观测不到"当成常态; + qwen 在同一网关同一 key 上照常返回推理信号(findings 2026-08-25 §2), + 故这里能且必须要求正面结论——它一旦掉成 `UNKNOWN`,说明的是库的组装路径 + 丢了信号,而不是上游行为变了。 + """ + matrix, provider, model = "L6b", "qwen", "qwen3.7-plus" + desc = f"{provider} enable_thinking=True" + try: + obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=True) + except (AllSourcesExhausted, SourceDeadError, TransientError) as exc: + _skip_if_unreachable(exc, matrix, desc) + ons = [o for o in obs if _reasoning_on(o)] + _record( + matrix, + desc, + "PASS" if len(ons) * 2 > len(obs) else "FAIL", + f"{len(ons)}/{len(obs)} 轮观测到推理(OBSERVED)", + obs, + ) + assert len(ons) * 2 > len(obs), f"{provider} 开启方向要求多数轮 OBSERVED: {obs}" + class TestCapabilityDrift: """L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。""" diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index 156e6a1..290ba64 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -5,6 +5,7 @@ import hashlib import json import pytest +from loguru import logger from polygateway.backends.memory.cache import InMemoryCache from polygateway.errors import ResultInvalidError, TransientError @@ -266,19 +267,47 @@ class TestThinkingObservationRehydration: assert isinstance(hit.thinking_observation, ThinkingObservation) assert hit.thinking_observation is ThinkingObservation.OBSERVED - async def test_illegal_value_falls_back_to_source(self): - """污染值(旧版本写入或人为篡改)按未命中回源,不得复活出域外取值。""" + async def test_unknown_value_degrades_to_unknown_and_still_hits(self): + """域外取值降级为 UNKNOWN,内容照常复活——不得因此作废整条缓存。 + + 真实场景: 三项目共用一个 Redis,先升级的项目写入了本版没有的第四态, + 未升级的两个项目若把它判成未命中,就会在这些 key 上每次真打网关、随后 + 覆写回旧值,两个版本互相打对方的缓存(表现是命中率莫名腰斩)。一个纯 + 可观测性字段不该有能力废掉内容完好的缓存响应。 + """ backend = InMemoryCache() mw = _mw(backend) key = build_cache_key("m", _MSGS, "proj", None) - poisoned = dataclasses.asdict(_resp(content="poisoned")) - poisoned["thinking_observation"] = "bogus" + poisoned = dataclasses.asdict(_resp(content="from-a-newer-version")) + poisoned["thinking_observation"] = "partially_observed" poisoned.pop("structured_data", None) await backend.set(key, json.dumps(poisoned), 3600) terminal = _Terminal(_resp()) + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + resp = await mw(ChatRequest(messages=_MSGS), terminal) + finally: + logger.remove(sink_id) + assert terminal.calls == 0 and resp.cache_hit is True + assert resp.content == "from-a-newer-version" # 内容完好,照常复活 + assert resp.thinking_observation is ThinkingObservation.UNKNOWN + # 单独一条讲清原因的 warning: 通用的"重建失败"没有任何线索指向真因 + hits = [m for m in messages if "partially_observed" in m] + assert len(hits) == 1, f"域外取值必须单独告警一次,实得 {len(hits)} 条: {messages}" + assert "thinking_observation" in hits[0] + assert [m for m in messages if "重建失败" in m] == [] + + async def test_a_broken_payload_still_falls_back_to_source(self): + """对照组: 内容完整性真被破坏时,仍必须按未命中回源(降级方向不变)。""" + backend = InMemoryCache() + mw = _mw(backend) + key = build_cache_key("m", _MSGS, "proj", None) + await backend.set(key, "{not json at all", 3600) + terminal = _Terminal(_resp()) resp = await mw(ChatRequest(messages=_MSGS), terminal) assert terminal.calls == 1 and resp.cache_hit is False - assert resp.content == "cached" # 回源结果,不是被污染的那条 + assert resp.content == "cached" async def test_legacy_entry_without_key_rehydrates_to_default(self): """升级前写入的条目没有该键,必须照常复活并落到默认 UNKNOWN。""" diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index e2f365b..2791828 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -547,10 +547,11 @@ class TestThinkingObservationVerdict: class TestThinkingReconciliation: - """对账告警按 (model, direction) 节流(设计 §5)。 + """对账告警按 (source, model, direction) 节流(设计 §5)。 - 节流键必须含方向: 同一模型的开、关两档是两个独立的矛盾,合并键会让先出现 - 的那一档把另一档永久静音。 + 键的三段缺一不可,理由同源: 合并任意一段,都会让先出现的那一组把另一组 + 永久静音——同一模型的开/关两档是两个独立的矛盾,同一模型的两个源背后是 + 两个独立的账号/网关。 """ def _handler(self, request): @@ -563,9 +564,9 @@ class TestThinkingReconciliation: # 开启档却零信号 → UNKNOWN,无法确认是否生效(M3 实测形态) return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE)) - def _minimax(self, enable_thinking): + def _minimax(self, enable_thinking, name="mm"): return _source( - name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking + name=name, provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking ) async def test_same_model_and_direction_warns_only_once(self): @@ -581,6 +582,36 @@ class TestThinkingReconciliation: hits = [m for m in messages if "MiniMax-M3" in m] assert len(hits) == 1, f"同一 (model, direction) 应只告警一次,实得 {len(hits)} 次" + async def test_each_source_gets_its_own_warning(self): + """多源多账号是本库的核心场景: 同一 model 跨 N 个源不得只喊第一个。 + + 节流键漏掉源标识时,5 个共用同一模型的源里第一个出问题的喊完一次,其余 + 四个**永久静音**——而每个源背后是独立的账号/网关,它们的行为互不代表。 + """ + transport = _transport_for(self._handler) + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + await _complete(transport, self._minimax(False, name="gw-a")) + await _complete(transport, self._minimax(False, name="gw-b")) + finally: + logger.remove(sink_id) + hits = [m for m in messages if "MiniMax-M3" in m] + assert len(hits) == 2, f"两个源各应告警一次,实得 {len(hits)} 次" + + async def test_the_warning_names_the_source(self): + """拿到告警的人得知道该查哪个网关: 只报模型名定位不到源。""" + transport = _transport_for(self._handler) + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + await _complete(transport, self._minimax(False, name="gw-a")) + finally: + logger.remove(sink_id) + hits = [m for m in messages if "MiniMax-M3" in m] + assert len(hits) == 1 + assert "gw-a" in hits[0], f"告警未点名出问题的源: {hits[0]}" + async def test_switching_direction_earns_a_second_warning(self): transport = _transport_for(self._handler) messages: list[str] = [] diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index e2294e0..c91188e 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -9,6 +9,7 @@ import subprocess from pathlib import Path import pytest +from loguru import logger from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.limiter import InMemoryLimiter @@ -1169,6 +1170,57 @@ class TestEmitterThinkingObservation: assert value == "observed" assert type(value) is str # 不是 ThinkingObservation: 子类实例不得下沉到 recorder + async def test_a_bare_string_verdict_still_lands(self): + """下游填裸 str 时**整行**不得丢失(遥测必录)。 + + `LLMResponse` 是无运行时校验的 frozen dataclass,写 + `LLMResponse(..., thinking_observation="observed")` 完全自然且 `==` 比较 + 照常成立;若 emitter 直接取 `.value`,这里会抛 `AttributeError` 并被 + `_record` 的 `except Exception` 吞成一条泛化 warning——丢的不是这一列, + 是整行,正是 1.3.0 那次"19 次调用一行未落"的同款形态。 + """ + rec = _MemoryRecorder() + await TelemetryEmitter(rec, text_cap=None).emit_attempt( + request=_REQ, + source=_source(), + call_id="c", + latency_ms=1, + response=_resp(thinking_observation="observed"), + error=None, + ) + assert len(rec.rows) == 1, "整行被吞了" + value = rec.rows[0]["thinking_observation"] + assert value == "observed" + assert type(value) is str + + async def test_an_out_of_domain_verdict_degrades_but_keeps_the_row(self): + """域外取值挡在落库前,但**降级不丢行**: 列的取值域由库守,代价不是整行。 + + 直接 `ThinkingObservation(x).value` 会在这里抛 `ValueError`,同样被 + `_record` 的 `except Exception` 吞成丢整行——那只修好了裸 str 一半, + 口误值(大小写不符、拼错)对测试替身同样自然。故降级为 `unknown` + (对库而言本次确实判不出来)并单独告警,与缓存回放的方向选择一致。 + """ + rec = _MemoryRecorder() + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + await TelemetryEmitter(rec, text_cap=None).emit_attempt( + request=_REQ, + source=_source(), + call_id="c", + latency_ms=1, + response=_resp(thinking_observation="OBSERVED"), # 大小写不符即域外 + error=None, + ) + finally: + logger.remove(sink_id) + assert len(rec.rows) == 1, "整行被吞了" + assert rec.rows[0]["thinking_observation"] == "unknown" + hits = [m for m in messages if "OBSERVED" in m] + assert len(hits) == 1, f"域外取值必须单独告警: {messages}" + assert [m for m in messages if "遥测记录失败" in m] == [] + async def test_cache_hit_replays_the_recorded_verdict(self): """缓存命中回放历史那次的裁定: 与 model/prompt_tokens 同一口径。""" rec = _MemoryRecorder() diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 75cacb6..c6fa423 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -66,6 +66,18 @@ class TestObserveThinking: observe_thinking(thinking="想了想", reasoning_tokens=0) is ThinkingObservation.OBSERVED ) + @pytest.mark.parametrize("negative", [-1, -205]) + def test_negative_token_count_is_not_evidence_of_absence(self, negative): + """负数是坏数据,不是"上游明确上报未推理"这个最强的正面结论。 + + 当前 transport 已在边界把负数归 `None`,所以这条走不通;但本函数的 + docstring 自称"外部输入校验后使用",第二个 transport 直接填该值时, + `> 0 else ABSENT` 会给出一个方向相反的强结论。函数自身必须闭合(P5)。 + """ + assert observe_thinking(thinking="", reasoning_tokens=negative) is ( + ThinkingObservation.UNKNOWN + ) + class TestThinkingObservationEnum: def test_values_are_stable_strings(self): @@ -85,7 +97,12 @@ class TestThinkingObservationEnum: @pytest.mark.parametrize("bogus", ["", "OBSERVED", "yes", "none"]) def test_unknown_strings_are_rejected(bogus): - """非法值必须抛 ValueError: 缓存回放靠它把污染数据挡成"未命中"(设计 §6)。""" + """非法值必须抛 ValueError: 缓存回放与遥测归一化都靠它识别域外取值(设计 §6)。 + + 两处接住这个 ValueError 后**降级而非作废**(缓存复活内容 + 记 UNKNOWN、遥测 + 照常落行),但降级的前提是构造器真的会拒绝——它一旦放行,域外取值就会一路 + 进到 `LLMResponse` 与遥测列里。 + """ with pytest.raises(ValueError): ThinkingObservation(bogus) @@ -239,6 +256,23 @@ class TestReconcileThinking: assert msg is not None assert "MiniMax-M3" in msg + def test_off_and_absent_stays_silent(self): + """要求关闭 + 上游明确上报未推理 = 要求被满足,没有可报的矛盾。 + + 这一格与 `test_off_and_unknown_stays_silent` 的沉默理由**不同**: 那里是 + "没有证伪力",这里是"正面证实要求已满足"。两者都必须沉默,漏测哪一格, + 把 Phase 2 的判据写成 `is ABSENT` 之类的反向条件都不会被抓住。 + """ + assert ( + reconcile_thinking( + enable_thinking=False, + observation=ThinkingObservation.ABSENT, + capability=self._CAP, + model="qwen3.7-plus", + ) + is None + ) + def test_off_and_unknown_stays_silent(self): """UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。""" assert (