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:
@@ -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