"""网关适配器:把本库的一次模型调用翻译成网关的一次治理调用。 **这一层叫 integration,因为它连的是真的 PolyGateway**(`CLAUDE.md` §1.9 的分层判据是「依赖 什么」):这里用的 `GatewaySettings`、`SourceConfig`、`LLMResponse` 全是网关真实的类型,装配 守卫也真的跑了。它**不打真实模型**——那是 e2e 的事,所以客户端那一下换成一个受控替身。 网关不在公共源上,装它要 `polyloop[gateway]` 那个 extra。没装的时候整份文件跳过,而不是让 `make ci` 红——一个因为可选依赖没装而常年红的套件会训练所有人忽略红。 """ from collections.abc import Mapping 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.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_only_the_binding_keys_the_gateway_has_slots_for_are_forwarded() -> None: """其余的键不往下传也不报错——它们已经进了参数快照,网关那边只是没有格子放。 报错等于要求项目为了适配一个网关而裁剪自己的坐标系,而绑定同时是续跑守卫的输入。 """ stub = _StubClient(_response()) client = GatewayModelClient(client=stub, settings=_settings()) await client.call(_call(binding={"session_id": "s1", "book": "b7", "task": "t3"})) ((_, kwargs),) = stub.calls assert kwargs == {"session_id": "s1"} 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"]