Files
PolyLoop/tests/integration/test_gateway_model_client.py
T
iomgaa 1fac387e75 feat(testing): 契约套件搬进 polyloop.testing 随包发布,五套全部接上实现
tests/ 不进 wheel,所以那套被 CLAUDE.md §0 称作「任何新适配器的准入标准」的用例,第一个
下游根本拿不到。**接法同时换掉**:pytest 的 conftest 只沿被收集文件的目录链查找,装在
site-packages 里的测试模块看不见下游的 conftest,原来那个「在自己的 conftest 里覆盖同名
fixture」的接法在发布之后走不通。改成继承契约基类,下游的子类定义在自己的目录链上。

**搬的过程中发现这套准入标准从来没被执行过。** test_model_client.py 有四条用例调用
records.model_call(...),而工厂里根本没有这个方法——它没炸是因为那个 fixture 默认 skip。
五个接缝里只有存储那套被真跑过(15 条跳过里有 15 条是这四套)。

所以这个提交的另一半是让它真的跑起来。存储接两个实现(一份契约同时验多个实现,正是换接法
换来的);动作执行接注册表分发器,外加一个有真实等待点的替身,否则那条取消用例的断言半边
永远走不到;模型调用接网关适配器,落在 integration,它连的是真网关;决策解释与事件出口各
接一个测试替身——替身住在 tests/ 里不进 wheel,下游拿不到,所以不违反「库不带默认实现」,
判据是下游拿不拿得到。

**一并清掉两类坏用例。** 五条函数体只有 docstring、一个断言都没有却报 PASSED 的假绿——一个
准入标准里出现假绿比出现跳过糟得多,下游看到全绿会以为验过了。以及一条端口从没承诺过的
长度断言(len(history_text) <= len(reply.content)):压测的 AppWorld 场景为了迁就它,刻意
不补被复刻的实现真的会补的三个反引号,注释里写着「补一个字符就违约」。七条「这一层验不了」
统一成无条件 skip,理由字符串写全「承诺是什么/为什么验不了/你该在哪儿自己验」。

**发一个 pytest11 entry point,只为换回断言重写。** 契约模块不在下游的 python_files 里,
默认不被重写,于是一条契约失败时下游看到的是光秃秃的 AssertionError。不做的话没有任何东西
会报错,纯静默退化。实测过:editable 安装下 entry point 注册了但重写不生效(RECORD 里没有
包文件),要装真 wheel 才验得出来。
2026-08-27 03:59:16 -04:00

251 lines
10 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
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_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"]
# ---------------------------------------------------------------------------
# 模型调用契约(`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(),)),))