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