diff --git a/src/polyloop/adapters/__init__.py b/src/polyloop/adapters/__init__.py index de13a6a..fc571d1 100644 --- a/src/polyloop/adapters/__init__.py +++ b/src/polyloop/adapters/__init__.py @@ -19,8 +19,14 @@ 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") +#: 绑定里的保留前缀:带它的键才往下传给网关(`design/0017-gateway-forwarding.md` 决策一)。 +_GATEWAY_BINDING_PREFIX = "gateway." + +#: 会改变请求本身、因而不接受从绑定走的网关参数名(同上决策四第一条)。 +_STRUCTURAL_GATEWAY_PARAMETERS = frozenset({"messages", "stream", "structured", "overlay"}) + +#: 前缀落地之前按名字撞着转发的两个裸键,现在报错并给出改法(同上决策四第三条)。 +_LEGACY_BARE_KEYS = ("session_id", "parent_call_id") class GatewayModelClient: @@ -36,6 +42,15 @@ class GatewayModelClient: **调用方要保证这两个参数是它真的配对使用的那一对。** 传一个客户端加另一份配置,参数快照 会说谎,而续跑守卫就白设了——库验不了这件事,客户端不公开它是按哪份配置装的。 + **绑定里 `gateway.` 是保留前缀。** 键名以它开头的,前缀之后那一段当作 `chat()` 的关键字 + 参数名往下传;其余的键是项目自己的坐标,只进参数快照,不往下传:: + + model_binding={"book": "b7", "gateway.cache_namespace": "acme:v1:tenant:x7"} + + 这一份传给网关的是 `cache_namespace="acme:v1:tenant:x7"`,`book` 不传。**本库不解释前缀 + 后面那个名字**,认不认得由网关决定——它不认得的会当场抛 `TypeError`,错误信息里带着那个 + 参数名。有几类名字本库自己就拒了,见 `_forwarded_binding`。 + 它满足 `polyloop.ports.ModelClient`,但不显式继承那个 Protocol:结构化子类型不需要继承。 """ @@ -55,6 +70,9 @@ class GatewayModelClient: (记一条带失败说明的结果记录、记一步、以模型调用失败收尾),而失败说明取的是异常的 类名与文本——网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它 盖掉。重试尤其不能做:网关内部已经有重试、退避、换源、熔断。 + + 绑定里带 `gateway.` 前缀的键剥掉前缀之后一起发出去,那几条拒绝在这一步判,见 + `_forwarded_binding`。 """ response = await self._client.chat( [_as_gateway_message(message) for message in call.messages], @@ -108,14 +126,45 @@ def _block_text(block: ContentBlock) -> str: def _forwarded_binding(binding: Mapping[str, str]) -> dict[str, str]: - """绑定里网关认得的那几个键。 + """绑定里要往下传给网关的那些键。 - **其余的键不往下传,也不报错。** 绑定是项目自己的坐标(某个下游有五维),而网关只有两个 - 槽位放得下这类东西。不报错是因为那些键**已经被记下来了**——绑定的全部键值都进运行开始 - 记录的参数快照(`0006` 决策三),续跑时逐字段比对。报错等于要求项目为了适配一个网关而 - 裁剪自己的坐标系,而绑定同时是续跑守卫的输入,改它会让所有在跑的运行续不上。 + 键名以 `gateway.` 开头的,前缀之后那一段是 `chat()` 的关键字参数名,值原样传下去。 + **不带前缀的键不往下传,也不报错**,因为那些键已经被记下来了——绑定的全部键值都进运行 + 开始记录的参数快照(`0006` 决策三),续跑时逐字段比对。为什么转发按前缀而不是按一份网关 + 参数名单,以及这里四条防御各自挡的是什么,见 + `research-wiki/design/0017-gateway-forwarding.md`。 + + 键排序后遍历,好让同时有多个键出错时报出来的总是同一个。 """ - return {key: binding[key] for key in _FORWARDED_BINDING_KEYS if key in binding} + forwarded: dict[str, str] = {} + for key in sorted(binding): + if key in _LEGACY_BARE_KEYS: + raise ValueError( + f"绑定里的 {key!r} 不再被转发给网关。要继续把它传下去," + f"把这个键改名成 {_GATEWAY_BINDING_PREFIX + key!r}" + ) + if not key.startswith(_GATEWAY_BINDING_PREFIX): + continue + parameter = key[len(_GATEWAY_BINDING_PREFIX) :] + if not parameter: + raise ValueError( + f"绑定里的 {key!r} 前缀后面是空的。{_GATEWAY_BINDING_PREFIX!r} 之后要跟一个网关的" + "关键字参数名,比如 'gateway.cache_namespace'" + ) + if parameter in _STRUCTURAL_GATEWAY_PARAMETERS: + raise ValueError( + f"绑定里的 {key!r} 不能从绑定走:{parameter!r} 会改变请求本身," + "而请求内容与采样、结构化设置另有权威(这次调用的消息,以及模型身份那份快照)。" + "要改这些就去改模型配置或这次调用本身,不要放进绑定" + ) + if not binding[key].strip(): + raise ValueError( + f"绑定里的 {key!r} 是空串或纯空白。判据是这个值带不带信息,不是它的格式对不对:" + "空串在网关那边和「没传」分不开,纯空白更糟——它是个真值,会被原样当成一个取值" + "用下去。要传就给一个带信息的值,不想传就把这个键去掉" + ) + forwarded[parameter] = binding[key] + return forwarded def _describe_sources(sources: Sequence[SourceConfig]) -> str: diff --git a/tests/integration/test_gateway_model_client.py b/tests/integration/test_gateway_model_client.py index c26f509..3caef5b 100644 --- a/tests/integration/test_gateway_model_client.py +++ b/tests/integration/test_gateway_model_client.py @@ -8,6 +8,7 @@ `make ci` 红——一个因为可选依赖没装而常年红的套件会训练所有人忽略红。 """ +import re from collections.abc import Mapping from dataclasses import dataclass @@ -141,20 +142,108 @@ async def test_an_empty_call_id_becomes_no_call_id() -> None: assert (await client.call(_call())).call_id is None -async def test_only_the_binding_keys_the_gateway_has_slots_for_are_forwarded() -> None: - """其余的键不往下传也不报错——它们已经进了参数快照,网关那边只是没有格子放。 +async def test_prefixed_binding_keys_are_forwarded_with_the_prefix_stripped() -> None: + """带 `gateway.` 前缀的键剥掉前缀之后当关键字参数传下去,不带前缀的坐标一个都不传。 - 报错等于要求项目为了适配一个网关而裁剪自己的坐标系,而绑定同时是续跑守卫的输入。 + 本库不认识网关的参数表,认不认得 `cache_namespace` 这种名字是网关的事,所以替身照单全收。 """ stub = _StubClient(_response()) client = GatewayModelClient(client=stub, settings=_settings()) - await client.call(_call(binding={"session_id": "s1", "book": "b7", "task": "t3"})) + await client.call( + _call( + binding={ + "book": "b7", + "task": "t3", + "gateway.cache_namespace": "acme:v1:tenant:x7", + "gateway.tenant_id": "x7", + } + ) + ) + + ((_, kwargs),) = stub.calls + assert kwargs == {"cache_namespace": "acme:v1:tenant:x7", "tenant_id": "x7"} + + +async def test_a_historical_name_still_works_once_it_carries_the_prefix() -> None: + """`session_id` 这两个名字没有被禁掉,被禁掉的是不带前缀那种写法。""" + stub = _StubClient(_response()) + client = GatewayModelClient(client=stub, settings=_settings()) + + await client.call(_call(binding={"gateway.session_id": "s1"})) ((_, kwargs),) = stub.calls assert kwargs == {"session_id": "s1"} +@pytest.mark.parametrize("key", ["session_id", "parent_call_id"]) +async def test_a_bare_historical_key_is_rejected_and_the_error_gives_the_new_spelling( + key: str, +) -> None: + """这两个键从前被静默转发,现在报错——静默不传的话下游的遥测会悄悄不再分组。 + + 错误信息里必须出现改法,撞上的人才知道下一步写什么。 + """ + stub = _StubClient(_response()) + client = GatewayModelClient(client=stub, settings=_settings()) + + with pytest.raises(ValueError, match=re.escape(f"gateway.{key}")): + await client.call(_call(binding={key: "v"})) + + assert stub.calls == [] + + +@pytest.mark.parametrize("parameter", ["messages", "stream", "structured", "overlay"]) +async def test_a_structural_gateway_parameter_is_rejected(parameter: str) -> None: + """这四个参数改变的是请求本身,而它们的取值另有权威,从绑定走等于让同一件事有两处记录。""" + stub = _StubClient(_response()) + client = GatewayModelClient(client=stub, settings=_settings()) + + with pytest.raises(ValueError, match=re.escape(f"gateway.{parameter}")): + await client.call(_call(binding={f"gateway.{parameter}": "v"})) + + assert stub.calls == [] + + +@pytest.mark.parametrize("parameter", ["cache_namespace", "cache_salt"]) +@pytest.mark.parametrize("value", ["", " ", "\t"]) +async def test_a_blank_forwarded_value_is_rejected(parameter: str, value: str) -> None: + """这条防御对所有带前缀的键一视同仁,不认某个具体的参数名。 + + 空串在网关那边和「没传」分不开,`gateway.cache_namespace=""` 会静默落回默认命名空间; + 纯空白更糟——它是个真值,会被当成一个真的命名空间用下去,于是所有配错的租户共用同一格。 + """ + stub = _StubClient(_response()) + client = GatewayModelClient(client=stub, settings=_settings()) + + with pytest.raises(ValueError, match=re.escape(f"gateway.{parameter}")): + await client.call(_call(binding={f"gateway.{parameter}": value})) + + assert stub.calls == [] + + +async def test_the_bare_prefix_is_rejected() -> None: + """前缀后面没有名字就没有参数名可传,静默跳过会让人以为自己传出去了。""" + stub = _StubClient(_response()) + client = GatewayModelClient(client=stub, settings=_settings()) + + with pytest.raises(ValueError, match=re.escape("gateway.")): + await client.call(_call(binding={"gateway.": "v"})) + + assert stub.calls == [] + + +async def test_a_binding_of_plain_coordinates_forwards_nothing_and_raises_nothing() -> None: + """不带前缀的键已经进了参数快照,报错等于要求项目为了适配网关而裁剪自己的坐标系。""" + stub = _StubClient(_response()) + client = GatewayModelClient(client=stub, settings=_settings()) + + await client.call(_call(binding={"book": "b7", "task": "t3"})) + + ((_, kwargs),) = stub.calls + assert kwargs == {} + + async def test_gateway_errors_propagate_untranslated() -> None: """网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。