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:
2026-08-29 11:53:00 -04:00
parent d2be742383
commit fd9cae7da5
2 changed files with 150 additions and 12 deletions
+93 -4
View File
@@ -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:
"""网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。