Compare commits

4 Commits

Author SHA1 Message Date
iomgaa 622b17f90c chore(release): 定版 1.0.3
CHANGELOG 的「未发布」段落定成 1.0.3(2026-08-29),上面留一个空段给下一版接着攒;两处版本号
(pyproject.toml 与 polyloop/__init__.py)同步。

发之前跑了一轮完整压测,与 1.0.2 那次验收逐项可比:正常负载 400 次运行、3501 次真实模型调用,
十一条不变量全部通过、零击穿、零无法判定;九类故障注入 58 条判据全部通过、零击穿,剩下那条
无法判定与 1.0.2 是同一条。产物在 tools/soak/runs/v1.0.3/(该目录在 gitignore 里)。

第一次开跑那轮作废:模型中转连返 20 次 503 打开了网关熔断,400 次运行全部快速失败以 llm_error
收尾,整轮 181 秒「跑完」。正常负载那一轮的记分板不开 --allow-undetermined,当场拦下、退出码
非零——一次什么都没验到的跑没有冒充通过,那道闸是对的。产物留在 v1.0.3-aborted-503/。

README 的三条安装命令不用改:约束是 polyloop==1.0.*,1.0.3 落在里面。这意味着钉了那个约束的
下游一次例行升级就会拿到这一版,而这一版有一次破坏性的行为变更(绑定里不带前缀的 session_id
与 parent_call_id 从静默转发变成抛 ValueError)——眼下没有下游在用,所以实际影响为零,
CHANGELOG 那一节把迁移写法写清楚了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 14:19:30 -04:00
iomgaa c0d9d7b66e docs: 回写 CHANGELOG、GovDoc 迁移,以及压测那条过期注释
CHANGELOG 落在「未发布」段,按那一段自己的规矩不提前写版本号。写清了四条会抛 ValueError 的
情形与迁移写法,因为这是一次破坏性变更。

migrations/govdoc-saas.md 加一节讲租户命名空间怎么传,含迁完算不算数的四条判据,最终判据是
「同一段文本由两个租户各提交一次,各自拿到自己的那份输出」。参数一律指向 0017 不复述。这份
文档原来说 GovDoc 侧「还没有可迁移的东西」,现在有了第一条能逐条验的接入动作,文件头那句
状态说明跟着补了一句例外。

tools/soak/run_soak.py 那份空绑定上方的注释在复述旧转发规则,顺手改对。它给的理由本来就不准
——让绑定留空的真正原因是绑定的全部键值都进参数快照,每批都不同的值会让故障注入那一步续跑时
报一次假的参数漂移,和转发哪些键无关。三份压测绑定常量里都没有裸键,所以这次变更打不到压测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:53:20 -04:00
iomgaa fd9cae7da5 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>
2026-08-29 11:53:00 -04:00
iomgaa d2be742383 docs(design): 落定网关转发改用保留前缀的方案
回答 GovDoc-SaaS 提的 issue #6,取代 0012 决策四。那一条的结论(只转发 session_id 与
parent_call_id)和它的理由(网关只有两个槽位)都不成立:0012 定于 2026-08-10,而依赖下界
polygateway>=1.1 里 registry 上唯一装得到的 1.1.1 发布于 08-06,那一版的 chat() 上已经有四个
str | None 的槽位。被挡在外面的 cache_namespace 恰恰是上游指定的租户隔离手段,挡掉之后两个
租户提交相同文本时,第二个会读到第一个的模型输出。

要换掉的不是那份白名单的取值,是「哪些键往下传由下游取的名字和网关取的名字偶然相同来决定」
这个机制——补成五个键只是把同一个陷阱重新上好膛。改成绑定里带 gateway. 前缀的键才转发,前缀
之后整段当 chat() 的关键字参数名。由此本库不必列举网关能接受哪些坐标维度,依赖下界照旧
>=1.1,<2,上游此后加维度也不用本库发版(限于取值是字符串的维度,绑定装不下别的类型)。

issue 给的三条路都不采纳,理由分别在决策一到决策三;meta 这一维不做,写在决策五。四条防御
(结构性参数、空串或纯空白、前缀后无参数名、不带前缀的两个历史裸键)都在 call() 里抛
ValueError,表现为一次「第 0 步就以 LLM_ERROR 收尾」的运行。

