Files
PolyLoop/tests/integration/test_gateway_model_client.py
iomgaa fd9cae7da5 fix(adapters): 转发改按 gateway. 前缀,四条防御各自报错
落实 0017。模块级白名单换成三个常量:保留前缀、结构性参数拒绝集合、历史裸键元组。
_forwarded_binding 按排序后的键遍历(多个键同时违规时报出来的总是同一个),带前缀的剥掉前缀
当关键字参数名,不带前缀的照旧不传也不报错。

**这是一次破坏性的行为变更**:绑定里不带前缀的 session_id 与 parent_call_id 从静默转发变成抛
ValueError,错误信息里给出 gateway.session_id 这个改法。静默不传是又一次静默的行为变更——
下游的网关遥测会悄悄不再按会话分组而没有任何提示;不设弃用期是因为那要求这一版继续按旧机制
转发,等于把要拆的撞名机制再留一个版本。

空值那条防御拒的是「空串或纯空白」,不只是空串。这一条是 Codex 对抗审查抓出来的:空白在网关
那边是真值,会被原样当成命名空间用,于是所有拿到这份坏配置的租户共用同一格,正是 issue #6
那个跨租户串读场景换了个入口。判据是这个取值带不带信息,不是格式对不对——本库不解释绑定的
取值,值原样转发不做 strip,"acme:v1:tenant:" 这种少了一截的它拦不住也不该拦。

四条判断都在 chat() 的实参求值阶段完成,所以出错那次调用一次都没发出去,每条用例都断言了
替身的 calls 为空。空值那条用例是 2 个参数名 × 3 种取值的参数化——独立审查指出单参数版本
钉不住「对所有带前缀的键一视同仁」:把实现写成只认 cache_namespace 也照样绿,而那个错实现下
gateway.cache_salt="" 会被转发成一次读到缓存的调用。

371 passed / 16 skipped,六条 import 契约全 KEPT。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:53:00 -04:00

