diff --git a/src/polygateway/middleware/cache.py b/src/polygateway/middleware/cache.py index 88a63a7..52f0fa1 100644 --- a/src/polygateway/middleware/cache.py +++ b/src/polygateway/middleware/cache.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any from loguru import logger -from polygateway.types import ChatRequest, LLMResponse +from polygateway.types import ChatRequest, LLMResponse, ThinkingObservation if TYPE_CHECKING: from collections.abc import Mapping @@ -132,6 +132,12 @@ class CacheMW: data = json.loads(raw) fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS} structured_data = self._rebuild_structured(fields.get("content", ""), request) + # JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉 + # (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False); + # 键缺失即升级前写入的旧条目,交给 dataclass 默认值。域外值抛 + # ValueError,由下方 except 吞成"按未命中回源"——降级方向正确。 + if "thinking_observation" in fields: + fields["thinking_observation"] = ThinkingObservation(fields["thinking_observation"]) fields.update( cache_hit=True, latency_ms=0, diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index 1b28405..156e6a1 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -10,7 +10,7 @@ from polygateway.backends.memory.cache import InMemoryCache from polygateway.errors import ResultInvalidError, TransientError from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages from polygateway.middleware.telemetry import TelemetryEmitter -from polygateway.types import ChatRequest, LLMResponse, SourceConfig +from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation _MSGS = [{"role": "user", "content": "hi"}] @@ -249,6 +249,52 @@ class TestObservabilityFieldsOnHit: assert hit.cached_prompt_tokens is None and hit.model_reported is None +class TestThinkingObservationRehydration: + """issue #16/#17: 命中回放必须复活成枚举实例,而不是 JSON 里的裸 str。 + + 裸 str 与字段注解分叉,下游拿 `resp.thinking_observation is + ThinkingObservation.OBSERVED` 判等会在缓存命中路径上静默为 False。 + """ + + async def test_hit_replays_enum_instance_not_bare_str(self): + backend = InMemoryCache() + mw = _mw(backend) + terminal = _Terminal(_resp(thinking_observation=ThinkingObservation.OBSERVED)) + 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.thinking_observation, ThinkingObservation) + assert hit.thinking_observation is ThinkingObservation.OBSERVED + + async def test_illegal_value_falls_back_to_source(self): + """污染值(旧版本写入或人为篡改)按未命中回源,不得复活出域外取值。""" + backend = InMemoryCache() + mw = _mw(backend) + key = build_cache_key("m", _MSGS, "proj", None) + poisoned = dataclasses.asdict(_resp(content="poisoned")) + poisoned["thinking_observation"] = "bogus" + poisoned.pop("structured_data", None) + await backend.set(key, json.dumps(poisoned), 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" # 回源结果,不是被污染的那条 + + async def test_legacy_entry_without_key_rehydrates_to_default(self): + """升级前写入的条目没有该键,必须照常复活并落到默认 UNKNOWN。""" + backend = InMemoryCache() + mw = _mw(backend) + key = build_cache_key("m", _MSGS, "proj", None) + legacy = dataclasses.asdict(_resp(content="legacy")) + legacy.pop("thinking_observation") + 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.thinking_observation is ThinkingObservation.UNKNOWN + + class _BrokenBackend: async def get(self, key): raise ConnectionError("redis down")