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 上。
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""网关适配器:把本库的一次模型调用翻译成网关的一次治理调用。
|
||||
|
||||
**这一层叫 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"]
|
||||
Reference in New Issue
Block a user