393f2bf617
Grow the telemetry contract from 26 to 36 fields and give every logical call a failure terminal row, so SQL can finally answer "how many calls failed" and "why did the whole pool die". Schema and port move together with the emitter writes in one commit: splitting them would ship columns that nothing populates. - schema: append 10 nullable columns (scope, operation, logical_call_id, event_kind, http_status_code, error_type, cause_type, error_body, attempts, total_latency_ms) to all five definition sites in one order - ports: 10 keyword-only parameters without defaults; the protocol signature is now the single source the assembly gate derives from - emitter: take domain exception objects instead of pre-flattened text and pin down the diagnostics in one helper; a relabelled 503 stays 503 and success rows leave all five columns NULL - emitter: reject recorders whose record_llm_call cannot accept the current field shape at assembly time, since _record would otherwise swallow the TypeError and drop every row while calls keep succeeding - clients: write at most one terminal row per logical call through a single shared exit, deduplicated by the call context; TelemetryMW stops writing terminals so the two sites cannot double count - clients: cancellation stays best effort and propagates, non-domain exceptions get no terminal row and keep their classification - transports: give _status_to_error an explicit operation and fix the historically mislabelled embedding HTTP failures - structured: promote the bounded error formatter so the reask feedback and the terminal explanation share one set of limits Terminal rows carry no cost and no tokens, so cost aggregation is unchanged; failure counts must now filter on event_kind.
894 lines
37 KiB
Python
894 lines
37 KiB
Python
"""CacheMW 与缓存 key 公式测试(ARCH §7.5: 防毒化 key、命中重建、静默降级)。"""
|
|
|
|
import dataclasses
|
|
import hashlib
|
|
import json
|
|
|
|
import pytest
|
|
from loguru import logger
|
|
|
|
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,
|
|
Effort,
|
|
LLMResponse,
|
|
SourceConfig,
|
|
ThinkingObservation,
|
|
)
|
|
|
|
_MSGS = [{"role": "user", "content": "hi"}]
|
|
|
|
|
|
def _resp(content="cached", **overrides):
|
|
base = {
|
|
"content": content,
|
|
"thinking": "",
|
|
"model": "m",
|
|
"provider": "p",
|
|
"prompt_tokens": 1,
|
|
"completion_tokens": 2,
|
|
"latency_ms": 30,
|
|
"ttft_ms": 5.0,
|
|
"max_inter_token_ms": 2.0,
|
|
"cache_hit": False,
|
|
"call_id": "orig",
|
|
"source_name": "s1",
|
|
"usage_source": "measured",
|
|
}
|
|
base.update(overrides)
|
|
return LLMResponse(**base)
|
|
|
|
|
|
class TestKeyFormula:
|
|
def test_same_request_same_key(self):
|
|
k1 = build_cache_key("m", _MSGS, "proj", None)
|
|
k2 = build_cache_key("m", _MSGS, "proj", None)
|
|
assert k1 == k2 and k1.startswith("pgw:cache:")
|
|
|
|
@pytest.mark.parametrize(
|
|
("a", "b"),
|
|
[
|
|
(("m1", _MSGS, "proj", None), ("m2", _MSGS, "proj", None)),
|
|
(("m", _MSGS, "proj", None), ("m", _MSGS, "tenant2", None)),
|
|
(("m", _MSGS, "proj", None), ("m", _MSGS, "proj", "epoch2")),
|
|
(("m", _MSGS, "proj", "s1"), ("m", _MSGS, "proj", "s2")),
|
|
(("m", _MSGS, "proj", None), ("m", [{"role": "user", "content": "yo"}], "proj", None)),
|
|
],
|
|
)
|
|
def test_any_dimension_change_changes_key(self, a, b):
|
|
assert build_cache_key(*a) != build_cache_key(*b)
|
|
|
|
def test_empty_sampling_keeps_legacy_key(self):
|
|
"""空采样参数时键形逐字不变,存量缓存不被全量作废(issue #4 决策 C)。
|
|
|
|
golden 值取自加 sampling 维度之前的实现,不得随实现漂移。
|
|
"""
|
|
assert build_cache_key("qwen-max", [{"role": "user", "content": "hi"}], "proj", None) == (
|
|
"pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b"
|
|
)
|
|
assert build_cache_key("qwen-max", [{"role": "user", "content": "hi"}], "proj", "s1") == (
|
|
"pgw:cache:eed9cd9cc06acc0dedf4f337b74e06ed3482afdc30fa2acedd194f6cc1df33bf"
|
|
)
|
|
|
|
def test_differing_seed_changes_key(self):
|
|
"""issue #4 的直接回归: 5 个 seed 若共用一个 key,标准差会恒为 0。"""
|
|
k1 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 1})
|
|
k2 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 2})
|
|
assert k1 != k2
|
|
|
|
def test_sampling_key_order_irrelevant(self):
|
|
k1 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 1, "temperature": 0})
|
|
k2 = build_cache_key("m", _MSGS, "proj", None, sampling={"temperature": 0, "seed": 1})
|
|
assert k1 == k2
|
|
|
|
def test_empty_sampling_equals_omitted(self):
|
|
"""空 dict 与不传须同键,否则升级后存量缓存全部 miss。"""
|
|
assert build_cache_key("m", _MSGS, "proj", None, sampling={}) == build_cache_key(
|
|
"m", _MSGS, "proj", None
|
|
)
|
|
|
|
def test_multimodal_part_digested_not_inlined(self):
|
|
big_b64 = "data:image/png;base64," + "A" * 1_000_000
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "image_url", "image_url": {"url": big_b64}},
|
|
{"type": "text", "text": "describe"},
|
|
],
|
|
}
|
|
]
|
|
digested = digest_messages(messages)
|
|
payload = json.dumps(digested, ensure_ascii=False)
|
|
assert len(payload) < 500 # 大图不进 canonical_json
|
|
expected = hashlib.sha256(big_b64.encode()).hexdigest()
|
|
assert expected in payload # 但字节变化仍改变 key
|
|
# 图像字节变化 → key 变
|
|
messages2 = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "image_url", "image_url": {"url": big_b64[:-1] + "B"}},
|
|
{"type": "text", "text": "describe"},
|
|
],
|
|
}
|
|
]
|
|
assert build_cache_key("m", messages, "p", None) != build_cache_key(
|
|
"m", messages2, "p", None
|
|
)
|
|
|
|
def test_request_tier_changes_key(self):
|
|
"""同 messages 跑 low 与 max 不得互相命中(issue #20;issue #4 的逐字翻版)。
|
|
|
|
请求级档位必须**独立于** `model_fingerprint` 进 key: 后者是装配期算出的
|
|
集合级指纹,一次调用改档位不会让它变一个字节。
|
|
"""
|
|
k_low = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.LOW)
|
|
k_max = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.MAX)
|
|
assert k_low != k_max
|
|
|
|
def test_explicit_none_tier_is_not_the_absent_tier(self):
|
|
"""`None`(不表态)与 `Effort.NONE`(要求不推理)是两个 key。
|
|
|
|
二者合并即毒化: "没写档位"的调用会读到"明确关掉推理"那次的响应,
|
|
而后者的内容恰恰是缺推理过程的。
|
|
"""
|
|
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
|
|
"m", _MSGS, "proj", None, reasoning_effort=Effort.NONE
|
|
)
|
|
|
|
def test_absent_tier_keeps_legacy_key(self):
|
|
"""不表态档位时键形逐字不变,存量缓存不被本次升级全量作废。
|
|
|
|
golden 值与 `test_empty_sampling_keeps_legacy_key` 同源,取自加
|
|
`reasoning_effort` 维度之前的实现,不得随实现漂移。
|
|
"""
|
|
assert build_cache_key(
|
|
"qwen-max",
|
|
[{"role": "user", "content": "hi"}],
|
|
"proj",
|
|
None,
|
|
reasoning_effort=None,
|
|
) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b")
|
|
|
|
def test_declared_tier_key_is_a_golden(self):
|
|
"""配了档位那一侧同样要有 golden: 字面量变了就是所有该档缓存冷启动。
|
|
|
|
存量(不表态)那侧的 golden 由 `test_absent_tier_keeps_legacy_key` 守着,
|
|
而"档位怎么写进 key"此前没有任何字面量断言——变异实测把 `str(...)` 换成
|
|
`repr(...)`,全套件依然全绿(2026-09-05 独立验证查出)。
|
|
"""
|
|
assert build_cache_key(
|
|
"qwen-max",
|
|
[{"role": "user", "content": "hi"}],
|
|
"proj",
|
|
None,
|
|
reasoning_effort=Effort.LOW,
|
|
) == ("pgw:cache:21d7be93729635b27d4ee54e0e7e7310554bb04faf08919f8ce83794ac33f575")
|
|
assert build_cache_key(
|
|
"qwen-max",
|
|
[{"role": "user", "content": "hi"}],
|
|
"proj",
|
|
"s1",
|
|
reasoning_effort=Effort.NONE,
|
|
) == ("pgw:cache:44f4f1ce1ee39e5003a27f4f21a531554cb54d1c66e936d11008d4ff06ddc5e6")
|
|
|
|
|
|
class _Terminal:
|
|
def __init__(self, response):
|
|
self.response = response
|
|
self.calls = 0
|
|
|
|
async def __call__(self, request):
|
|
self.calls += 1
|
|
if isinstance(self.response, Exception):
|
|
raise self.response
|
|
return self.response
|
|
|
|
|
|
def _mw(backend, **kwargs):
|
|
defaults = {
|
|
"backend": backend,
|
|
"model_fingerprint": "m",
|
|
"default_namespace": "proj",
|
|
"ttl_s": 3600,
|
|
}
|
|
defaults.update(kwargs)
|
|
return CacheMW(**defaults)
|
|
|
|
|
|
class TestCacheFlow:
|
|
async def test_miss_then_hit_with_fresh_call_id(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
terminal = _Terminal(_resp())
|
|
first = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert first.cache_hit is False and terminal.calls == 1
|
|
second = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert second.cache_hit is True and second.latency_ms == 0
|
|
assert second.content == "cached"
|
|
assert second.call_id != first.call_id # 命中生成独立 cache_call_id
|
|
assert terminal.calls == 1 # 未再触达内层
|
|
|
|
async def test_differing_sampling_does_not_hit(self):
|
|
"""issue #4 的中间件层回归: 逐 rollout 变 seed 必须回源,不得复用响应。"""
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
terminal = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
|
await mw(ChatRequest(messages=_MSGS, sampling={"seed": 2}), terminal)
|
|
assert terminal.calls == 2 # 两次都回源
|
|
# 同 seed 才命中
|
|
third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
|
assert third.cache_hit is True and terminal.calls == 2
|
|
|
|
async def test_differing_reasoning_effort_does_not_hit(self):
|
|
"""接线门: `CacheMW` 必须把 `request.reasoning_effort` 传进 key 公式。
|
|
|
|
只测 `build_cache_key` 不够——参数加了却没人传是本改动最可能的落地方式,
|
|
那种缺口在公式层的用例里完全看不见。
|
|
"""
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
terminal = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
|
|
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.MAX), terminal)
|
|
assert terminal.calls == 2 # 两档各自回源
|
|
third = await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
|
|
assert third.cache_hit is True and terminal.calls == 2 # 同档才命中
|
|
|
|
async def test_structured_injection_does_not_pollute_key(self):
|
|
"""CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。"""
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
terminal = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
|
polluted = ChatRequest(
|
|
messages=_MSGS,
|
|
sampling={"seed": 1},
|
|
overlay={"seed": 1, "response_format": {"type": "json_object"}},
|
|
)
|
|
assert (await mw(polluted, terminal)).cache_hit is True
|
|
assert terminal.calls == 1
|
|
|
|
async def test_per_call_namespace_overrides_default(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
terminal = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS, cache_namespace="tenant-a"), terminal)
|
|
# 另一租户不得命中
|
|
await mw(ChatRequest(messages=_MSGS, cache_namespace="tenant-b"), terminal)
|
|
assert terminal.calls == 2
|
|
|
|
async def test_failure_not_cached(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
failing = _Terminal(TransientError("boom"))
|
|
with pytest.raises(TransientError):
|
|
await mw(ChatRequest(messages=_MSGS), failing)
|
|
ok = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS), ok)
|
|
assert ok.calls == 1 # 失败未被固化,正常回源
|
|
|
|
async def test_ttl_expiry(self):
|
|
t = {"now": 0.0}
|
|
backend = InMemoryCache(now=lambda: t["now"])
|
|
mw = _mw(backend, ttl_s=100)
|
|
terminal = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
|
t["now"] = 101.0
|
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert terminal.calls == 2
|
|
|
|
|
|
class TestObservabilityFieldsOnHit:
|
|
"""issue #3 决策 B1: 命中行原样回放,与 model/prompt_tokens 同一口径。"""
|
|
|
|
async def test_fields_replayed_on_hit(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
terminal = _Terminal(
|
|
_resp(cached_prompt_tokens=64, model_reported="MiniMax-Text-01-250321")
|
|
)
|
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert hit.cache_hit is True
|
|
assert hit.cached_prompt_tokens == 64
|
|
assert hit.model_reported == "MiniMax-Text-01-250321"
|
|
|
|
async def test_legacy_cache_entry_without_new_keys_rehydrates(self):
|
|
"""旧格式条目(无这两个键)必须照常重建为 None,不得抛异常回源。"""
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
key = build_cache_key("m", _MSGS, "proj", None)
|
|
legacy = {
|
|
"content": "legacy",
|
|
"thinking": "",
|
|
"model": "m",
|
|
"provider": "p",
|
|
"prompt_tokens": 1,
|
|
"completion_tokens": 2,
|
|
"latency_ms": 30,
|
|
"ttft_ms": 5.0,
|
|
"max_inter_token_ms": 2.0,
|
|
"cache_hit": False,
|
|
"call_id": "orig",
|
|
"source_name": "s1",
|
|
"cost": None,
|
|
"usage_source": "measured",
|
|
}
|
|
await backend.set(key, json.dumps(legacy), ttl_s=100)
|
|
terminal = _Terminal(_resp())
|
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert hit.content == "legacy" and terminal.calls == 0 # 真的走了缓存
|
|
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_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="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"
|
|
|
|
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 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")
|
|
|
|
async def set(self, key, value, ttl_s):
|
|
raise ConnectionError("redis down")
|
|
|
|
|
|
class TestDegradation:
|
|
async def test_backend_failure_degrades_silently(self):
|
|
mw = _mw(_BrokenBackend())
|
|
terminal = _Terminal(_resp())
|
|
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert resp.content == "cached" and terminal.calls == 1 # 读写全降级,调用照常
|
|
|
|
async def test_corrupt_cache_value_treated_as_miss(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
key = build_cache_key("m", _MSGS, "proj", None)
|
|
await backend.set(key, "{not json", 3600)
|
|
terminal = _Terminal(_resp())
|
|
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert terminal.calls == 1 and resp.cache_hit is False
|
|
|
|
|
|
class _FakeStrategy:
|
|
"""fake StructuredOutputStrategy(T2 冻结的 Protocol,不依赖 T11)。"""
|
|
|
|
def request_overlay(self, schema):
|
|
return {}
|
|
|
|
def parse(self, text):
|
|
data = json.loads(text) # 简化: 直接 json.loads
|
|
if not isinstance(data, dict):
|
|
raise ResultInvalidError("非对象", raw_text=text)
|
|
return data
|
|
|
|
|
|
class _StrictModel:
|
|
"""鸭子型 pydantic 模型: model_validate 要求含 answer 键。"""
|
|
|
|
@classmethod
|
|
def model_validate(cls, data):
|
|
if "answer" not in data:
|
|
raise ValueError("missing answer")
|
|
return {"validated": data["answer"]}
|
|
|
|
|
|
class TestStructuredRehydration:
|
|
async def test_hit_rebuilds_structured_data(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend, strategy=_FakeStrategy())
|
|
terminal = _Terminal(_resp(content='{"answer": 42}'))
|
|
req = ChatRequest(messages=_MSGS, structured=_StrictModel)
|
|
await mw(req, terminal)
|
|
hit = await mw(req, terminal)
|
|
assert hit.cache_hit is True
|
|
assert hit.structured_data == {"validated": 42}
|
|
assert terminal.calls == 1
|
|
|
|
async def test_schema_change_revalidation_failure_falls_back_to_source(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend, strategy=_FakeStrategy())
|
|
terminal = _Terminal(_resp(content='{"other": 1}')) # 缓存内容不含 answer
|
|
await mw(ChatRequest(messages=_MSGS), terminal) # 无 structured 写入
|
|
# 换 schema 读: 重校验失败 → 按未命中回源
|
|
again = await mw(ChatRequest(messages=_MSGS, structured=_StrictModel), terminal)
|
|
assert terminal.calls == 2 and again.cache_hit is False
|
|
|
|
async def test_structured_data_not_serialized_into_cache(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend, strategy=_FakeStrategy())
|
|
terminal = _Terminal(
|
|
dataclasses.replace(_resp(content='{"answer": 1}'), structured_data={"x": object()})
|
|
)
|
|
await mw(ChatRequest(messages=_MSGS), terminal) # 不可 JSON 的 structured_data 不阻塞写缓存
|
|
key = build_cache_key("m", _MSGS, "proj", None)
|
|
raw = await backend.get(key)
|
|
assert raw is not None and "structured_data" not in json.loads(raw)
|
|
|
|
|
|
class TestTelemetryCapDoesNotPoisonTheCacheKey:
|
|
"""红线之一(issue #12): 遥测截断绝不能改到缓存 key。
|
|
|
|
`digest_messages` 对 content 非 list 的消息**原样透传同一个 dict 对象**
|
|
(本文件上方公式测试依赖的也是这份对象),遥测拿到的与算 key 用的是同一份。
|
|
就地截断会让同一组 messages 在遥测前后算出两个不同的 key——全量 miss、
|
|
且没有任何报错。故这里测的是"截断没有就地改掉调用方的对象",不只是
|
|
"截断函数是纯的"。
|
|
"""
|
|
|
|
class _Rows:
|
|
def __init__(self):
|
|
self.rows = []
|
|
|
|
async def record_llm_call(self, **fields):
|
|
self.rows.append(fields)
|
|
|
|
async def test_key_is_byte_identical_across_a_capped_emit(self):
|
|
messages = [
|
|
{"role": "user", "content": "合同正文" * 31},
|
|
{"role": "user", "content": [{"type": "text", "text": "标书正文" * 30}]},
|
|
]
|
|
before = build_cache_key("m", messages, "proj", None)
|
|
|
|
rec = self._Rows()
|
|
await TelemetryEmitter(rec, text_cap=8, scope="LLM").emit_attempt(
|
|
request=ChatRequest(messages=messages),
|
|
source=SourceConfig(
|
|
name="s1",
|
|
provider="p",
|
|
base_url="https://gw.example/v1",
|
|
api_key="sk",
|
|
model="m",
|
|
timeout_s=10.0,
|
|
),
|
|
call_id="c",
|
|
latency_ms=1,
|
|
response=_resp(),
|
|
error=None,
|
|
reasoning_applies=True,
|
|
operation="chat",
|
|
)
|
|
# 截断确实发生了(否则本用例恒真)
|
|
logged = json.loads(rec.rows[0]["messages"])
|
|
assert "(略 116 字)" in logged[0]["content"]
|
|
assert "(略 112 字)" in logged[1]["content"][0]["text"]
|
|
|
|
assert build_cache_key("m", messages, "proj", None) == before
|
|
|
|
|
|
class TestExplicitCacheMigration:
|
|
"""相同模型身份不代表相同推理策略,隔离必须由调用方显式选择。"""
|
|
|
|
def _client(self, cache, source, *, capabilities=None, registry=None):
|
|
import httpx
|
|
|
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
|
from tests.unit.test_client import _client, _sse
|
|
|
|
sent = []
|
|
|
|
def handler(request):
|
|
payload = json.loads(request.content)
|
|
sent.append(payload)
|
|
return _sse(json.dumps(payload, sort_keys=True))
|
|
|
|
transport = OpenAICompatTransport(
|
|
client_factory=lambda src: httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
capabilities=capabilities,
|
|
registry=registry,
|
|
)
|
|
client = _client(
|
|
sources=[source],
|
|
transport=transport,
|
|
cache=cache,
|
|
cache_namespace="tenant-a",
|
|
cache_ttl_s=60,
|
|
)
|
|
return client, transport, sent
|
|
|
|
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
|
|
@pytest.mark.parametrize("change", ["capability", "fallback"])
|
|
async def test_capability_change_requires_explicit_identity(self, isolation, change):
|
|
from polygateway.client import build_model_fingerprint
|
|
from polygateway.errors import RequestRejectedError
|
|
from polygateway.thinking import ThinkingCapability
|
|
from tests.unit.test_client import _source
|
|
|
|
cache = InMemoryCache()
|
|
source = _source(provider="openai", model="migration-model")
|
|
if change == "capability":
|
|
old_cap = ThinkingCapability((Effort.AUTO, Effort.HIGH), "本地旧声明")
|
|
new_cap = ThinkingCapability((Effort.HIGH,), "本地新声明")
|
|
tier = Effort.AUTO
|
|
new_source = source
|
|
else:
|
|
old_cap = new_cap = ThinkingCapability((Effort.LOW, Effort.HIGH), "本地映射声明")
|
|
source = dataclasses.replace(source, effort_fallback="nearest")
|
|
new_source = dataclasses.replace(source, effort_fallback="error")
|
|
tier = Effort.MEDIUM
|
|
assert build_model_fingerprint([source]) == build_model_fingerprint([new_source])
|
|
old, old_transport, old_sent = self._client(
|
|
cache, source, capabilities={source.model: old_cap}
|
|
)
|
|
new, new_transport, new_sent = self._client(
|
|
cache, new_source, capabilities={source.model: new_cap}
|
|
)
|
|
identity = (
|
|
{"cache_namespace": "tenant-a:migrated"}
|
|
if isolation == "namespace"
|
|
else {"cache_salt": "migrated"}
|
|
)
|
|
try:
|
|
original = await old.chat(_MSGS, reasoning_effort=tier)
|
|
replay = await new.chat(_MSGS, reasoning_effort=tier)
|
|
assert replay.cache_hit and replay.content == original.content
|
|
assert len(old_sent) == 1 and not new_sent
|
|
with pytest.raises(RequestRejectedError):
|
|
await new.chat(_MSGS, reasoning_effort=tier, **identity)
|
|
assert not new_sent
|
|
assert (await old.chat(_MSGS, reasoning_effort=tier)).cache_hit
|
|
finally:
|
|
await old_transport.aclose()
|
|
await new_transport.aclose()
|
|
|
|
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
|
|
async def test_custom_wire_change_requires_explicit_identity(self, isolation):
|
|
from polygateway.client import build_model_fingerprint
|
|
from polygateway.providers import ProviderProfile, ThinkingWire
|
|
from polygateway.thinking import ThinkingCapability
|
|
from tests.unit.test_client import _source
|
|
|
|
source = _source(provider="custom", model="migration-model")
|
|
caps = {source.model: ThinkingCapability((Effort.HIGH,), "本地声明")}
|
|
|
|
def profile(key):
|
|
return {
|
|
"custom": ProviderProfile(
|
|
name="custom",
|
|
thinking=ThinkingWire(off=None, on_base={}, effort_key=key),
|
|
strip_think_tags=False,
|
|
)
|
|
}
|
|
|
|
cache = InMemoryCache()
|
|
old, t1, sent1 = self._client(cache, source, capabilities=caps, registry=profile("depth_a"))
|
|
new, t2, sent2 = self._client(cache, source, capabilities=caps, registry=profile("depth_b"))
|
|
assert build_model_fingerprint(old._terminal._sources) == build_model_fingerprint(
|
|
new._terminal._sources
|
|
)
|
|
identity = (
|
|
{"cache_namespace": "tenant-a:migrated"}
|
|
if isolation == "namespace"
|
|
else {"cache_salt": "migrated"}
|
|
)
|
|
try:
|
|
original = await old.chat(_MSGS, reasoning_effort=Effort.HIGH)
|
|
assert (await new.chat(_MSGS, reasoning_effort=Effort.HIGH)).cache_hit
|
|
migrated = await new.chat(_MSGS, reasoning_effort=Effort.HIGH, **identity)
|
|
assert not migrated.cache_hit and migrated.content != original.content
|
|
assert len(sent1) == len(sent2) == 1
|
|
assert sent2[0]["depth_b"] == "high" and "depth_a" not in sent2[0]
|
|
assert (await old.chat(_MSGS, reasoning_effort=Effort.HIGH)).content == original.content
|
|
finally:
|
|
await t1.aclose()
|
|
await t2.aclose()
|
|
|
|
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
|
|
async def test_legacy_raw_override_requires_explicit_identity(self, isolation):
|
|
from polygateway.client import build_model_fingerprint
|
|
from polygateway.errors import RequestRejectedError
|
|
from tests.unit.test_client import _source
|
|
|
|
source = _source(
|
|
provider="openai", reasoning_effort="high", extra_body={"reasoning_effort": "low"}
|
|
)
|
|
cache = InMemoryCache()
|
|
key = build_cache_key(build_model_fingerprint([source]), _MSGS, "tenant-a", None)
|
|
legacy = dataclasses.asdict(_resp(content="legacy-raw-low", applied_effort=Effort.HIGH))
|
|
legacy.pop("structured_data", None)
|
|
await cache.set(key, json.dumps(legacy), 60)
|
|
client, transport, sent = self._client(cache, source)
|
|
identity = (
|
|
{"cache_namespace": "tenant-a:migrated"}
|
|
if isolation == "namespace"
|
|
else {"cache_salt": "migrated"}
|
|
)
|
|
try:
|
|
assert (await client.chat(_MSGS)).content == "legacy-raw-low"
|
|
with pytest.raises(RequestRejectedError, match="冲突"):
|
|
await client.chat(_MSGS, **identity)
|
|
assert sent == []
|
|
assert await cache.get(key) is not None
|
|
finally:
|
|
await transport.aclose()
|
|
|
|
async def test_per_call_namespace_survives_a_changed_factory_default(self):
|
|
from polygateway import GatewayClient, GatewaySettings
|
|
from tests.unit.test_client import _ENV, _source
|
|
|
|
cache = InMemoryCache()
|
|
source = _source()
|
|
old, transport, sent = self._client(cache, source)
|
|
try:
|
|
await old.chat(_MSGS, cache_namespace="tenant-a")
|
|
# 工厂路径和全量注入配置同模型身份;只改默认不能改变显式租户覆盖。
|
|
settings = GatewaySettings.from_env(
|
|
env={
|
|
**_ENV,
|
|
"PGW_CACHE_BACKEND": "memory",
|
|
"PGW_CACHE_NAMESPACE": "changed-default",
|
|
"PGW_CACHE_TTL_S": "60",
|
|
}
|
|
)
|
|
new = GatewayClient.from_settings(settings, cache=cache)
|
|
try:
|
|
assert (await new.chat(_MSGS, cache_namespace="tenant-a")).cache_hit
|
|
assert len(sent) == 1
|
|
finally:
|
|
await new.aclose()
|
|
finally:
|
|
await transport.aclose()
|
|
|
|
async def test_shared_source_pool_migration_preserves_tenant_boundaries(self):
|
|
import httpx
|
|
|
|
from polygateway.errors import RequestRejectedError
|
|
from polygateway.thinking import ThinkingCapability
|
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
|
from tests.unit.test_client import _client, _source, _sse
|
|
|
|
sources = [
|
|
_source(name=name, provider="openai", model="shared-model") for name in ("a", "b")
|
|
]
|
|
cache = InMemoryCache()
|
|
sent = []
|
|
|
|
def handler(request):
|
|
sent.append(request)
|
|
return _sse()
|
|
|
|
transports = [
|
|
OpenAICompatTransport(
|
|
client_factory=lambda src: httpx.AsyncClient(
|
|
transport=httpx.MockTransport(handler)
|
|
),
|
|
capabilities={"shared-model": ThinkingCapability(choices, "本地声明")},
|
|
)
|
|
for choices in ((Effort.AUTO, Effort.HIGH), (Effort.HIGH,))
|
|
]
|
|
clients = [
|
|
_client(
|
|
sources=sources, transport=t, cache=cache, cache_namespace="default", cache_ttl_s=60
|
|
)
|
|
for t in transports
|
|
]
|
|
try:
|
|
for tenant in ("tenant-a", "tenant-b"):
|
|
await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
|
|
assert (
|
|
await clients[1].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
|
|
).cache_hit
|
|
with pytest.raises(RequestRejectedError):
|
|
await clients[1].chat(
|
|
_MSGS, reasoning_effort="auto", cache_namespace=tenant + ":new"
|
|
)
|
|
assert len(sent) == 2
|
|
for tenant in ("tenant-a", "tenant-b"):
|
|
assert (
|
|
await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
|
|
).cache_hit
|
|
finally:
|
|
for transport in transports:
|
|
await transport.aclose()
|
|
|
|
|
|
class TestCallStatsNotPoisoned:
|
|
"""缓存不得回放历史统计(1.3.5 设计 §3)。
|
|
|
|
统计描述**本次**调用;把上次那条存进去再放出来,等于对调用方谎称这次
|
|
重试了 N 次、耗了 M 毫秒。
|
|
"""
|
|
|
|
async def test_serialized_payload_carries_no_call_stats_key(self):
|
|
from polygateway.types import CallStats
|
|
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
stats = CallStats(logical_call_id="lc-1", attempts=3, total_latency_ms=900)
|
|
terminal = _Terminal(_resp(call_stats=stats))
|
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
|
key = build_cache_key("m", _MSGS, "proj", None)
|
|
stored = json.loads(await backend.get(key))
|
|
assert "call_stats" not in stored # asdict 会把它摊成 dict,必须显式剔除
|
|
|
|
async def test_historic_dict_never_impersonates_call_stats(self):
|
|
"""旧条目里的 `call_stats` dict 会被 `_RESPONSE_FIELDS` 放行,必须显式覆盖。
|
|
|
|
不覆盖就会有一个 dict 冒充 `CallStats` 从公共 API 漏给调用方,
|
|
`resp.call_stats.attempts` 当场 `AttributeError`。
|
|
"""
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
key = build_cache_key("m", _MSGS, "proj", None)
|
|
poisoned = {
|
|
"content": "legacy",
|
|
"thinking": "",
|
|
"model": "m",
|
|
"provider": "p",
|
|
"prompt_tokens": 1,
|
|
"completion_tokens": 2,
|
|
"latency_ms": 30,
|
|
"ttft_ms": 5.0,
|
|
"max_inter_token_ms": 2.0,
|
|
"cache_hit": False,
|
|
"call_id": "orig",
|
|
"source_name": "s1",
|
|
"usage_source": "measured",
|
|
"call_stats": {
|
|
"logical_call_id": "stale-lc",
|
|
"attempts": 7,
|
|
"total_latency_ms": 9999,
|
|
},
|
|
}
|
|
await backend.set(key, json.dumps(poisoned), ttl_s=100)
|
|
terminal = _Terminal(_resp())
|
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert hit.content == "legacy" and terminal.calls == 0 # 真的走了缓存
|
|
assert hit.call_stats is None # dict 不得冒充 CallStats
|
|
|
|
async def test_cache_key_is_unchanged_by_the_new_field(self):
|
|
"""新增内部字段不得扰动 key 公式,否则存量缓存全量冷启动(黄金值)。"""
|
|
from polygateway.types import _CallContext
|
|
|
|
class _Clock:
|
|
def __call__(self):
|
|
return 1000.0
|
|
|
|
ctx = _CallContext(now=_Clock())
|
|
bare = build_cache_key("m", _MSGS, "proj", None)
|
|
assert bare == build_cache_key("m", _MSGS, "proj", None)
|
|
# 带上下文的请求与不带的请求必须落在同一个 key 上
|
|
with_ctx = ChatRequest(messages=_MSGS, call_context=ctx)
|
|
without = ChatRequest(messages=_MSGS)
|
|
assert with_ctx.cache_namespace == without.cache_namespace
|
|
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
|