fix(adapters): 转发改按 gateway. 前缀,四条防御各自报错
落实 0017。模块级白名单换成三个常量:保留前缀、结构性参数拒绝集合、历史裸键元组。 _forwarded_binding 按排序后的键遍历(多个键同时违规时报出来的总是同一个),带前缀的剥掉前缀 当关键字参数名,不带前缀的照旧不传也不报错。 **这是一次破坏性的行为变更**:绑定里不带前缀的 session_id 与 parent_call_id 从静默转发变成抛 ValueError,错误信息里给出 gateway.session_id 这个改法。静默不传是又一次静默的行为变更—— 下游的网关遥测会悄悄不再按会话分组而没有任何提示;不设弃用期是因为那要求这一版继续按旧机制 转发,等于把要拆的撞名机制再留一个版本。 空值那条防御拒的是「空串或纯空白」,不只是空串。这一条是 Codex 对抗审查抓出来的:空白在网关 那边是真值,会被原样当成命名空间用,于是所有拿到这份坏配置的租户共用同一格,正是 issue #6 那个跨租户串读场景换了个入口。判据是这个取值带不带信息,不是格式对不对——本库不解释绑定的 取值,值原样转发不做 strip,"acme:v1:tenant:" 这种少了一截的它拦不住也不该拦。 四条判断都在 chat() 的实参求值阶段完成,所以出错那次调用一次都没发出去,每条用例都断言了 替身的 calls 为空。空值那条用例是 2 个参数名 × 3 种取值的参数化——独立审查指出单参数版本 钉不住「对所有带前缀的键一视同仁」:把实现写成只认 cache_namespace 也照样绿,而那个错实现下 gateway.cache_salt="" 会被转发成一次读到缓存的调用。 371 passed / 16 skipped,六条 import 契约全 KEPT。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user