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:
2026-08-10 03:57:50 -04:00
parent aeb575e0f7
commit 39417a21ec
3 changed files with 355 additions and 8 deletions
+137 -3
View File
@@ -1,5 +1,139 @@
"""PolyGateway 模型适配器。**须由使用者显式 import。**
"""模型调用接缝的网关适配器。
**这是全库唯一允许 import `polygateway` 的地方。** 不进 `polyloop/__init__.py`一个
顺手提供的默认模型客户端会让每个进程在 import 时把网关连同它的 provider 目录一起拉起来。
**这个模块公开,但必须显式 import**不进 `polyloop/__init__.py``0003` 决策八第 9 条)
顺手导出它会让每个进程在 import 本库时把网关连同它的 provider 目录一起拉起来。它也是本库里
**唯一**允许 import 网关的地方,由 `pyproject.toml` 的一条 import 契约断言。
装它要装 `polyloop[gateway]` 那个 extra。缝两边怎么对上,定在
`research-wiki/design/0012-gateway-model-client.md`。
**这里不写重试、限流、熔断、缓存、遥测**(`CLAUDE.md` §1.5)。网关内部已经有这些,外面再套
一层会让两套预算重叠计费。缺能力就给网关提 PR。
"""
import json
from collections.abc import Mapping, Sequence
from polygateway import GatewayClient, GatewaySettings, SourceConfig
from polyloop.ports import ModelCall
from polyloop.types import ContentBlock, Message, ModelReply, TextBlock
#: 绑定里能被网关认下的那几个键。其余的键留在参数快照里,不往下传。
_FORWARDED_BINDING_KEYS = ("session_id", "parent_call_id")
class GatewayModelClient:
"""把本库的一次模型调用翻译成网关的一次治理调用。
**收一个已经装配好的客户端,不自己装配。** 网关的装配入口收十几个治理参数(限流器、熔断
器、缓存后端、遥测记录器……),那些按 `CLAUDE.md` §1.5 不归本库管;而且项目常常要在多个
用途之间共享同一个限流器和缓存,那只有它自己装配才做得到。
**同时收那份配置,只为算出可复现的模型身份。** 客户端把源列表与 scope 收在内部、不公开,
所以从它本身问不出「这次运行用的是哪个模型配置」。
**调用方要保证这两个参数是它真的配对使用的那一对。** 传一个客户端加另一份配置,参数快照
会说谎,而续跑守卫就白设了——库验不了这件事,客户端不公开它是按哪份配置装的。
它满足 `polyloop.ports.ModelClient`,但不显式继承那个 Protocol:结构化子类型不需要继承。
"""
__slots__ = ("_client", "_settings")
def __init__(self, *, client: GatewayClient, settings: GatewaySettings) -> None:
self._client = client
self._settings = settings
def __repr__(self) -> str:
return f"GatewayModelClient(scope={self._settings.scope!r})"
async def call(self, call: ModelCall) -> ModelReply:
"""发一次调用,把响应收成三个字段。
**网关的异常原样穿出去**,不捕获、不翻译、不重试。上一层已经定了模型调用失败怎么处置
(记一条带失败说明的结果记录、记一步、以模型调用失败收尾),而失败说明取的是异常的
类名与文本——网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它
盖掉。重试尤其不能做:网关内部已经有重试、退避、换源、熔断。
"""
response = await self._client.chat(
[_as_gateway_message(message) for message in call.messages],
**_forwarded_binding(call.binding),
)
return ModelReply(
# 网关那边这个字段的类型是 `str`,而本库这边**绝不为空串**——空串是个看起来合法的
# 键,连表时静默匹配不上,而空值至少能被显式筛出来。
call_id=response.call_id or None,
content=response.content,
thinking=response.thinking,
)
def parameters(self) -> Mapping[str, str]:
"""上报这次装配的模型身份。
每个源报四样:源名、供应商、模型名,以及会改变请求体的那两项(恒定采样参数、推理开关)。
把采样参数算进去是必须的——temperature 从 0 改成 1 之后续跑,模型的行为变了而轨迹上
看不出来。
**比网关自己那个缓存指纹更严**:那个按模型名去重,因为缓存怕的是「不同配置读到同一份
缓存」;续跑守卫怕的是另一件事——「配置变了而我没发现」,所以改一个源名也该报出来,
那意味着这次运行打的可能是另一个端点。
"""
return {
"scope": self._settings.scope,
"sources": _describe_sources(self._settings.sources),
}
def _as_gateway_message(message: Message) -> dict[str, object]:
"""一条消息翻译成网关那边的形状。
**内容块按顺序拼成一个字符串,不加分隔符**——块之间本来就没有分隔符这个概念,加了就是往
模型看见的文字里塞东西。第一版只有文本块;将来有图片块时这里改成多模态数组,那是加分支
不是改签名,正是 `0003` 决策六把内容定成序列而不是裸字符串换来的。
"""
return {
"role": message.role.value,
"content": "".join(_block_text(block) for block in message.content),
}
def _block_text(block: ContentBlock) -> str:
if isinstance(block, TextBlock):
return block.text
raise TypeError(
f"这个适配器还不认得内容块类型 {type(block).__name__}"
"加一种块就要在这里加一条对应的翻译,漏了的话那一块会静默地不进请求"
)
def _forwarded_binding(binding: Mapping[str, str]) -> dict[str, str]:
"""绑定里网关认得的那几个键。
**其余的键不往下传,也不报错。** 绑定是项目自己的坐标(某个下游有五维),而网关只有两个
槽位放得下这类东西。不报错是因为那些键**已经被记下来了**——绑定的全部键值都进运行开始
记录的参数快照(`0006` 决策三),续跑时逐字段比对。报错等于要求项目为了适配一个网关而
裁剪自己的坐标系,而绑定同时是续跑守卫的输入,改它会让所有在跑的运行续不上。
"""
return {key: binding[key] for key in _FORWARDED_BINDING_KEYS if key in binding}
def _describe_sources(sources: Sequence[SourceConfig]) -> str:
"""把源列表压成一个确定的字符串。
按源名排序,免得配置文件里换个顺序就报出一次假的漂移。
"""
described = [
{
"name": source.name,
"provider": source.provider,
"model": source.model,
"extra_body": dict(source.extra_body),
"enable_thinking": source.enable_thinking,
}
for source in sorted(sources, key=lambda source: source.name)
]
return json.dumps(described, sort_keys=True, ensure_ascii=False)
__all__ = ["GatewayModelClient"]