Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 622b17f90c | |||
| c0d9d7b66e | |||
| fd9cae7da5 | |||
| d2be742383 | |||
| 72cfba5996 |
@@ -14,6 +14,55 @@
|
||||
长期停在 1.0.5,下游 `pip install` 拿不到任何修复且无人发现。提前把号写进这一段就是在重演
|
||||
那个形态——读到号的人会以为那一版已经在 registry 上,而它不在。
|
||||
|
||||
## 1.0.3(2026-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.2(2026-08-27)
|
||||
|
||||
回应下游项目在 PolyLoop 仓库上提的五个 issue。
|
||||
|
||||
+1
-1
@@ -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` 就行。这是决策一那种「不列举坐标维度」的做法白拿的收益:白名单
|
||||
方案得为它多列一个名字,还得先判断该不该列。
|
||||
@@ -305,13 +305,34 @@ unzip -p polyloop-X.Y.Z-py3-none-any.whl polyloop/__init__.py | grep __version__
|
||||
之间没有任何机器约束——构建时工作区不干净、`dist/` 没清、传错了文件,都会让一个「下载成功」
|
||||
的包里装着旧代码。
|
||||
|
||||
sdist 要单独取,加 `--no-binary :all:`。它里面那份 `PKG-INFO` 的正文就是包页面要渲染的
|
||||
README,可以在这里先看一眼(1.0.1 那次是 6462 个字符):
|
||||
sdist 要单独取。它里面那份 `PKG-INFO` 的正文就是包页面要渲染的 README,可以在这里先看一眼
|
||||
(1.0.1 那次是 6462 个字符,1.0.2 是 9566 个):
|
||||
|
||||
```
|
||||
NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \
|
||||
pip download --no-deps --no-binary :all: --no-build-isolation \
|
||||
--index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||
"polyloop==X.Y.Z"
|
||||
tar -xzf polyloop-X.Y.Z.tar.gz -O polyloop-X.Y.Z/PKG-INFO | head -40
|
||||
```
|
||||
|
||||
**`--no-build-isolation` 不能省,而漏了它的失败形态会指向错误的方向。** `--no-binary :all:`
|
||||
让 pip 取 sdist 而不是 wheel,而 pip 拿到 sdist 之后会去构建它的元数据,构建要先装
|
||||
`pyproject.toml` 里 `build-system.requires` 那个 setuptools——`--index-url` 已经把索引整个换成
|
||||
了这个 registry,那儿只有 polyloop,没有 setuptools。报出来的是:
|
||||
|
||||
```
|
||||
ERROR: Failed to build 'polyloop' when installing build dependencies for polyloop
|
||||
```
|
||||
|
||||
这句话读起来像刚传上去的那个包坏了,而实际上包好好的,坏的是这条命令的索引配置。
|
||||
`--no-build-isolation` 让 pip 用当前 conda 环境里已经装着的 setuptools(`dev` 那组的 `build`
|
||||
带着它),不去索引找。
|
||||
|
||||
跳过构建隔离不影响这一步的判据:这里验的是 registry 上那份 sdist 的**内容**对不对,不是
|
||||
「下游能不能从源码把它构建出来」。真要验后者,索引那一项得写成 `--extra-index-url`,让 PyPI
|
||||
仍然在链上。
|
||||
|
||||
这一步单独占一个位置,而不是并进第七步说一句「传完了」,是因为 PolyGateway 那两个缺失版本
|
||||
的形态就是「本地看起来全做完了」——只有一条真的去 registry 取一次的命令能区分开。
|
||||
|
||||
|
||||
@@ -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 现在答不上来的问题。答不上来不等于设计错了,但每一条都得有明确结论——
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
"""网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
|
||||
|
||||
|
||||
@@ -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 用的是
|
||||
|
||||
Reference in New Issue
Block a user