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.
This commit is contained in:
@@ -53,6 +53,29 @@ def _coerce_observation(raw: Any) -> ThinkingObservation:
|
|||||||
return ThinkingObservation.UNKNOWN
|
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]]:
|
def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
|
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
|
||||||
|
|
||||||
@@ -176,6 +199,8 @@ class CacheMW:
|
|||||||
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
||||||
if "thinking_observation" in fields:
|
if "thinking_observation" in fields:
|
||||||
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
||||||
|
if "applied_effort" in fields:
|
||||||
|
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
|
||||||
fields.update(
|
fields.update(
|
||||||
cache_hit=True,
|
cache_hit=True,
|
||||||
latency_ms=0,
|
latency_ms=0,
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ class TestKeyFormula:
|
|||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b")
|
) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b")
|
||||||
|
|
||||||
|
|
||||||
def test_declared_tier_key_is_a_golden(self):
|
def test_declared_tier_key_is_a_golden(self):
|
||||||
"""配了档位那一侧同样要有 golden: 字面量变了就是所有该档缓存冷启动。
|
"""配了档位那一侧同样要有 golden: 字面量变了就是所有该档缓存冷启动。
|
||||||
|
|
||||||
@@ -402,6 +401,69 @@ class TestThinkingObservationRehydration:
|
|||||||
assert hit.thinking_observation is ThinkingObservation.UNKNOWN
|
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:
|
class _BrokenBackend:
|
||||||
async def get(self, key):
|
async def get(self, key):
|
||||||
raise ConnectionError("redis down")
|
raise ConnectionError("redis down")
|
||||||
|
|||||||
Reference in New Issue
Block a user