From bd9da4c91191d536b502299cc76d43054b0684bb Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 5 Sep 2026 05:05:28 -0400 Subject: [PATCH] fix: bring a cached tier back as a tier, not as a bare string Adding applied_effort to LLMResponse put it through the cache round trip, where JSON stores a StrEnum as its plain value. Rehydrated raw, a hit would hand downstream a str while the annotation says Effort, and every `is Effort.LOW` in the library would quietly answer False on the hit path only -- the same trap thinking_observation already has a coercion for. A value outside this version's vocabulary degrades to None rather than failing the entry: projects sharing one Redis would otherwise keep invalidating each other's writes over an attribution field, and None is the honest reading of a tier this version cannot name. --- src/polygateway/middleware/cache.py | 25 +++++++++++ tests/unit/test_cache.py | 64 ++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/polygateway/middleware/cache.py b/src/polygateway/middleware/cache.py index bc9cb3e..abdc2e7 100644 --- a/src/polygateway/middleware/cache.py +++ b/src/polygateway/middleware/cache.py @@ -53,6 +53,29 @@ def _coerce_observation(raw: Any) -> ThinkingObservation: return ThinkingObservation.UNKNOWN +def _coerce_applied_effort(raw: Any) -> Effort | None: + """缓存里的档位字符串 → 枚举;域外取值降级为 `None`,**不作废整条缓存**。 + + 与 `_coerce_observation` 同源同向,理由逐条相同: 多项目共用一个 Redis 时, + 先升级的进程可能写入本版没有的档位名,未升级的进程若把这些条目判成未命中, + 两个版本就会互相打对方的缓存。归因字段不该有能力废掉内容完好的响应。 + + 降级到 `None` 而不是别的档: 它的语义是"库不知道这次跑在哪档",对一个读不懂 + 的取值这是唯一诚实的说法——随便挑一档等于替上游声称了一件它没说过的事。 + """ + if raw is None: + return None + try: + return Effort(raw) + except ValueError: + logger.warning( + "缓存条目的 applied_effort 取值 {!r} 不在本版档位词汇内(多半由更新版本的" + "进程写入),已降级为 None;响应内容照常复活——归因字段不作废缓存", + raw, + ) + return None + + def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。 @@ -176,6 +199,8 @@ class CacheMW: # 键缺失即升级前写入的旧条目,交给 dataclass 默认值 if "thinking_observation" in fields: fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"]) + if "applied_effort" in fields: + fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"]) fields.update( cache_hit=True, latency_ms=0, diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index 9b079ba..a46069b 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -154,7 +154,6 @@ class TestKeyFormula: reasoning_effort=None, ) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b") - def test_declared_tier_key_is_a_golden(self): """配了档位那一侧同样要有 golden: 字面量变了就是所有该档缓存冷启动。 @@ -402,6 +401,69 @@ class TestThinkingObservationRehydration: assert hit.thinking_observation is ThinkingObservation.UNKNOWN +class TestAppliedEffortRehydration: + """issue #20: 实际档同样必须复活成枚举,理由与 `thinking_observation` 逐条相同。 + + JSON 里存的是 `StrEnum` 的字符串值;不转就复活成裸 str,而库内一路是 + `is Effort.LOW` 的身份比较——命中路径上会静默判否,且下游拿到的类型与字段 + 注解分叉。缓存是档位的**第三条入口**(另两条是 `.env` 解析与 `chat()` 参数), + 归一化不变式必须在这里也闭合。 + """ + + async def test_hit_replays_enum_instance_not_bare_str(self): + backend = InMemoryCache() + mw = _mw(backend) + terminal = _Terminal(_resp(applied_effort=Effort.LOW)) + await mw(ChatRequest(messages=_MSGS), terminal) + hit = await mw(ChatRequest(messages=_MSGS), terminal) + assert hit.cache_hit is True and terminal.calls == 1 + assert isinstance(hit.applied_effort, Effort) + assert hit.applied_effort is Effort.LOW + + async def test_unknown_tier_degrades_to_none_and_still_hits(self): + """域外档位降级为 None(=不知道这次跑在哪档),不作废内容完好的条目。 + + 降级方向与 `thinking_observation` 同源: 共用一个 Redis 的项目里,先升级 + 的那个可能写入本版没有的档位名,未升级的项目若判成未命中,两个版本就会 + 互相打对方的缓存。归因字段不该有能力废掉一条内容完好的响应。 + """ + backend = InMemoryCache() + mw = _mw(backend) + key = build_cache_key("m", _MSGS, "proj", None) + poisoned = dataclasses.asdict(_resp(content="from-a-newer-version")) + poisoned["applied_effort"] = "ultra" + 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.applied_effort is None + hits = [m for m in messages if "ultra" in m] + assert len(hits) == 1, f"域外档位必须单独告警一次,实得 {len(hits)} 条: {messages}" + assert "applied_effort" in hits[0] + assert [m for m in messages if "重建失败" in m] == [] + + async def test_legacy_entry_without_key_rehydrates_to_none(self): + """升级前写入的条目没有该键,必须照常复活并落到默认 None。""" + backend = InMemoryCache() + mw = _mw(backend) + key = build_cache_key("m", _MSGS, "proj", None) + legacy = dataclasses.asdict(_resp(content="legacy")) + legacy.pop("applied_effort") + legacy.pop("structured_data", None) + await backend.set(key, json.dumps(legacy), 3600) + terminal = _Terminal(_resp()) + hit = await mw(ChatRequest(messages=_MSGS), terminal) + assert hit.content == "legacy" and terminal.calls == 0 + assert hit.applied_effort is None + + class _BrokenBackend: async def get(self, key): raise ConnectionError("redis down")