Files
PolyLoop/tests/integration/test_gateway_model_client.py
T
iomgaa 39417a21ec feat(adapters): 落成网关适配器,十个模块全部有内容
design 0012(待确认)定六条:收一个已经装配好的客户端而不自己装配(治理参数按 §1.5 不归本库
管,而且项目常要在多个用途间共享同一个限流器和缓存);同时收那份配置只为算模型身份(客户端
把源列表与 scope 收在内部不公开);内容块按顺序拼成一个字符串不加分隔符;绑定里只有网关认得
的两个键往下传、其余留在参数快照里且不报错;网关异常原样穿出去不翻译不重试;空串的调用标识
映射成空值。

参数快照不用网关内部那个 build_model_fingerprint:它不在 __all__ 里(用它就得从子模块 import,
他们重排一次我们就断),而且它是为缓存键设计的、按模型名去重。续跑守卫怕的是「配置变了而我
没发现」,所以宁可更严——改一个源名也报出来,那意味着这次运行打的可能是另一个端点。

tests/integration/ 这一层第一次有内容:用的是网关真实的 GatewaySettings / LLMResponse /
异常类型,装配守卫也真的跑了,只把「真的发出去」那一下换成受控替身。没装 polyloop[gateway]
时整份文件跳过——一个因为可选依赖没装而常年红的套件会训练所有人忽略红。

环境:从 ~/Projects/PolyGateway(活版本 1.1.2,比 reference/ 那份 1.1.1 新)复制一份装进
conda 环境。没有配私有源,polygateway 不在任何可达的 index 上。
2026-08-10 03:57:50 -04:00

210 lines
8.0 KiB
Python
Raw 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` 红——一个因为可选依赖没装而常年红的套件会训练所有人忽略红。
"""
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"]