fix: revive the reasoning verdict as an enum, not a bare string
asdict keeps the enum and json.dumps writes it as a string because StrEnum is a str subclass, but nothing turns it back on the way in, so a cache hit returned a plain str where the annotation promised an enum. Verified end to end rather than assumed from the subclass relation. A value outside the domain now raises inside the existing guard and the call falls back to source, which is the right direction for a poisoned or stale cache entry. Entries written before this column existed still replay: the guard checks for the key first, and a test pins that, since turning it into an unconditional conversion would quietly turn every pre-upgrade entry into a permanent miss.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user