340 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""网关适配器:把本库的一次模型调用翻译成网关的一次治理调用。
**这一层叫 integration,因为它连的是真的 PolyGateway**`CLAUDE.md` §1.9 的分层判据是「依赖
什么」):这里用的 `GatewaySettings`、`SourceConfig`、`LLMResponse` 全是网关真实的类型,装配
守卫也真的跑了。它**不打真实模型**——那是 e2e 的事,所以客户端那一下换成一个受控替身。
网关不在公共源上,装它要 `polyloop[gateway]` 那个 extra。没装的时候整份文件跳过,而不是让
`make ci` 红——一个因为可选依赖没装而常年红的套件会训练所有人忽略红。
"""
import re
from collections.abc import Mapping
from dataclasses import dataclass
import pytest
polygateway = pytest.importorskip(
"polygateway", reason="没装 polyloop[gateway],网关适配器这一层跳过"
)
from polygateway import GatewaySettings, LLMResponse # noqa: E402
from polygateway.errors import AllSourcesExhausted # noqa: E402
from polyloop.adapters import GatewayModelClient # noqa: E402
from polyloop.ports import ModelCall # noqa: E402
from polyloop.testing import ModelClientContract # noqa: E402
from polyloop.types import Message, Role, TextBlock # noqa: E402
pytestmark = pytest.mark.integration
#: 一份最小但能过网关全部装配守卫的配置。
#: 键的形状是 `{SCOPE}__{PROVIDER}__{序号}__{字段}`,源名由网关拼成 `{provider}_{序号}`。
_ENV = {
"LLM__OPENAI__1__BASE_URL": "https://example.invalid/v1",
"LLM__OPENAI__1__API_KEY": "sk-test",
"LLM__OPENAI__1__MODEL": "test-model",
"LLM__OPENAI__1__TIMEOUT_S": "30",
# 治理参数网关一律要求显式声明,不给默认——配错的代价它自己承担,这里照最小值填。
"LLM__RETRY__MAX_ATTEMPTS": "1",
"LLM__RETRY__BACKOFF_BASE_S": "0.1",
"LLM__RETRY__BACKOFF_MAX_S": "1",
"LLM__BREAKER__FAIL_THRESHOLD": "5",
"LLM__BREAKER__COOLDOWN_S": "10",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
}
def _settings(**overrides: str) -> GatewaySettings:
return GatewaySettings.from_env(scope="LLM", env={**_ENV, **overrides}, env_file="")
class _StubClient:
"""替身客户端:记下收到什么,按脚本返回或抛出。
只替掉「真的发出去」那一下——配置、装配守卫、响应类型都还是网关真的那套。
"""
def __init__(self, response: LLMResponse | BaseException) -> None:
self._response = response
self.calls: list[tuple[list[dict], dict]] = []
async def chat(self, messages, **kwargs):
self.calls.append((messages, kwargs))
if isinstance(self._response, BaseException):
raise self._response
return self._response
def _response(*, call_id: str = "gw-1", content: str = "答案", thinking: str = "想了想"):
return LLMResponse(
content=content,
thinking=thinking,
model="test-model",
provider="openai",
prompt_tokens=10,
completion_tokens=5,
latency_ms=123,
ttft_ms=12.0,
max_inter_token_ms=3.0,
cache_hit=False,
call_id=call_id,
)
def _call(*, messages=None, binding: Mapping[str, str] | None = None) -> ModelCall:
return ModelCall(
messages=messages
or (
Message(role=Role.SYSTEM, content=(TextBlock(text="你是助手"),)),
Message(role=Role.USER, content=(TextBlock(text="数到三"),)),
),
call_index=0,
run_id="r1",
result_id="r1#model#0",
binding=dict(binding or {}),
)
async def test_messages_are_translated_to_role_content_dicts() -> None:
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
await client.call(_call())
((messages, _),) = stub.calls
assert messages == [
{"role": "system", "content": "你是助手"},
{"role": "user", "content": "数到三"},
]
async def test_content_blocks_are_joined_without_a_separator() -> None:
"""块之间本来就没有分隔符这个概念,加了就是往模型看见的文字里塞东西。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
message = Message(role=Role.USER, content=(TextBlock(text="前半"), TextBlock(text="后半")))
await client.call(_call(messages=(message,)))
((messages, _),) = stub.calls
assert messages == [{"role": "user", "content": "前半后半"}]
async def test_the_reply_keeps_only_the_three_fields_this_library_records() -> None:
"""响应有二十来个字段,本库只取三个,其余留在网关的账目里靠调用标识连过去。"""
stub = _StubClient(_response(call_id="gw-7", content="内容", thinking="推理"))
client = GatewayModelClient(client=stub, settings=_settings())
reply = await client.call(_call())
assert reply.call_id == "gw-7"
assert reply.content == "内容"
assert reply.thinking == "推理"
async def test_an_empty_call_id_becomes_no_call_id() -> None:
"""空串是个看起来合法的键,连表时静默匹配不上,而空值至少能被显式筛出来。"""
stub = _StubClient(_response(call_id=""))
client = GatewayModelClient(client=stub, settings=_settings())
assert (await client.call(_call())).call_id is None
async def test_prefixed_binding_keys_are_forwarded_with_the_prefix_stripped() -> None:
"""带 `gateway.` 前缀的键剥掉前缀之后当关键字参数传下去,不带前缀的坐标一个都不传。
本库不认识网关的参数表,认不认得 `cache_namespace` 这种名字是网关的事,所以替身照单全收。
"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
await client.call(
_call(
binding={
"book": "b7",
"task": "t3",
"gateway.cache_namespace": "acme:v1:tenant:x7",
"gateway.tenant_id": "x7",
}
)
)
((_, kwargs),) = stub.calls
assert kwargs == {"cache_namespace": "acme:v1:tenant:x7", "tenant_id": "x7"}
async def test_a_historical_name_still_works_once_it_carries_the_prefix() -> None:
"""`session_id` 这两个名字没有被禁掉,被禁掉的是不带前缀那种写法。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
await client.call(_call(binding={"gateway.session_id": "s1"}))
((_, kwargs),) = stub.calls
assert kwargs == {"session_id": "s1"}
@pytest.mark.parametrize("key", ["session_id", "parent_call_id"])
async def test_a_bare_historical_key_is_rejected_and_the_error_gives_the_new_spelling(
key: str,
) -> None:
"""这两个键从前被静默转发,现在报错——静默不传的话下游的遥测会悄悄不再分组。
错误信息里必须出现改法,撞上的人才知道下一步写什么。
"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(ValueError, match=re.escape(f"gateway.{key}")):
await client.call(_call(binding={key: "v"}))
assert stub.calls == []
@pytest.mark.parametrize("parameter", ["messages", "stream", "structured", "overlay"])
async def test_a_structural_gateway_parameter_is_rejected(parameter: str) -> None:
"""这四个参数改变的是请求本身,而它们的取值另有权威,从绑定走等于让同一件事有两处记录。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(ValueError, match=re.escape(f"gateway.{parameter}")):
await client.call(_call(binding={f"gateway.{parameter}": "v"}))
assert stub.calls == []
@pytest.mark.parametrize("parameter", ["cache_namespace", "cache_salt"])
@pytest.mark.parametrize("value", ["", " ", "\t"])
async def test_a_blank_forwarded_value_is_rejected(parameter: str, value: str) -> None:
"""这条防御对所有带前缀的键一视同仁,不认某个具体的参数名。
空串在网关那边和「没传」分不开,`gateway.cache_namespace=""` 会静默落回默认命名空间;
纯空白更糟——它是个真值,会被当成一个真的命名空间用下去,于是所有配错的租户共用同一格。
"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(ValueError, match=re.escape(f"gateway.{parameter}")):
await client.call(_call(binding={f"gateway.{parameter}": value}))
assert stub.calls == []
async def test_the_bare_prefix_is_rejected() -> None:
"""前缀后面没有名字就没有参数名可传,静默跳过会让人以为自己传出去了。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(ValueError, match=re.escape("gateway.")):
await client.call(_call(binding={"gateway.": "v"}))
assert stub.calls == []
async def test_a_binding_of_plain_coordinates_forwards_nothing_and_raises_nothing() -> None:
"""不带前缀的键已经进了参数快照,报错等于要求项目为了适配网关而裁剪自己的坐标系。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
await client.call(_call(binding={"book": "b7", "task": "t3"}))
((_, kwargs),) = stub.calls
assert kwargs == {}
async def test_gateway_errors_propagate_untranslated() -> None:
"""网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
上一层已经定了怎么处置:记一条带失败说明的结果记录、记一步、以模型调用失败收尾。
"""
stub = _StubClient(
AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0)
)
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(AllSourcesExhausted):
await client.call(_call())
async def test_cancellation_passes_straight_through() -> None:
import asyncio
stub = _StubClient(asyncio.CancelledError())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(asyncio.CancelledError):
await client.call(_call())
def test_the_parameters_describe_the_model_configuration() -> None:
client = GatewayModelClient(client=_StubClient(_response()), settings=_settings())
parameters = client.parameters()
assert parameters["scope"] == "llm" # 网关自己把 scope 收成小写
assert "test-model" in parameters["sources"]
assert "openai" in parameters["sources"]
def test_changing_the_model_changes_the_parameters() -> None:
"""换了模型而快照不变的话,续跑守卫就漏掉了最该拦的那一种改动。"""
before = GatewayModelClient(client=_StubClient(_response()), settings=_settings()).parameters()
after = GatewayModelClient(
client=_StubClient(_response()),
settings=_settings(**{"LLM__OPENAI__1__MODEL": "another-model"}),
).parameters()
assert before["sources"] != after["sources"]
def test_changing_a_sampling_parameter_changes_the_parameters() -> None:
"""temperature 从 0 改成 1 之后续跑,模型的行为变了而轨迹上看不出来——除非它进快照。"""
before = GatewayModelClient(client=_StubClient(_response()), settings=_settings()).parameters()
after = GatewayModelClient(
client=_StubClient(_response()),
settings=_settings(**{"LLM__OPENAI__1__EXTRA_BODY": '{"temperature": 1}'}),
).parameters()
assert before["sources"] != after["sources"]
# ---------------------------------------------------------------------------
# 模型调用契约(`polyloop.testing.ModelClientContract`)接在这一层,不在契约层。
#
# 分层判据是「依赖什么」(`CLAUDE.md` §1.9):这里的配置、装配守卫、响应类型全是网关真的
# 那套,所以它是 integration。`research-wiki/design/0014-contract-suite-distribution.md`
# 决策六那张表里,五个接缝只有这一行落在契约层之外,就是这个原因。
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class _UnsupportedBlock:
"""一种适配器不认得的内容块。
它是 `failing_call` 用来让适配器在入参这一关就挂掉的东西。适配器把每个块翻译成文本,
碰到不认得的类型直接抛——那是它自己的守卫,不是替身编出来的失败。
"""
class TestGatewayModelClient(ModelClientContract):
"""网关适配器要满足模型调用接缝的全部契约。"""
@pytest.fixture
def model_client(self) -> GatewayModelClient:
return GatewayModelClient(client=_StubClient(_response()), settings=_settings())
@pytest.fixture
def failing_call(self) -> ModelCall:
"""一次带着适配器不认得的内容块的调用。
**失败发生在请求打出去之前**:适配器逐块翻译消息,碰到不是文本块的东西直接抛。所以这
条路径不碰替身客户端、不产生任何副作用,客户端实例失败之后照样能接着服务——这三件事
正是 `failing_call` 那份 docstring 要求的。
另一条路是让替身客户端认一个暗号、见到就抛,那等于在实现这一侧重新造出套件刚刚扔掉的
那个约定,而它验的会变成替身的分支写对没有。
"""
return _call(messages=(Message(role=Role.USER, content=(_UnsupportedBlock(),)),))