fd9cae7da5
落实 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>
340 lines
14 KiB
Python
340 lines
14 KiB
Python
"""网关适配器:把本库的一次模型调用翻译成网关的一次治理调用。
|
||
|
||
**这一层叫 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(),)),))
|