过了两轮硕士生冷读。第二轮抓出一处事实错误:初稿跟着 issue 写了「下游自己写的 ModelClient
契约套件覆盖不到」,而 polyloop.testing.ModelClientContract 1.0.2 起就随包发布,继承它就能跑,
取消传播恰恰是它覆盖的五条之一。背景那一段换成了成立的那条代价——两份网关适配器各自漂移。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:52:37 -04:00
8 changed files with 604 additions and 18 deletions
+49
View File
@@ -14,6 +14,55 @@
长期停在 1.0.5,下游 `pip install` 拿不到任何修复且无人发现。提前把号写进这一段就是在重演
那个形态——读到号的人会以为那一版已经在 registry 上,而它不在。
## 1.0.32026-08-29
回应 GovDoc-SaaS 提的 issue #6:网关适配器的转发白名单把 `cache_namespace` 挡在外面,而那是
PolyGateway 指定的租户隔离手段。
**这一版发出去之前跑了一轮完整压测**,与 1.0.2 那次验收逐项可比:正常负载 400 次运行、3501 次
真实模型调用,十一条不变量全部通过、零击穿、零无法判定;九类故障注入 58 条判据全部通过、零
击穿,剩下那一条无法判定与 1.0.2 那次是同一条(`cancel_env` 那次运行一条事件都没发出过,
事件文件的完整性无从判起)。库本身没有被压出 bug。产物在 `tools/soak/runs/v1.0.3/`
(该目录在 gitignore 里)。
第一次开跑那轮作废了,原因不在库:模型中转连返 20 次 503 打开了网关的熔断,其后每次调用都
快速失败,400 次运行全部以 `llm_error` 收尾。正常负载那一轮的记分板不开「允许无法判定」,
于是它当场拦下、退出码非零——一次什么都没验到的跑没有冒充通过。那批产物留在
`tools/soak/runs/v1.0.3-aborted-503/`
### 网关适配器改按保留前缀转发绑定,而不是按键名撞
**这是一次破坏性的行为变更。** 绑定里不带前缀的 `session_id``parent_call_id` 从「静默转发
给网关」变成「抛 `ValueError` 并给出改法」,改法是把键名写成 `gateway.session_id`
```python
model_binding={
"book": "b7", # 项目自己的坐标,不传
"gateway.cache_namespace": "acme:v1:tenant:x7", # 传成 chat(cache_namespace=...)
"gateway.tenant_id": "x7",
}
```
绑定里键名以 `gateway.` 开头的,前缀之后那一段当作网关 `chat()` 的关键字参数名,值原样传下去;
其余的键照旧不传、也不报错。**本库不再持有一份网关参数名单**——上游哪天再加一个字符串维度,
下游当天就能用,本库不发版,声明的依赖下界也不用跟着抬。
原来那份写死的白名单只认两个键,把 `cache_namespace` 挡在外面,而那是 PolyGateway 指定的租户
隔离手段:挡掉之后,两个租户提交内容相同的一段文字,第二个会读到第一个那次的模型输出。给出
那份白名单的理由是「网关只有两个槽位放得下这类东西」,而这句话在写下的那天就已经不成立——
当时装得到的最低版本上已经有四个。
四条会抛 `ValueError` 的情形:带前缀的键名指向 `messages``stream``structured``overlay`
之一(这些改变请求本身,另有权威);带前缀的键取值是空串或纯空白(在网关那边和「没传」分不
开,或者不带任何信息,而一个看着配了、实际没配的隔离比没配更糟);键恰好是 `gateway.`、前缀
后面没跟参数名;以及不带前缀的那两个历史裸键。
判断发生在把请求交给网关之前,所以出错的那次调用不会真的发出去。
转发的键照旧全部进参数快照,键名不变(`request.binding.gateway.cache_namespace`),所以换一个
命名空间续跑会照常撞参数漂移。
方案与被否掉的三条路见 `research-wiki/design/0017-gateway-forwarding.md`
## 1.0.22026-08-27
回应下游项目在 PolyLoop 仓库上提的五个 issue。
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "polyloop"
# 与 src/polyloop/__init__.py 的 __version__ 必须一致,由 tests/unit/test_package.py 断言。
version = "1.0.2"
version = "1.0.3"
description = "PolyLoop:实验室共用的 Agent 执行内核——一次运行的预算、停止语义、取消、逐步轨迹与 Skill 注入"
# registry 的包页面正文只认这一项:缺了它页面就是一片空白,而 twine 只会警告
# long_description missing,不阻塞上传——三步全绿、产物是坏的(PolyGateway 1.1.2 的教训)。
@@ -0,0 +1,345 @@
# Design 0017 · 网关适配器往下传哪些键
**日期** 2026-08-29 · **状态** 已接受(2026-08-29 项目负责人确认)
**回答** 实验室 Gitea 上 PolyLoop 仓库的 issue #6,标题是「转发白名单挡掉了 cache_namespace
而上游指定它做租户隔离」,提出者是下游项目 GovDoc-SaaS。
**取代** `0012-gateway-model-client.md` 决策四。那一条的结论(只传两个键)与它的理由(网关
只有两个槽位)都不再成立,理由见背景。`0012` 的其余五条决策不受影响。
**触及** `../../src/polyloop/adapters/__init__.py`
`../../tests/integration/test_gateway_model_client.py``../../CHANGELOG.md`,以及
`../../research-wiki/migrations/govdoc-saas.md`。逐处要改什么在文末的回写清单。
## 读本文需要的几个名字
**接缝** 是本库留给下游替换实现的接口点,一律是 `Protocol`。模型调用接缝
`polyloop.ports.ModelClient`)只有两个方法:发一次调用,以及上报自己的可复现参数。本文讲的
`GatewayModelClient` 是这个接缝的一个实现,作用是把本库的一次调用翻译成 PolyGateway 的一次
治理调用。
**装配分成两半。** 跨运行不变、可以并发复用的那一半是 `AgentDefinition`,四个接缝挂在它上面,
模型调用接缝是其中之一。随每次运行变的那一半是 `RunRequest`,预算、工具、上下文、绑定挂在
它上面。这条分界在决策三里是判据。
**绑定** 是下游项目自己的坐标,库不解释内容、原样透传给模型调用接缝。它在 `RunRequest` 上的
字段名是 `model_binding`,类型 `Mapping[str, str]`,到了模型调用接缝手上叫 `ModelCall.binding`
**它进参数快照时的键前缀是 `request.binding.`,不是字段名那个 `model_binding`**——同一样东西
在三处有三个写法。
**参数快照** 是一次运行开始时算出来、写进运行开始记录的一份字符串到字符串的映射,回答的是
「这次运行是什么设置」。它汇的是绑定与配方指纹这两个请求字段,加上向四个接缝各问一次
`parameters()` 得到的结果。**配方指纹**是挂在运行请求上的另一份字符串到字符串的映射,记的是
这次运行用的材料是哪一版——提示词模板的哈希、技能库的版本这类;它和绑定的分界是「坐标还是
配方版本」。**续跑**指的是一次运行崩了或被打断之后,用同一个运行标识接着往下跑;开工前会
重算一次快照并与日志里那份逐字段比对,对不上就抛 `ParameterDriftError` 中止,免得跑出一条
前后来自两套配置的轨迹。
**`parameters()`** 是接缝上那个上报可复现参数的方法。模型调用接缝这一侧,`GatewayModelClient`
报的是 scope 与源列表。**scope** 是网关里一组模型源的命名分组,也是网关的治理单位——限流、
熔断、缓存的账都按 scope 记。**源**是这个分组里的一个具体端点,一个源就是「供应商 + 地址 +
模型名」那一组,多个源可以指向同一个模型。这两样合起来回答的是**这次运行用的是哪个模型、
打到哪儿、带着哪些恒定采样参数**,这一整份叫「模型身份」。它进快照时的键前缀是
`model_client.`
网关 `chat()` 签名上的几个参数:
- **`cache_namespace`** 是缓存键的一段。缓存键由「模型指纹 + 消息摘要 + 命名空间」构成,
命名空间不同的两次调用读不到彼此的缓存。**模型指纹**是网关自己算给缓存键用的那一份,按
模型名去重;它和上面那个模型身份是两侧的两样东西,模型身份更严——改一个源名也要报出来。
**不传不等于不缓存**:网关取值的写法是「本次调用给了就用本次的,没给就用网关装配时配的
那个默认命名空间」,所以不传是「和所有人共用一格」。
- **`cache_salt`** 是同一命名空间内再分一层的字符串。给同一批消息换一个盐,本该命中的调用
就会打空、真的重新去问模型——需要同一批输入跑多轮且每轮都要真实调用的下游用得上它。
- **`tenant_id`** 与 **`meta`** 是网关 1.3.0 起新增的两个调用方自定义维度,只进它的遥测、
不进缓存键。前者是遥测表里的真实列(可挂行级安全、可进复合索引),后者是任意键值容器。
- **`overlay`** 是采样参数覆盖层(`temperature``seed``max_tokens` 这类),优先级高于配置
里那份恒定采样参数。**`structured`** 要求模型按一个给定的结构返回,网关会为此改写请求并在
返回前校验、必要时重试。
## 背景
issue #6 指出的事实逐条核过都成立。
**适配器只往 `chat()` 传两个键。** `adapters/__init__.py` 里那份模块级白名单是
`("session_id", "parent_call_id")`:绑定里键名与它们相同的那些被当作同名关键字参数传给
`chat()`,其余的键留在参数快照里、不往下传。
**给出的理由是「网关只有两个槽位放得下这类东西」,而这句话在写下的那天就已经不成立。**
`0012` 定于 2026-08-10`pyproject.toml` 声明的依赖下界是 `polygateway>=1.1,<2`,而 1.1.x 里
registry 上唯一存在的版本 1.1.1 发布于 2026-08-06——也就是说定 `0012` 的那天,装得到的最低
版本上就已经有四个 `str | None` 的槽位:`session_id``parent_call_id``cache_salt`
`cache_namespace`。(1.1.0 有过版本号但从未上传,装不到,这正是 `CLAUDE.md` §1.10 记的那个
教训。)所以这不是一条随上游演进而过期的判断,是当时就核错了。1.3.0 上又多了 `tenant_id`
`meta`1.1.1 上没有这两个。
**被挡掉的 `cache_namespace` 正是上游指定的租户隔离手段。** 网关自己把这件事写死了两处:
网关装配时启用了缓存却没配命名空间,它直接拒绝装配,报错原文是「缓存 key 靠它做租户
隔离」;`tenant_id` 的 docstring 明写它不进缓存键,租户隔离由 `cache_namespace` 负责。上游
指派了一个机制来守这条边界,而经过本库之后那个机制传不进去。
失败场景具体:两个租户提交了内容相同的一段文字,两次调用的模型指纹、消息摘要、命名空间三项
全部相同,于是第二个租户读到第一个租户那次的模型输出。这不是缓存效率变差,是跨租户读到别人
的内容。这一条是本次判断里权重最大的那个事实。
**下游能绕过去,代价是多养一个适配器。** `ModelClient` 只有两个方法,下游自己写一个实现不
难,而且不会因此掉出机器兜底:本库随包发布的公共契约套件里就有模型调用接缝那一份
`polyloop.testing.ModelClientContract`),自己写的实现继承它、覆盖 `model_client`
`failing_call` 两个 fixture 就能跑。它守的是接缝层面的五条:返回三个字段、调用标识绝不为
空串、失败以异常表达、取消要能穿过模型调用且 `CancelledError` 不许被吞、签名里不出现重试与
限流参数——取消传播恰恰是套件覆盖了的那一条。
**套件覆盖不到的是翻译那一段。** 把本库的一次调用变成这个网关的一次调用——消息怎么拼、网关
抛的异常怎么原样穿出、调用标识怎么从返回里取、模型身份怎么算——是这个适配器特有的行为,不是
接缝对所有实现的承诺。所以任何一个自己写的网关适配器,在这部分没有机器兜底。
**真正的代价是两份适配器各自漂移**,这一条是提出者自己写的:他们会长期维护第二个网关适配器,
和本库自带的那个唯一区别是多传几个参数;两份会各自往前走,而漂移的那天不会有任何东西提示。
issue 提了三条可能的做法:补白名单、把白名单做成构造参数、把这几个值做成构造参数。三条都不
采纳。
## 决策一:转发不再按名字撞,改成绑定里的保留前缀
绑定里键名以 `gateway.` 开头的,前缀之后那一整段当作 `chat()` 的关键字参数名,值原样传下去。
其余的键照旧不传、不报错。
```python
model_binding={
"book": "b7", # 项目自己的坐标,不传
"gateway.cache_namespace": "acme:v1:tenant:x7", # 传成 chat(cache_namespace="acme:v1:tenant:x7")
"gateway.tenant_id": "x7",
}
```
**前缀之后不再解析,点号也算参数名的一部分。** `gateway.meta.foo` 交给 `chat()` 的参数名是
`meta.foo`,网关签名上没有这个名字,于是抛 `TypeError`。绑定这一侧不为点号编一层嵌套,理由
在决策五。
**其余的键不报错,这是一条有意划的线。** 本库不知道下游的坐标该叫什么,所以对它不认识的键
一律保持沉默——一个叫 `book` 的坐标不是一次写错了的转发。唯一的例外是 `session_id`
`parent_call_id`:这两个名字今天真的会被转发,对它们保持沉默等于静默改变行为,所以它们要
报错,改法见决策四第四条。
**要换掉的是「哪些键往下传」由两个命名空间的名字偶然相同来决定这件事。** 绑定这一侧的名字由
下游取,`chat()` 那一侧的名字由网关取,白名单让两边撞名的那些自动接通。这个机制在两个方向都
会坏:下游随手起一个叫 `tenant_id` 的坐标就被静默传下去;上游加一个参数,本库就得改一次
常量,而漏改的表现是「这个参数传不进去」——issue #6 就是这么来的。**issue 的第一条路(把白
名单从两个键补到五个)只改了那份常量的取值,没有改这个机制**,等于把同一个陷阱重新上好膛,
下一个新参数出现时会再来一次。
前缀让转发变成下游显式声明的:不写前缀就不会往下传,写了就说明是有意的。它的直接后果是本库
不必列举**网关能接受哪些坐标维度**,代码里也不必出现 `cache_namespace` 这个名字。本库手上
仍然有一份网关参数名的清单,但那是另一份东西——决策四第一条那份「不许从绑定走的结构性参数」,
四个名字,在依赖下界那一版上就已经存在,不随上游新增维度而增长。会随上游长的是坐标维度那
份名单,而那份追不动,被拿掉的正是它。由此得到三件事:
**依赖下界不用抬,靠的正是「不列举坐标维度」这一件事。** `tenant_id` 只在 1.3.0 之后存在,
本库若把它写进白名单常量,声明的下界就得从 `>=1.1` 抬到 `>=1.3`,于是每个下游都得跟着升
网关——包括那些根本不需要这个维度的。用前缀的话本库一个坐标维度的名字都没提,下界照旧
`>=1.1,<2`:网关支持的那个下游传得进去,装着旧网关的那个会拿到 `TypeError`,而错误信息里
带着参数名。上游此后再加维度也是同一个待遇,本库不发版。这条免疫只覆盖取值是字符串的新
维度:绑定的类型是 `Mapping[str, str]`,装不下别的,网关哪天加一个取 `int``bool` 或者
嵌套映射的参数,前缀这条路传不了它。这个限制可以接受——调用方坐标这一类维度天然是字符串
标识,租户、会话、命名空间、盐写出来都是名字;真出现一个非字符串的新维度,那是一次新的设计
(放宽绑定的取值类型,或者另开一条通道),不是这个机制上的一个漏洞。
**转发的键照旧全程进参数快照。** 键名不变,`gateway.cache_namespace` 在快照里是
`request.binding.gateway.cache_namespace`。所以「这次运行用的是哪个命名空间」事后查得到,
而换一个命名空间续跑会撞 `ParameterDriftError`——那正是最该炸的一次,因为续跑读到的可能是
另一个租户的缓存。
**升级不改变现存运行的行为。** 前缀今天不被任何东西解释,带前缀的键在今天只是一个普通坐标,
所以装上新版之后往网关传的东西不变。补白名单则相反:一个绑定里本来就有 `tenant_id` 的下游,
升级当天行为就变了,而快照里那一项一个字没改,续跑守卫也不会响——一次静默的行为变更,正是
本库最怕的形态。
**残留风险照实认下:今天真有一个叫 `gateway.<某某>` 的坐标的下游,升级后它会被往下传。**
这个风险以「装上就炸」的形态出现而不是静默扩散——网关不认得那个参数名会当场抛 `TypeError`
除非那个名字恰好也是网关的参数名,而那种巧合要同时撞中两层。
**前缀取 `gateway.` 而不是别的写法**,因为它说的正是「这个键是给网关的」,而读到它的人手上
拿的就是网关适配器。更不容易撞的写法(加下划线、加更长的限定串)换来的是每次都要多打几个字
和一个记不住的拼写,而撞名的代价上一段已经认下了。
**代价:`gateway.` 从此是绑定里的保留前缀。** 一个真的想用这个前缀当自己坐标的下游没法这么
取名了。接受,因为绑定的键名本来就由下游自己定,改一个名字的成本是一行。
## 决策二:为什么不是「白名单做成构造参数」
issue 的第二条路是让调用方决定转发哪些键。它没有解决决策一说的那件事——转发仍然按名字撞,
只是撞的范围可配。而且它多出一个后果:读一份参数快照不再能回答「网关那次收到了什么」,因为
答案同时取决于绑定和构造时那份配置,而后者不在快照里。把它也塞进 `parameters()` 能补上,但
那是为一个不必要的旋钮再加一层机制。
## 决策三:为什么不是「做成 `GatewayModelClient` 的构造参数」
issue 的第三条路(提出者自己最倾向的那条)是把这几个值直接做成适配器的构造参数。它和装配的
两半相冲,而且冲的地方正是这次要传的那个值。
`GatewayModelClient` 挂在 `AgentDefinition` 上,也就是跨运行不变、可以并发复用的那一半。租户
标识与租户命名空间恰恰**每次运行都可能不同**——一个多租户服务里,一次运行属于一个租户。把它
放到构造参数上,等于要求每个租户各装配一份定义,而定义那一半存在的理由就是它不随运行变。
本库已经有一条专门的每次运行通道,就是绑定。租户坐标是坐标,走坐标那条路。
(提出者自己也写了「跨运行基本不变,`tenant_id` 除外」。那个例外不是边角,它是这次需求的主体。)
## 决策四:四条防御
前缀让下游能把任意参数名传给 `chat()`,所以适配器要挡住几类明显不该这么传的东西。挡不住的
那些原样交给网关,由它抛 `TypeError`——错误信息里有参数名,比本库自己编一条更有用。
### 一、会改变请求本身的参数名一律拒绝
`messages``stream``structured``overlay` 四个。
**判据是「这个参数有没有一个已经存在的权威」,不是「它重不重要」。** 模型身份那份快照
`parameters()` 报的 scope 与源列表,含配置里的恒定采样参数)是「这次运行的模型行为是什么」
的权威。`overlay` 改的是同一件事,从绑定走等于让同一件事有两处记录,而其中一处不是权威——
`0012` 决策二把采样参数算进模型身份,为的就是「temperature 从 0 改成 1 之后续跑,模型的行为
变了而轨迹上看不出来」,从绑定塞 `overlay` 会把那道守卫从旁边绕过去。`structured` 会让网关
改写请求并按结构校验、必要时重试,`stream``messages` 决定发出去什么,同理。
**缓存维度(`cache_namespace`、`cache_salt`)不落在这一类,因为没有第二个地方记它们。**
它们改变的是「这次调用会不会真的发出去、读到谁的那一份结果」,而这件事在本库这边只有绑定
一处记录,逐字段进快照、逐字段守。所以它们是坐标,不是结构。
这份拒绝清单也会过期,但它过期的后果比今天那份软一档:漏掉一个新的结构性参数,表现是「该拦
的没拦住」,而且要下游主动写出那个名字才会发生;今天那份漏掉一个新参数,表现是「该传的传不
了」,下游什么都不做就中招。
### 二、取值是空串或纯空白时拒绝,带前缀的键一律如此
网关那边取命名空间的写法是「本次调用给了就用本次的,没给就用默认的」,而空串在 Python 里是
假值——所以 `gateway.cache_namespace=""` 会静默落回默认命名空间,也就是悄悄关掉隔离。绑定
那一侧的校验只管键和值都得是字符串,管不到空串。
**纯空白(`" "`、`"\t"`)比空串更坏,所以一起拒。** 空白在网关那边是真值,会被原样当成一个
命名空间用——于是隔离看起来成立,实际是所有拿到这份坏配置的租户共用同一格,而这正是本文要
修的那个跨租户串读场景换了个入口。空串至少还会落回默认命名空间那条众所周知的路,空白连这条
路都不走。
**判据是这个取值带不带信息,不是这个命名空间格式对不对。** 空串与纯空白都不带,所以拒得掉;
`"acme:v1:tenant:"`(租户标识拼空了)带信息但内容是错的,本库拦不住,也不该拦——它不解释绑定
的取值,一旦开始判断「什么样的命名空间算合法」,就等于替下游定了编码规则。这条线划在这里是
有意的,它挡的是「一个看着配了、实际什么都没配的隔离」,不是「配错了的隔离」。
**这一条只管带前缀的键。** 不带前缀的键根本到不了网关,它们的取值是下游自己的坐标,空不空
由下游自己判——库对它们唯一的要求是「键和值都得是字符串」,那一条在别处已经有了。
### 三、前缀后面没有参数名时拒绝
键恰好是 `gateway.` 时,剥掉前缀之后是一个空的参数名。**这一条不补任何漏洞**:真交下去,
`chat()` 的签名是纯关键字、没有 `**kwargs`,网关照样会抛 `TypeError`。它换掉的只是错误信息
——`TypeError: chat() got an unexpected keyword argument ''` 说不出问题出在绑定的哪个键上,
而这是一个纯粹的手滑,报错该直接指着那个键说「前缀后面要跟一个参数名」。
交给网关是默认,本库只在自己能给出明显更有用的信息时才拦。
### 四、不带前缀的 `session_id` 与 `parent_call_id` 拒绝,并在错误信息里给出改法
这两个键今天会被转发,前缀落地之后不再转发。直接静默不传是又一次静默的行为变更——下游的
网关遥测会悄悄不再按会话分组,而没有任何东西会提示。报错则要求它把键名改成
`gateway.session_id`,一行的事。这份清单只有历史遗留的那两个,此后永不增长——只有这两个键
曾经被真的转发过,所以需要迁移提示的名字集合是封闭的,不会有第三个。下一个 major 可以整条
删掉。
**不设弃用期(先警告一版、下一版再报错)**,因为弃用期要求这一版继续按旧机制转发这两个键,
也就是把决策一要拆掉的那个撞名机制再留一个版本;而警告是可以被忽略的,日志里多一行
`DeprecationWarning` 在一个跑批量运行的进程里没人会看见。用一次响亮的失败换掉一段没人读的
警告,代价是撞上的人要改一行,收益是这一版之后再没有第二套转发机制活着。
### 这四条报错时会发生什么
绑定要到一次模型调用发生时才到适配器手上,所以这四条只能在 `call()` 里判,判不到构造那一刻。
`session`——本库里驱动一次运行的那个模块,和网关那个 `session_id` 只是撞名——捕获模型
调用接缝抛出的任何异常,写一条带失败说明的结果记录、记一步、以 `StopReason.LLM_ERROR` 收尾。
也就是说这四条报的 `ValueError` 不会穿出 `run()`,它表现为一次「第 0 步就以模型调用失败
结束」的运行,失败说明里带着 `ValueError` 这个类名和出问题的那个键名。
**判断发生在把请求交给网关之前**,所以出错的那次调用一次都没发出去:转发出来的那份关键字
参数是 `chat()` 的实参,Python 求值完全部实参才进函数体。
**这个形态可以接受,因为绑定一次运行内不变**:错了就是第一次调用就错,不存在跑到一半才炸,
也没有任何预算被花掉。代价是这次运行在下游的统计里落在「模型调用失败」那一格而不是「配置
写错了」,要读失败说明才分得开——这一点和 `0016` 决策二说的那件事同族。那一条定的是:动作
执行接缝抛异常时库不捕获,也不替它编一个结算结果,因为异常抛出的那一刻副作用发生没发生是
未知的,库编一个出来就是把「不知道」改写成「知道,而且是这个值」。区别是那里库有得选(可以
不接管,让异常穿出去),这里没得选——模型调用接缝的异常怎么处置早就定死了,而适配器没有
更早的位置可以判。
## 决策五:`meta` 这一维不做
`meta` 的类型是 `Mapping[str, Any]`,而绑定是 `Mapping[str, str]`,装不下。要装下得二选一:
给绑定加一层嵌套编码(`gateway.meta.<key>` 这类),或者放宽绑定的取值类型。后者动的是公共
类型,而绑定被定成字符串映射是为了让它能逐字段进参数快照——`0006` 里那句「不透明对象是公共
签名上一个永久的洞」说的就是这件事。前者是为一条需求引入第二套解析规则。
提出者自己把这一维标成最低优先级,说明的用途是带一个自己的 trace 标识。那个用
`gateway.session_id` 就能带:网关那边 `session_id` 本来就是一个用来分组的字符串,而本库不往
里面填任何东西——按 `0012` 那句「项目想让网关按运行分组,把它要的那个键放进绑定里」,这个
槽位一直是留给下游的。
不做 `meta` 是一次决定,不是漏掉的一维。提出者明确要求「如果你们认为这不该由库来做,请在
issue 里说一声」,所以 issue #6 的回复里要写上这一条。
## 公共契约套件不动
转发是这个适配器的行为,不是 `ModelClient` 这个接缝对所有实现的承诺——另一个下游写的实现接的
可能根本不是这个网关,对它断言 `gateway.` 前缀没有意义。
真正的缺口在别处:一个下游被逼着自己写网关适配器时,它重写的正是套件覆盖不到的那一段翻译
逻辑,而两份适配器此后会各自漂移。这一版之后那个前提没了——需要租户隔离的下游不必再自己写
实现,留在自带适配器上就能把命名空间传下去,而自带适配器的转发行为由 `tests/integration/`
里的用例钉着。**缺口是靠「消除下游离开这条路的理由」补上的,不是靠给套件加一条断言。**
**issue #6 末尾那个附带请求已经满足了。** 提出者问的是「能不能让准入契约套件也能套在自己写
`ModelClient` 实现上」——`polyloop.testing.ModelClientContract` 就是它,1.0.2 起随包发布,
继承它、覆盖两个 fixture 即可。
## 版本:1.0.3
`session_id` 从「静默转发」变成「报错」是一次破坏性的行为变更,按语义化版本的字面它不该
落在补丁号上。号是项目负责人拍的板,没有附理由;下面三条是这个选择为什么可以承受:
- 旧行为本身是缺陷。按名字撞的转发从来没有被任何人显式选择过,它是 `0012` 那个核错的前提留下
来的;
- 没有已发布的消费者依赖它。两个下游都在重建中,本库 1.0.2 发布于 2026-08-27,两天前;
- 失败是响亮的。撞上的人拿到一条带迁移写法的 `ValueError`,不是一次静默的行为改变。
**次版本号按同样这三条一样安全**,而且不和语义化版本的字面打架,所以 1.1.0 是一个真的选项。
取舍在别的地方:这次改动没有给库添任何新能力,它修的是一条本来就写错了的转发规则,用次版本
号会把一次修缺陷宣传成一次加特性;而读到次版本号的人更容易判断「这一版我可以先不升」,可
这一版恰恰是每个多租户消费者都该升的。落在补丁号是拍板的结果,上面那三条与这段取舍都是事后补的论证。
**残留风险照实认下**:万一有本次不知道的消费者正在依赖裸键转发,它会在一次补丁升级上撞到
`ValueError`。用报错而不是静默不传,就是为了让这个风险以「装上就炸」而不是「跑了三个月才
发现遥测一直没分组」的形态出现。
## 回写清单
**不回写下面这几处,本文就是死的**——写适配器的人读的是 docstring,而它现在还写着「网关只有
两个槽位」。
- `adapters/__init__.py` 的模块常量:白名单换成前缀常量、结构性参数拒绝清单、历史裸键清单;
- `_forwarded_binding` 的实现与 docstring:那句「网关只有两个槽位放得下这类东西」是本文推翻
的那条,改成决策一的说法,并指向本文;
- `GatewayModelClient` 的类 docstring:加一段说明保留前缀怎么用,含一个例子;
- `tests/integration/test_gateway_model_client.py` 里钉住旧行为那条用例:改写成前缀语义,另加
四条防御各一条;
- `CHANGELOG.md`:单开一节写迁移写法,因为它是破坏性变更落在补丁号上;
- `migrations/govdoc-saas.md`:那边要写的是租户命名空间怎么传;
- `tools/soak/run_soak.py` 里那份空绑定上方的注释:它把「绑定为什么留空」归给了转发规则,
而真正的理由是全部键值都进参数快照、每批不同的值会报假漂移,和转发哪些键无关。
## 留给后续的
**本库仍然不往网关的任何槽位里自动填东西。** 运行标识 `run_id`(续跑就是拿着它接着往下跑
的那个)不会被自动填进 `session_id`:那个槽位是下游的,本库填进去就会和下游自己的会话概念
撞,而撞了之后先写的那个赢。要按运行分组的下游自己写 `gateway.session_id`
**`cache_salt` 顺带通了,没有为它写一行代码。** 需要同一批消息跑多轮、每轮都真的重新调用的
下游写 `gateway.cache_salt` 就行。这是决策一那种「不列举坐标维度」的做法白拿的收益:白名单
方案得为它多列一个名字,还得先判断该不该列。
+55 -1
View File
@@ -5,7 +5,8 @@
> 来源与缺口清单。
>
> **当前状态:这不是一份迁移清单,是一份需求清单。** GovDoc-SaaS 的 agent 部分还没有可迁移
> 的东西,本文的作用是防止 PolyLoop 只按 dissect 一家的形状长。
> 的东西,本文的作用是防止 PolyLoop 只按 dissect 一家的形状长。唯一已经能逐条验的接入动作是
> 租户隔离参数那一节。
PolyLoop 只有 dissect 一个硬消费者(见 `dissect.md`)。只对着一个消费者做,做出来的库会长成
那个消费者的形状,而这件事在完成之前看不出来。GovDoc 这边不做迁移验收,改做**设计级验收**:
@@ -141,6 +142,59 @@ Codex 对抗审查抓出来了,修法见 `../design/0004-stopping-and-step-rec
显式注入,重试对它们就静默失效」——那是 PolyGateway 装配的问题,要在装配点解决,不是在
Agent 层补一层。
## 租户隔离参数怎么传
**绑定**是挂在一次运行请求上的一组字符串到字符串的映射,装的是下游项目自己的坐标。PolyLoop
不解释它的内容,原样交给模型调用接缝;自带的网关适配器再从里面挑出该往 PolyGateway 那次调用
上传的键。
GovDoc 在 PolyLoop 仓库提的 issue #6 说的是:适配器挑键的老办法把租户命名空间挡在外面,而那
是 PolyGateway 指定的租户隔离手段,挡掉之后两个租户提交相同的一段文本时,第二个会读到第一个
那次的模型输出。`../design/0017-gateway-forwarding.md` 决策一换掉了挑键的机制——**绑定里键名
`gateway.` 开头的才往下传,前缀之后那一段当作网关 `chat()` 的关键字参数名**。
### 要改的地方
租户命名空间与租户标识都写成带前缀的绑定键,每次运行按这次运行属于哪个租户填值:
```python
model_binding = {
"case": "...", # GovDoc 自己的坐标,不带前缀,不往下传
"gateway.cache_namespace": ..., # 这次运行所属租户的命名空间
"gateway.tenant_id": ..., # 同一个租户的标识
}
```
两个值的字符串怎么编码由 GovDoc 自己定(它的设计记录里已经定死),PolyLoop 不解释这两个字符串
的内容,也不替它们拼任何前后缀。这两个网关参数各自是什么语义、以及租户坐标为什么走绑定而不是
走适配器的构造参数,见 `0017` 的术语节与决策三。
**issue 里提的第三个维度 `meta` 本库不做**,理由在 `0017` 决策五。它原本要带的那个自己的 trace
标识改走 `gateway.session_id`:网关那边这个槽位本来就是一个用来分组的字符串,而 PolyLoop 不往
里面填任何东西,它一直留给下游自己写。
**绑定里现有的不带前缀的 `session_id` 与 `parent_call_id` 要改成带前缀的写法。** 这两个键在旧
机制下会被转发,`0017` 之后不再转发,而且会被适配器显式拒绝——用一次报错换掉一次静默的行为
变更,理由在 `0017` 决策四的第四条防御。拒绝发生在第一次模型调用时,形态是一次「第 0 步就以
模型调用失败收尾」的运行,失败说明里带着出问题的那个键名,没有预算被花掉。落地在哪个版本、
以及网关依赖下界要不要跟着动,见 `0017`
### 迁完算不算数
**绑定里再没有不带前缀的 `session_id` 或 `parent_call_id`。** 这条装上新版就自己验了:有残留
的话,用到那份绑定的第一次运行会在第一次模型调用上失败。
**每一次运行的绑定里都带着这次运行所属租户的命名空间键,值不是空串。** 空串会被适配器拒绝
`0017` 决策四的第二条防御)。查法是拿一次真实运行的运行开始记录看参数快照:带前缀的键照旧
逐字段进快照,所以「这次运行用的是哪个命名空间」事后查得到。
**同一段文本由两个租户各提交一次,各自拿到自己的那份模型输出。** issue #6 的失败场景不再复现
——这是最终判据,前面两条都是为它服务的。
**续跑一次运行时命名空间不变。** 命名空间随绑定进参数快照,而续跑开工前会把重算的快照与日志
里那份逐字段比对,换过命名空间就会被当成参数漂移中止。撞上这条说明续跑读到的缓存可能属于另一
个租户,中止是对的。
## 缺口登记
这些是 PolyLoop 现在答不上来的问题。答不上来不等于设计错了,但每一条都得有明确结论——
+1 -1
View File
@@ -15,4 +15,4 @@ import 时把网关连同它的 provider 目录一起拉起来,而不传存储
#: 与 `pyproject.toml` 的 `project.version` 必须一致,由 `tests/unit/test_package.py` 断言。
#: 两处双写是因为运行时读不到构建元数据(未安装的源码树里 `importlib.metadata` 查不到),
#: 而下游报 bug 时第一件事就是问版本号。
__version__ = "1.0.2"
__version__ = "1.0.3"
+57 -8
View File
@@ -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:
+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:
"""网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
+3 -3
View File
@@ -51,9 +51,9 @@ if TYPE_CHECKING:
APPWORLD = "appworld"
GOVDOC = "govdoc"
#: 传给每次模型调用的项目侧标识。**空的**:网关适配器只往下转发 `session_id` 与
#: `parent_call_id`,而这两样每批都不同;进了参数快照,故障注入那一步续跑时的逐字段比对
#: 就会报一次假的参数漂移
#: 传给每次模型调用的项目侧标识。**空的**:绑定的全部键值都进参数快照,而每批都不同的值会让
#: 故障注入那一步续跑时的逐字段比对报一次假的参数漂移。这里跟转发无关——网关适配器只转发带
#: `gateway.` 前缀的键,而这份一个都没有
MODEL_BINDING: Mapping[str, str] = {}
#: GovDoc 的工作区落在 runs 目录下的这个子目录里。记分板枚举 run 用的是