Compare commits
34 Commits
114fc8b1b3
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2af445cfc2 | |||
| 8b0f66b1a6 | |||
| 9d9e4ee533 | |||
| 56f380534c | |||
| b6165ff438 | |||
| 9bdd312928 | |||
| 25cb0a6e0c | |||
| d553d142c3 | |||
| 6ad58a6553 | |||
| 702040d1a3 | |||
| 4be2b4f287 | |||
| dba706b59c | |||
| 6af4673534 | |||
| bf2fbd6c5e | |||
| 80aa2b216d | |||
| a052f3eb28 | |||
| b671fb629a | |||
| 61122ce437 | |||
| 4351e2be73 | |||
| 17dcff41c3 | |||
| fa4a7e220b | |||
| 658086e2c0 | |||
| 9dada0be9d | |||
| a3f4cc323f | |||
| 0edb9d397a | |||
| 484900d300 | |||
| e302247022 | |||
| 1489aab95d | |||
| c2dd4a1cf4 | |||
| 1801289277 | |||
| 7462cad166 | |||
| 3cbe8aab91 | |||
| 707f8f7317 | |||
| 10fbc5441e |
+79
-2
@@ -1,8 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## 未发布
|
||||
## 1.2.1(2026-08-18)
|
||||
|
||||
- 打包元数据补齐: `readme` 与 `[project.urls]`。1.1.2 及之前的包在 registry 页面上**没有任何说明正文**(缺 `readme` 时 twine 只警告不阻塞),也没有仓库链接。代码零变更,随下一个版本生效。
|
||||
每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(issue #11)。`llm_calls` 存的是**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列**没有任何租户维度**——能区分来源的只有 `session_id` / `parent_call_id` 两个调用方自填、库内不校验的自由字符串。
|
||||
|
||||
不可逆性是这个 issue 的核心论点,且成立: 先启用遥测再补列,补列之前写进去的每一行都没有归属,事后无法还原哪行属于谁。
|
||||
|
||||
### 新增
|
||||
|
||||
- **四个公共方法各增两个 keyword-only 参数 `tenant_id` 与 `meta`**,都带默认值 `None`,**既有调用点零改动**: `GatewayClient.chat()`、`EmbeddingClient.embed()`、`OcrClient.recognize_text()`、`OcrClient.parse_layout()`。issue 只诉求前两条链路;OCR 经同一个 `TelemetryEmitter` 写**同一张表**,只覆盖两条会让同表内一部分行有归属、一部分永远空白,故一并纳入(与 issue #10 同一判断)。
|
||||
- **遥测表 `llm_calls` 新增两列**,排在既有 22 列**末尾**,两端类型按各自后端的原生能力取:
|
||||
|
||||
| 列 | Postgres | SQLite |
|
||||
|---|---|---|
|
||||
| `tenant_id` | `TEXT NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` |
|
||||
| `meta` | `JSONB NOT NULL DEFAULT '{}'::jsonb` | `TEXT NOT NULL DEFAULT '{}'` |
|
||||
|
||||
- **老表经现有 `_BACKFILL` 机制自动补列**(先探测再 `ALTER`,失败只逐行降级),补列后**老行的 `tenant_id` 读出是空串而非 NULL**。这个区别是刻意的: PG 的 RLS `USING` 表达式返回 false **或 null** 的行都不可见、且静默跳过不报错,所以 NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**;哨兵空串则显式可查,`COUNT(*) WHERE tenant_id = ''` 一条 SQL 就能审出还有多少行待归属。补列本身两端都不停机: PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作。
|
||||
- **`TelemetryRecorder.record_llm_call` 由 22 字段扩为 24**(`inspect.signature` 实测),`ChatRequest` 同步新增两个带默认值的字段。`meta` 以 `json.dumps(sort_keys=True, ensure_ascii=False, allow_nan=False)` 序列化,空 dict 落 `'{}'` 而非 NULL。
|
||||
|
||||
### 校验规则(超限报错,不静默丢弃)
|
||||
|
||||
校验在四个公共入口收口、进洋葱之前抛裸 `ValueError`,四条链路共用同一份实现:
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| `tenant_id` | 长度 ≤ **128**;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug |
|
||||
| `meta` 键数 | ≤ **16** |
|
||||
| `meta` 键 | 必须匹配 `[a-z0-9_.]{1,64}`;**`pg_` 前缀保留**给库将来的内建维度(本版库自身不写任何该前缀的键) |
|
||||
| `meta` 值 | 仅 `str` / `int` / `float` / `bool`,嵌套需调用方自行序列化;字符串值 ≤ **256** 字符;`float` 必须有限,`nan` / `inf` 报错(它们不是合法 JSON,PG 的 JSONB 会拒收) |
|
||||
|
||||
报错点选在入口而非遥测写入点: 遥测层的一切失败都按降级方向铁律吞成 warning,校验放那里等于没有校验。**超限一律报错**,不采用"超长就丢弃"的做法——那违反 P5「严禁默认值掩盖错误」,会把调用方的输入错误转化成静默丢数据。
|
||||
|
||||
### 不变
|
||||
|
||||
- **`tenant_id` 与 `meta` 都不进缓存 key**。租户级的缓存隔离由既有的 `cache_namespace` 负责,重复进 key 只会让全部存量缓存冷启动;且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
|
||||
- 既有 22 列的列名与列序、`ON CONFLICT (call_id) DO NOTHING` 幂等、单条写失败逐行丢弃的降级方向全部未动。**错误面零变更**,下游 `except` 写法不受影响。
|
||||
- 缓存命中行与终态失败行同样带维度,且读的是**本次** `request` 而不是缓存里的历史响应——这两类行恰恰是审计最需要的(命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
|
||||
|
||||
### 边界: 库只交付列,RLS 与索引由下游执行
|
||||
|
||||
**库不会执行 `ENABLE` / `FORCE ROW LEVEL SECURITY`,也不会建任何索引。** 需要数据库层的强制隔离,下游 DBA 必须自行执行 RLS DDL 与 `CREATE POLICY`(并建 `(tenant_id, created_at)` 复合索引——启用 RLS 后 policy 会给每条查询隐式追加 `tenant_id` 等值谓词,它必然是前导列);**不执行则 `tenant_id` 只是一个可查、可过滤的普通列,没有任何数据库层强制**。
|
||||
|
||||
不自动启用的首要理由是 **default-deny**: 启用 RLS 而无匹配 policy = 零行可写,且**静默不报错**。三个下游里只有一个是多租户,库若自动启用,其余部署升级后遥测**全量写失败**,再叠加遥测的静默降级铁律,就是无声全局丢数据——恰是本 issue 所担心的"不可逆"的最坏形态。其余理由: policy 必须绑定角色而库只拿到一条连接串;`CREATE POLICY` / `ALTER TABLE` 要求表属主,而按最佳实践部署时库的运行时角色恰好不是属主;SQLite 根本没有 RLS,承诺 RLS 会让两个后端语义不对等。
|
||||
|
||||
RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文必须在**显式事务内** `set_config(..., true)`,asyncpg 默认 autocommit 下单发 `SET LOCAL` 会当场失效而 PG 只发 warning;只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行)见 README「多租户与自定义维度」一节——那份模板随包分发,`research-wiki/` 不在 sdist 内。
|
||||
|
||||
### 升级提示
|
||||
|
||||
- **升级无需任何代码改动**: 两个新参数都是带默认值的 keyword-only,既有调用点原样工作;不传即写入哨兵空串与空 `{}`。
|
||||
- README 的安装 pin 由 `>=1.2,<2` 收紧为 `>=1.2.1,<2`。按 `>=1.2,<2` 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.0 就没有租户维度**。
|
||||
- README 的配置参考表此前漏列了源级 `MISSING_DONE` 与 `EXTRA_BODY`(正文别处却引用了后者)、`{SCOPE}__QUOTA_FULL`、embedding 专用键、`PGW_CACHE_BACKEND` 的 `memory` 档与三个可选 `PGW_*` 键,本版按 `config.py` 的 `_SOURCE_FIELDS` 与 `_load_pgw` 逐项补齐。代码零变更。
|
||||
|
||||
## 1.2.0(2026-08-16)
|
||||
|
||||
网关拒绝一次调用时,**它说的话不再丢失**(issue #10)。下游一轮 1050 张医学影像的批处理里,1 张在读表格这一步收到 400、被判确定性失败而放弃;事后想知道"这张图到底哪里不合规",无从查起——响应体在 transport 翻译层之后就不存在于进程任何位置了。
|
||||
|
||||
根因是三条留存通道同时为空: `_status_to_error` 手上握着 `body_text` 却只用于 429 的类型细分,该模块没有任何 logger 调用,异常类也没有承载响应体的字段。而库的逐次遥测写的是 `str(exc)`,即 message——所以**只给异常加字段并不能让它进遥测表**,必须两者都做。
|
||||
|
||||
### 新增
|
||||
|
||||
- **四分类错误新增 `body_text` 字段**(加在 `PolyGatewayError` 基类): 非 2xx 响应体的摘要。与 `ResultInvalidError.raw_text` 分工明确——前者是"对方拒绝的理由"(非 2xx),后者是"2xx 但内容不可解析时的模型输出"。scope 级错误(`GatewayUnavailableError` 一族)恒为空串: 它们没有单一响应体可言。
|
||||
- **同一份摘要同时进入异常 message**,故 SQLite/Postgres 遥测的 `error` 列里直接可查,下游不必为此单独埋点。
|
||||
|
||||
### 行为变更
|
||||
|
||||
- **非 2xx 的 message 末尾追加 ` | {响应体摘要}`**,覆盖两个 transport 的**全部**分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 `force_open` 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。
|
||||
- 摘要口径: 先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 **2048 字符**(对齐 Kubernetes client-go 同场景的 `maxUnstructuredResponseTextBytes`)。超长时**保留头 1400 + 尾 600**并记下省略字数——JSON 错误体的 `code` / `request_id` 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。
|
||||
- 遥测 `error` 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。
|
||||
|
||||
### 不变
|
||||
|
||||
- **状态码 → 错误分类的映射逐条未动**(ARCHITECTURE §6.2 表),`retry_after_s` 解析、429 免重试预算、`insufficient_quota` 细分全部保持——429 的类型判定仍解析**未截断的原文**,若改用摘要,超长 body 的配额耗尽会退化成普通限速、该源不再 `force_open`。
|
||||
- 异常类型树、`str(exc)` 之外的字段、遥测 22 字段与列序、DDL 全部未变。**错误面零变更**,下游 `except` 写法不受影响。
|
||||
- 400 仍按确定性失败处理(不重试不换源)。**但请注意**: 经第三方中转部署时,中转自身抖动也会回 400,从状态码上与"你的输入有问题"分不开(下游实测: 同一份字节 sha256 一致、重发 15 次全部成功,失败那次 `prompt_tokens=0` 且耗时远低于任何成功调用)。库不改默认语义——直连供应商时重试只会白烧配额——但 `body_text` 现在给了下游自行区分的判据。
|
||||
|
||||
### 升级提示
|
||||
|
||||
README 的安装 pin 由 `==1.1.*` 改为 `>=1.2,<2`。**仍按 `==1.1.*` 安装的下游会静默停在 1.1.2**,拿不到本次修复且没有任何报错,请同步改自己的依赖约束。
|
||||
|
||||
- 打包元数据补齐: `readme` 与 `[project.urls]`。1.1.2 及之前的包在 registry 页面上**没有任何说明正文**(缺 `readme` 时 twine 只警告不阻塞),也没有仓库链接。代码零变更,自本版生效。
|
||||
|
||||
## 1.1.2(2026-08-07)
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ make ci # 只读验证(check + test)
|
||||
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
|
||||
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
|
||||
| 8 | **验证已发布** | `pip download --no-deps --index-url .../pypi/simple/ "polygateway==X.Y.Z"`,并解包确认新代码在内。**不验证不算发布完成** |
|
||||
| 9 | **建 Release + 核对包页面** | `POST /api/v1/repos/iomgaa/PolyGateway/releases`(body 取 CHANGELOG 本版段;历史上只打 tag 不建 release,Releases 页长期为空);随后打开包页面确认有正文与仓库链接,**Link to a repository 只能在网页手动做**(该实例的 link API 返 404) |
|
||||
| 9 | **建 Release + 挂仓库 + 核对包页面** | `POST /api/v1/repos/iomgaa/PolyGateway/releases`(body 取 CHANGELOG 本版段;历史上只打 tag 不建 release,Releases 页长期为空);挂仓库走 `POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/PolyGateway`(**2026-08-16 实测返 201 可用**,此前记录的"该实例 link API 返 404、只能网页手动"已过时);随后打开包页面确认有正文与仓库链接 |
|
||||
|
||||
> [!CRITICAL]
|
||||
> **发布完成的判据是外部可见结果,不是本地步骤跑通**: 收尾必须以下游视角逐一打开产物页面(registry 包页面正文与仓库链接、仓库 Releases 页、`pip install` 后包内文件),看到什么算什么,缺的当场补进本清单——1.1.2 三步全绿却出现包页面空白(`pyproject` 缺 `readme`)、Releases 页 0 条、包未挂仓库。
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 |
|
||||
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
||||
| 背压与判死 | 配额满可选等待或快速失败;等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace/租户 + salt,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 22 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
||||
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
|
||||
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
|
||||
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
|
||||
@@ -31,7 +32,7 @@
|
||||
|
||||
```bash
|
||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||
"polygateway[redis,postgres,structured]==1.1.*"
|
||||
"polygateway[redis,postgres,structured]>=1.2.1,<2"
|
||||
```
|
||||
|
||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||
@@ -81,7 +82,7 @@ async def main() -> None:
|
||||
await client.aclose() # 归还连接与治理后端资源
|
||||
```
|
||||
|
||||
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制;`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
|
||||
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制,`tenant_id` / `meta` 用于遥测归属(见下文「多租户与自定义维度」);`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
|
||||
|
||||
### 3. OCR 与 Embedding
|
||||
|
||||
@@ -112,6 +113,45 @@ except RequestRejectedError:
|
||||
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
|
||||
```
|
||||
|
||||
### 5. 多租户与自定义维度
|
||||
|
||||
```python
|
||||
resp = await client.chat(
|
||||
messages,
|
||||
tenant_id="acme-corp", # 遥测表的真实列,可挂 RLS
|
||||
meta={"batch_id": "b-42", "stage": "extract"}, # 任意自定义 KV,进 meta 列
|
||||
)
|
||||
```
|
||||
|
||||
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
|
||||
|
||||
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表自动补列,**老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
|
||||
|
||||
**库只提供列,不启用 RLS、不建索引。** 要数据库层的强制隔离,以下 DDL 是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
|
||||
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
|
||||
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
|
||||
ON llm_calls (tenant_id, created_at);
|
||||
```
|
||||
|
||||
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。
|
||||
|
||||
三个陷阱,每一个的失败形态都是**静默的**:
|
||||
|
||||
| 陷阱 | 后果 |
|
||||
|---|---|
|
||||
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
|
||||
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
|
||||
| policy 必须同时写 `USING` 与 `WITH CHECK` | 只写前者则租户 A 读不到 B 的行,却**能插入标着 B 的行**——污染发生在写入侧,读侧查不出来 |
|
||||
|
||||
## 错误模型(四分类)
|
||||
|
||||
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
|
||||
@@ -125,6 +165,8 @@ except RequestRejectedError:
|
||||
|
||||
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
|
||||
|
||||
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
|
||||
|
||||
### 哪些异常会到达调用方
|
||||
|
||||
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
||||
@@ -146,12 +188,16 @@ except RequestRejectedError:
|
||||
|
||||
| 键形态 | 作用 |
|
||||
|---|---|
|
||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD ∈ BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/TRUST_ENV |
|
||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
|
||||
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
|
||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
|
||||
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
|
||||
| `PGW_CACHE_BACKEND` | `none` / `redis`(需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`) |
|
||||
| `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
|
||||
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
|
||||
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
|
||||
|
||||
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
|
||||
|
||||
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "polygateway"
|
||||
version = "1.1.2"
|
||||
version = "1.2.1"
|
||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||
|
||||
@@ -363,6 +363,16 @@ flowchart TB
|
||||
|
||||
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
|
||||
|
||||
**调用方维度追加(2026-08-17,issue #11)**: 四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)签名末尾各增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点:
|
||||
|
||||
① **校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}` 且 `pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。
|
||||
|
||||
② **两者都不进缓存 key**。租户级缓存隔离由既有 `cache_namespace` 负责(§7.5);重复进 key 只会让全部存量缓存冷启动,且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应 miss。
|
||||
|
||||
③ **维度的读取点恒为 `request`**,包括缓存命中行——那一行回答的是"本次调用由谁发起",不是缓存里历史那次。读历史会把本次记到上一个租户头上,两边的账同时错且无任何报错。
|
||||
|
||||
④ **库只交付列,不执行 RLS DDL、不建索引**(理由与模板见 `designs/2026-08-17-issue11-caller-dimensions-design.md` §4.5;下游可达的那份在 README「多租户与自定义维度」一节——`research-wiki/` 不在 sdist 内)。首要理由是 default-deny:启用 RLS 而无匹配 policy = 零行可写且静默不报错,三个下游只有一个是多租户,库若自动启用,其余部署升级后遥测全量写失败,叠加遥测静默降级铁律 = 无声全局丢数据。
|
||||
|
||||
---
|
||||
|
||||
## 6. 错误模型
|
||||
@@ -396,6 +406,10 @@ flowchart TB
|
||||
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) |
|
||||
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
|
||||
|
||||
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
|
||||
|
||||
**400 在中转拓扑下的语义提醒**(同上): 第三方 API 中转服务自身抖动时也会回 400,从状态码上与供应商的"输入非法"无法区分(下游实测: 同一份字节重发 15 次全成功,失败那次 `prompt_tokens=0`、耗时远低于任何成功调用,即请求在推理开始前被挡)。本表**不改** 400 → `RequestRejectedError` 的映射——直连供应商时重试只会白烧配额,且改默认语义等于让所有直连用户为一种部署形态买单;库改为把判据(`body_text`)交给下游自行区分。
|
||||
|
||||
### 6.3 "坏结果 ≠ 坏服务"(ResultInvalidError 语义,继承 CHSAnalyzer)
|
||||
|
||||
由输入内容决定的**确定性失败**(这张图就是解析不出表格、这段输出就是修不成 JSON):服务是健康的,换源重试只会白烧配额。因此熔断器记成功、不换源、异常上抛消耗业务侧的失败预算。出处:`CHSAnalyzer governance.py:237-239`。
|
||||
@@ -471,10 +485,14 @@ flowchart TB
|
||||
|
||||
### 7.8 遥测与成本
|
||||
|
||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**。
|
||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**。
|
||||
|
||||
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
|
||||
|
||||
**`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。
|
||||
|
||||
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
|
||||
|
||||
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
||||
|
||||
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# HTTP 错误响应体留存设计(Issue #10)
|
||||
|
||||
- **日期**: 2026-08-16
|
||||
- **来源**: Gitea Issue #10(下游 1050 张医学影像批处理,1 张收到 400 被判确定性失败;事后无从查证原因。基于 1.1.2 源码核查)
|
||||
- **状态**: **已批准(2026-08-16)**,待 `writing-plans`
|
||||
- **触发档位**: 强制(`errors.py` 属最内层内核,新增公共字段即变更库对下游的承诺)
|
||||
- **方案范围**: 人类明确要求单一方案(2026-08-16),故本文不列平行备选,仅在 §6 记录被否决路线及否决理由(体例沿用 Issue #7/#8 设计)
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
| | 内容 |
|
||||
|---|---|
|
||||
| **G1** | 网关拒绝一次调用时,**它说了什么必须可事后查证**——库自己的遥测表里就能查到,不依赖下游额外埋点 |
|
||||
| **G2** | 留存口径覆盖 transport 层**全部**非 2xx 分支与**全部** transport(chat / embedding / stream / OCR),杜绝"只修 400 → 下次 401 复发" |
|
||||
| **G3** | 摘要文本单点规范化(折叠空白 + 截断 + 截断标记),message 与结构化字段**取同一份串**,两处永不打架 |
|
||||
| **G4** | 不改变任何状态码 → 错误分类的映射(ARCHITECTURE §6.2 表原封不动),下游 `except` 写法零影响 |
|
||||
| **非目标** | 不改 400 的治理语义(不重试不换源,见 §5.2);不新增遥测列(见 §6.1);不新增配置项;不做错误分类可插拔(见 §6.4);不顺手修 `_status_to_error` 的 `operation` 硬编码缺陷(见 §5.4) |
|
||||
|
||||
### 1.1 Issue 前提的两处修正(按 1.1.2 源码核实)
|
||||
|
||||
| Issue 原文 | 实际情况 |
|
||||
|---|---|
|
||||
| 建议方向一「让异常带上截断后的响应体……就能让下游把它记进日志和遥测」 | **只做这一半解决不了 Issue 自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`middleware/retry.py:558` → `middleware/telemetry.py:89` → `telemetry/sqlite.py:43` 的 `error TEXT` 列),即**异常 message**。新增字段不会进库的遥测表;下游说的"写进遥测表"是他们自己的埋点。故本设计**两件都做,且以 message 为主**(§3.3) |
|
||||
| 缺陷范围 = 400 分支 + 4xx 兜底 | 实为 **6 处同构**:`openai_compat._status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 的两支(读了 body 判 `insufficient_quota`,但 message 仍不带),以及 `monkey_ocr._classify_status:74-88` 的**全部**分支(message 只有 `HTTP {status}`)。Issue 场景是"读表格",极可能正落在 OCR 路径 |
|
||||
|
||||
## 2. 根因:诊断信息在翻译层被丢弃,而遥测只看 message
|
||||
|
||||
`_status_to_error`(`transports/openai_compat.py:131-143`)手上握着 `body_text`,却只把它用于 429 的类型细分,翻出的异常与 message 都不携带它。响应体在这一层之后**不再存在于进程任何位置**:该模块无 logger(grep `logger|loguru` 零命中),异常类无字段,遥测只写 message。
|
||||
|
||||
三条留存通道同时为空,是"永久查不到"的完整解释:
|
||||
|
||||
| 通道 | 现状 | 本设计后 |
|
||||
|---|---|---|
|
||||
| 日志 | 模块无 logger | 仍无(§6.2:不加日志) |
|
||||
| 异常字段 | 无承载处 | `body_text`(§3.1) |
|
||||
| 库遥测 `error` 列 | 只有 `"{源名} 请求被拒: 400"` | message 携带摘要(§3.3) |
|
||||
|
||||
## 3. 选定方案
|
||||
|
||||
### 3.1 内核:`PolyGatewayError` 基类新增 `body_text`
|
||||
|
||||
```python
|
||||
class PolyGatewayError(Exception):
|
||||
def __init__(self, message, *, source_name=None, status_code=None,
|
||||
operation=None, body_text: str = "") -> None:
|
||||
```
|
||||
|
||||
**加在基类而非 `RequestRejectedError`**:这些错误全部由同一个 HTTP 响应翻译而来,"对方说了什么"与"它属于哪一类"正交。只给一个子类加,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 一次人类门。
|
||||
|
||||
与既有 `ResultInvalidError.raw_text`(`errors.py:77`)的界限必须在 docstring 钉死,否则两个"原文字段"必然被混用:
|
||||
|
||||
| 字段 | 语义 | 来源 |
|
||||
|---|---|---|
|
||||
| `body_text` | **非 2xx** 的 HTTP 错误响应体摘要——对方**拒绝**你的理由 | transport 翻译层 |
|
||||
| `raw_text` | **2xx** 但内容不可解析时的模型输出原文 | 结构化解析层 |
|
||||
|
||||
`GatewayUnavailableError` 一族继承到一个恒空的 `body_text` 不是噪音:scope 级失败本就"没有单一响应体可言",空串是对这件事的如实表达。
|
||||
|
||||
### 3.2 共享单元:`transports/_http_errors.py`(新建,~40 行)
|
||||
|
||||
两个 transport 各有自己的状态码分类逻辑(OCR 无 429 细分,有意保留,见 `monkey_ocr.py:53-54`),但**摘要口径必须同一份**,否则就是下一个"只修一半"。两函数:
|
||||
|
||||
| 函数 | 职责 | 关键防御 |
|
||||
|---|---|---|
|
||||
| `summarize_body(text) -> str` | 折叠空白 → 按 §3.4 的机械规则截断 | 空/空白入参返回 `""` |
|
||||
| `response_body(response) -> str` | 从 `httpx.Response` 取已缓冲文本 | `ResponseNotRead` → 返回 `""`,**绝不触发网络读** |
|
||||
|
||||
- **折叠空白不是洁癖**:错误体常是缩进 JSON,直接拼进 message 会让一行日志炸成多行、遥测列不可读。
|
||||
- **截断必须留标记**:不标记,读的人分不清"网关只说了这么多"和"库切的"。
|
||||
- **`response_body` 的防御是硬要求**:`monkey_ocr._classify_status` 只拿得到 `httpx.HTTPStatusError`,若某天 OCR 走 stream 请求,`.text` 会抛 `ResponseNotRead`,把一次可分类的 4xx 变成泄漏的 httpx 异常——**违反"一切失败必须落入四分类"铁律**。诊断信息缺失绝不能升级为崩溃(降级方向,§4.2)。
|
||||
|
||||
放在 `transports/` 私有模块而非 `errors.py`:职责是"HTTP 响应 → 领域错误"的工具,放内核会稀释 `errors.py` 的单一职责(P3)。两个 transport 同 import 一个私有模块,不构成 transport 之间的互相依赖,import-linter 的 layers 契约(同层 `|` 独立性)不受影响。
|
||||
|
||||
### 3.3 翻译层:表驱动收口,message 与字段共用一份摘要
|
||||
|
||||
`_status_to_error` 现在是五个分支各拼各的 message,新增摘要意味着五处重复。改为**分类表 + 单点拼装**,代码反而变短:
|
||||
|
||||
```
|
||||
summary = summarize_body(body_text) # 全函数只算一次
|
||||
ctx = {..., "body_text": summary} # 字段
|
||||
429 → _translate_429(source, body_text, headers, ctx) # 需原文判 type,单列
|
||||
其余 → cls, label = _STATUS_MAP 查表 → cls(_compose(source, label, status, summary), **ctx)
|
||||
```
|
||||
|
||||
message 形态:`"{源名} {标签}: {状态码} | {摘要}"`;**摘要为空时不拼后缀**,避免出现悬空的 ` | `。分隔符取 ` | ` 而非既有的 `: `,让"库的话"与"网关的话"一眼可分。
|
||||
|
||||
**429 也拼,不设例外**:例外就是下一个复发点。`insufficient_quota` 那支尤其需要(配额细节全在 body 里);普通限速 body 通常很短。代价是高频限速场景遥测 `error` 列变长,由 `_ERROR_BODY_CAP` 兜住。
|
||||
|
||||
`monkey_ocr._classify_status` 同款处理:`summary = summarize_body(response_body(exc.response))`,message 追加同一后缀,`ctx` 带上字段。
|
||||
|
||||
### 3.4 常量取值
|
||||
|
||||
**机械规则(实现与测试逐字照此)**:
|
||||
|
||||
```
|
||||
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
|
||||
_HEAD_CHARS = 1400
|
||||
_TAIL_CHARS = 600
|
||||
|
||||
折叠空白后 len ≤ 2048 → 原样返回
|
||||
否则 → s[:1400] + f"…(略 {len(s) - 2000} 字)…" + s[-600:]
|
||||
```
|
||||
|
||||
> 规则必须写成算术而非叙述:"截断至 cap 并补标记"能同时被读成总长 2048 与 2049,两者会让测试断言与遥测长度承诺对不上(Codex 审查 2026-08-16 提出)。
|
||||
|
||||
**头尾保留而非头部硬切**(2026-08-16 调研决策)。截断的对象是**结构化 JSON 错误体**,信息分布头重尾也重:人话(`message`)在前,机器可判的 `type` / `code` / `param` / `request_id` 在后。Issue 给出的真实样本即 `"code":"invalid_parameter_error"` 收尾——头部硬切正好切掉向网关方追查时唯一有用的那部分。省略标记记下**被省略的字符数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。
|
||||
|
||||
按**字符**而非字节切:多字节字符不会被切成半个(Sentry 曾为按字节切开 issue #1691),且 `error TEXT` 列无定长约束,无需字节口径。
|
||||
|
||||
### 3.4.1 取值依据:同场景开源实践
|
||||
|
||||
| 项目 | 场景 | 上限 | 保留策略 |
|
||||
|---|---|---|---|
|
||||
| **Kubernetes client-go** `rest/request.go` | **读 HTTP 错误体生成错误信息**(与本设计同构) | `maxUnstructuredResponseTextBytes = 2048` | 头部硬切 |
|
||||
| OpenAI Python SDK `_exceptions.py` | 异常对象持有 body | **不截断**(内存对象,不落库) | — |
|
||||
| Sentry Python `strip_string` | 事件写入前 trim | `max_value_length`,2.34.0 前默认 1024 | 头部 + `...`,另用 metadata 记原长 |
|
||||
| Elastic APM | 长字段 | keyword 1024 / long field 10000 | 截断带省略号 |
|
||||
| Python 标准库 `reprlib` | 给人读的长字符串 | `maxstring` | **头 + 尾,中间省略** |
|
||||
|
||||
**2048 对齐 k8s client-go**——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。初稿的 500 仅以 issue 的单个样本(约 160 字符)为据,是拿一个样本定上限,已废弃。头部硬切在 k8s/Sentry 成立是因为它们截的是任意文本;本设计截的是结构化 JSON,故取 `reprlib` 的头尾策略。
|
||||
|
||||
遥测代价:纯 ASCII 约 2KB/条,纯中文最多约 6KB/条;5xx 重试 3 次即一次调用最多约 18KB。批处理场景(1050 次调用、5% 失败)约 300KB,`TEXT` 列可忽略。
|
||||
|
||||
message 与 `body_text` **共用同一变量**,不设两个长度:两份不同长度会让"遥测里看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
|
||||
|
||||
### 3.5 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `errors.py` | 基类新增 `body_text` 字段 + 与 `raw_text` 的界限 docstring |
|
||||
| `transports/_http_errors.py` | **新建**:`summarize_body` / `response_body` / `_ERROR_BODY_CAP` |
|
||||
| `transports/openai_compat.py` | `_status_to_error` 表驱动重写;`_translate_429` 收 `ctx` |
|
||||
| `transports/monkey_ocr.py` | `_classify_status` 带摘要 |
|
||||
| `errors.py` docstring + `ARCHITECTURE.md` §6.2 | 中转拓扑下 400 的提醒(§5.2) |
|
||||
| `README.md:34` | 安装 pin `==1.1.*` → `>=1.2,<2`(§5.3,发布前置,漏改则下游拿不到本修复) |
|
||||
|
||||
三个调用点(`openai_compat.py:402` embed、`:417` stream、`:509` 非流式)**签名不变**,无需改动。
|
||||
|
||||
## 4. 非功能维度
|
||||
|
||||
### 4.1 并发与取消
|
||||
|
||||
新增全部是纯函数与数据字段,无状态、无锁、无 IO、不引入 `await`。`response_body` 只读已缓冲字节,`ResponseNotRead` 时直接返回空串而**不发起网络读**——否则会在错误路径上凭空插入一次可能挂住的 IO。`CancelledError` 路径逐字不变。
|
||||
|
||||
### 4.2 降级方向
|
||||
|
||||
响应体不可得(未读缓冲 / 解码失败 / 空体)→ `body_text=""`,**静默降级,绝不报错**。诊断信息属可观测性,按库铁律与缓存/遥测同档:缺了降级,不得把一次本可正确分类的失败变成不可分类的崩溃。流式路径的 `(await resp.aread()).decode("utf-8", errors="replace")`(`:416`)已是这个口径,保持。
|
||||
|
||||
### 4.3 幂等与重复
|
||||
|
||||
纯函数,同输入同输出。`summarize_body` 对自身输出再调用一次是幂等的:输出总长恒为 `2000 + len(标记) ≤ 2048`(标记形如 `…(略 N 字)…`,8 + N 的位数,现实中远不足 48),且不含需折叠的空白,故第二次调用走"原样返回"分支,不会出现标记被反复嵌套。
|
||||
|
||||
### 4.4 持久化与原子性
|
||||
|
||||
不新增表、不改 DDL、不动遥测端口的 22 字段与列序。摘要经既有 `error TEXT` 列落盘,原子性由既有单行写入保证。
|
||||
|
||||
### 4.5 安全与体积
|
||||
|
||||
- **响应体可能回显请求内容**(部分网关的 `error.param` 会带违规字段值)。截断 + 空白折叠是主要止血手段;字段 docstring 须写明"可能包含请求回显,已截断"。库不做内容脱敏——库不知道下游哪些字段敏感,猜测式脱敏只会同时丢掉诊断价值与安全性。
|
||||
- **本设计不放大既有的读取风险**:`_complete_stream:416` 的 `aread()` 对错误响应体无大小上限(超大错误体可打爆内存),该风险今天已经存在(读完即丢),留存后只是更显眼。**不夹带修复**,见 §5.4。
|
||||
|
||||
## 5. 错误处理、语义与边界
|
||||
|
||||
### 5.1 错误分类
|
||||
|
||||
不改任何映射。`body_text` 是**旁路数据**,不参与任何治理判定——不影响重试、换源、熔断计数、AIMD、限流结算。这是本设计能与 ARCHITECTURE §6.1/§6.2 零冲突的根本原因。
|
||||
|
||||
### 5.2 400 语义:不改行为,补文档
|
||||
|
||||
Issue 报告了一个有说服力的观察:同字节 15 次重发全部成功、`prompt_tokens=0`、耗时 2996ms 远低于同批 631 次成功调用的最快值 7366ms——说明那次 400 来自中转服务自身抖动,而非"你的输入有问题"。
|
||||
|
||||
**仍不改分类**:400 重试对直连供应商是纯浪费(确定性坏输入,重试只烧配额并拖延失败);"中转也回 400"是**部署拓扑**引入的信息损失,库从状态码无从分辨。默认改为可重试 = 让所有直连用户为一种部署形态买单,且推翻已冻结的公共契约。
|
||||
|
||||
**但本设计本身就是对这个观察最好的答复**:body 留存后,下游能自己区分——中转抖动的 400 体与供应商 `invalid_request_error` 体形态不同。库不替下游做判断,而是把判断所需的信息交出去。配套文档动作:`RequestRejectedError` docstring 与 ARCHITECTURE §6.2 各加一句"经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类"。
|
||||
|
||||
### 5.3 兼容性
|
||||
|
||||
`LLMResponse` 一族的"字段只增不删不改名"约束(ARCHITECTURE §5.1)同样适用于异常。本次是**纯新增关键字参数且带默认值**:既有构造点、既有 `except` 写法、既有 `str(exc)` 消费方全部不受影响。message 文本变化不构成破坏——现有测试对这些 message 无格式依赖(仅 `test_openai_compat.py:558` match 源名)。
|
||||
|
||||
版本 **1.2.0**(公共类型新增字段属 minor;2026-08-16 人类定夺)。
|
||||
|
||||
**发布时必须同步改 README 的安装 pin**:`README.md:34` 现为 `"polygateway[redis,postgres,structured]==1.1.*"`,发 1.2.0 后照此命令安装的下游会**静默停在 1.1.2**——无报错、无警告,与 CLAUDE.md §4.4.1 点名的"极易漏改"完全同款(registry 长期停在 1.0.5 即此类事故)。本次改为 **`>=1.2,<2`**,把"每发一个 minor 就要通知三个下游改 pin"这一反复出现的麻烦一次性消除。此项列入实现计划的发布前置步骤,不是发布日的临时动作。
|
||||
|
||||
### 5.4 有意不夹带的两项(建议单开 issue)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| `_status_to_error` 的 `operation` 硬编码 `"chat"`(`:134`),而 `embed()` 也调它(`:402`) | embedding 的 HTTP 错误在遥测里被标成 `operation="chat"`,是既有数据正确性缺陷,与本 issue 无关 |
|
||||
| `_complete_stream:416` 的 `aread()` 无大小上限 | 恶意/故障网关的超大错误体可打爆内存,属独立的健壮性问题 |
|
||||
|
||||
两项都在本次重构触及的函数附近,但修它们既不服务 G1-G4,也各自需要独立的行为讨论——按反 gold-plating 铁律留给独立 issue。
|
||||
|
||||
## 6. 被否决的路线
|
||||
|
||||
### 6.1 给遥测端口加一列(22 → 23 字段)
|
||||
|
||||
最"正统"的结构化留存,但成本极不相称:端口 Protocol 签名变更 + SQLite/Postgres 双后端 DDL 迁移 + 下游已有表的 ALTER + 列序契约测试全线改动——为一个诊断串付出一次跨三项目的迁移。而复用既有 `error TEXT` 列可达成同样的可查证性。
|
||||
|
||||
### 6.2 只在 `_status_to_error` 打一条 WARNING 日志(Issue 方向二)
|
||||
|
||||
不采纳为**主**手段:日志与遥测是两套留存,日志轮转后仍然查不到,而 Issue 的痛点恰是"事后"。且库铁律要求库不擅自向下游日志流写入高频内容(4xx/5xx 在批处理下可能极高频)。message 携带摘要已让 loguru 侧的下游在捕获点自然拿到同一份信息,再加一条独立日志属重复留存。
|
||||
|
||||
### 6.3 截断放在异常构造器内
|
||||
|
||||
构造器自动规范化更"防遗漏",但会让下游自建异常时传入的文本被悄悄改写,违反 P4;且 message 里的摘要仍需在翻译层单独算一次,反而出现两条规范化路径。选定方案在翻译层算一次、两处共用,更简且更显式。
|
||||
|
||||
### 6.4 错误分类映射可插拔(provider profile 注入 classifier)
|
||||
|
||||
Issue 的中转 400 场景确实指向这个方向,但当前只有一个使用方且他们已用自己的兜底分类解决。`ProviderProfile`(`providers.py:17-45`)目前也没有这个扩展点,加它是新子系统级的设计。YAGNI:等第二个使用方提出。
|
||||
|
||||
## 7. 测试策略(先失败后通过)
|
||||
|
||||
**验收主张**:一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要。这条端到端断言直接对应 Issue 的痛点,是本设计成立与否的唯一硬判据;其余为覆盖性用例。
|
||||
|
||||
| # | 用例 | 覆盖 |
|
||||
|---|---|---|
|
||||
| 1 | **端到端遥测**:mock transport 返回 400 + 真实样本体 → 断言 recorder 收到的 `error` 含摘要 | G1 |
|
||||
| 2 | 参数化状态码(400 / 401 / 404 兜底 / 429 普通 / 429 `insufficient_quota` / 500)→ 断言 message 含摘要且 `exc.body_text` 非空,**分类与既有断言逐一不变** | G2, G4 |
|
||||
| 3 | 超长体 → 前 1400 字符与原文头部逐字相同、**末 600 字符与原文尾部逐字相同**、中段为 `…(略 N 字)…` 且 N 等于实际省略数;`body_text` 与 message 中的摘要逐字相同 | G3 |
|
||||
| 3b | 长度恰为 2048 / 2049 的体 → 前者原样无标记,后者走头尾保留(边界) | §3.4 |
|
||||
| 3c | **尾部关键字段可见**:以 issue 的真实样本尾部 `"code":"invalid_parameter_error"}}` 构造超长体 → 断言该串出现在摘要中 | §3.4 头尾决策的验收 |
|
||||
| 3d | 摘要对自身幂等(再摘要一次不嵌套标记) | §4.3 |
|
||||
| 4 | 多行缩进 JSON → 折叠为单行 | G3 |
|
||||
| 5 | 空体 / 纯空白体 → 不拼悬空分隔符,`body_text == ""` | §3.3 |
|
||||
| 6 | 非 JSON 体、非 UTF-8 字节 → 不抛异常,分类不变 | §4.2 |
|
||||
| 7 | 流式错误路径(`_complete_stream` 415-417)同样带摘要 | G2 |
|
||||
| 8 | `monkey_ocr._classify_status` 同款(含 `ResponseNotRead` 时降级为空串而非抛出) | G2, §4.2 |
|
||||
| 9 | embedding 路径(`:402`)HTTP 错误带摘要 | G2 |
|
||||
|
||||
`tests/unit/test_errors.py:29` 现有的"四类构造形态"参数化用例需扩展 `body_text` 默认值断言(默认 `""`、可传入、`GatewayUnavailableError` 一族恒空)。
|
||||
|
||||
## 8. 人类定夺记录(2026-08-16)
|
||||
|
||||
| 议题 | 定夺 |
|
||||
|---|---|
|
||||
| 摘要上限与保留策略 | 初稿 500 + 头部硬切被否:上限提至 **2048**(对齐 k8s client-go 同场景先例),策略改为**头 1400 + 尾 600 + 省略字数标记**——人类指出"有用的信息可能只在后半部分",经调研证实 JSON 错误体的 `code`/`request_id` 确实收尾(§3.4、§3.4.1) |
|
||||
| 429 是否设例外 | **不设**,一律拼摘要(§3.3) |
|
||||
| 版本 | **1.2.0**,并同步把 README pin 由 `==1.1.*` 改为 `>=1.2,<2`(§5.3) |
|
||||
@@ -0,0 +1,230 @@
|
||||
# 调用方自定义维度设计(issue #11)
|
||||
|
||||
- **状态**: **已人类审批(2026-08-17)**;Codex 审查 4 项已全部采纳并修订
|
||||
- **触发**: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
|
||||
- **范围**: 公共 API(`chat`/`embed` 签名)、`ports.TelemetryRecorder` 端口、`types.ChatRequest`、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求与边界
|
||||
|
||||
### 1.1 issue 原始诉求
|
||||
|
||||
GovDoc-SaaS 是多租户法律文书 SaaS,准备启用 `PGW_TELEMETRY_BACKEND=postgres`。落库是审计链的一半证据(另一半业务事实在其自有库,靠 `call_id` 缝合)。阻塞点: `llm_calls` 22 列**没有任何租户维度**,能区分来源的只有 `session_id`/`parent_call_id` 两个调用方自填、库内不校验的自由字符串。而这张表存**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),即多个租户的完整合同与标书全文混在同一张表里,表结构本身不提供按租户过滤的能力。
|
||||
|
||||
诉求四条: ①真实租户列(不是藏在 `session_id` 里);②`chat()` 与 `embed()` 两条路径都能传;③该列能挂 RLS 或至少能做复合索引与查询条件;④保留期与访问控制(**issue 明说可另开,本设计不含**)。
|
||||
|
||||
**不可逆性是本 issue 的核心论点,且成立**: 先启用后加列,补列之前写进去的每一行都没有租户归属,事后无法还原哪行属于谁——而那些行里是客户合同全文。
|
||||
|
||||
### 1.2 本次放大的范围(2026-08-17 人类决策)
|
||||
|
||||
issue 只要租户维度。人类决定放大为**调用方自定义维度**的通用能力,但明确收窄了两处:
|
||||
|
||||
- **只做调用方自定义的维度**。请求自带信息(模型名、供应商、源名)继续走现有 `model`/`provider`/`source_name`/`model_reported` 列,**库不往新容器写任何自采信息**。
|
||||
- 保留期与访问控制不在本次范围(issue 第 4 条)。**已另开 issue #12**(2026-08-17)。
|
||||
|
||||
**范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖**三条**遥测链路而非两条。issue 与本设计初稿都只说了 `chat()`/`embed()`,但 `OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 同构。**OCR 行与 chat 行落在同一张表**——只覆盖两条会让同一张表里一部分行有租户归属、一部分永远空白,且「先启用后加列则归属无法还原」这条不可逆性论证对 OCR 行同样成立。与 issue #10 同一判断(那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修)。
|
||||
|
||||
### 1.3 明确不做
|
||||
|
||||
不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 `ENABLE ROW LEVEL SECURITY`;不自动建索引;不改动 `_BACKFILL` 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,**已另开 issue #13**)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键既有事实(设计必须服从的约束)
|
||||
|
||||
| # | 事实 | 出处 | 对本设计的约束 |
|
||||
|---|---|---|---|
|
||||
| F1 | `cache_namespace` **已是必填的租户/项目隔离维度**,per-call 可传并进缓存 key,正是为修正 GovDoc「单 client 服务多租户」的缓存毒化 | ARCH §7.5 | 缓存层租户隔离**已完成**,缺口只在遥测层。新维度**不得**再进缓存 key |
|
||||
| F2 | `chat()` 签名「冻结」,但带默认值的 keyword-only 参数不破坏该承诺 | ARCH §5.2 + issue #4 先例 | 新参数只能是 keyword-only + 默认值 |
|
||||
| F3 | 公共类型新增字段必须带默认值(三项目 fake 构造零改动) | ARCH §5.1 约定① | `ChatRequest` 新字段必须有默认 |
|
||||
| F4 | `TelemetryRecorder` 端口 22 字段冻结,且**新增参数不设默认值**(库外无第三方实现者) | `ports.py:247` | 端口扩到 24 字段,不给默认值 |
|
||||
| F5 | 遥测调用点收敛为单一 helper,禁止复制参数列表 | 库铁律 | 只改 `TelemetryEmitter._record` 一处 |
|
||||
| F6 | 遥测写失败降级 warning,不冒泡 | 库铁律 | 校验失败必须在**进洋葱之前**报错,否则被降级吞掉 |
|
||||
| F7 | SQLite 补列探测用 `PRAGMA table_info`;新列必须排在 `created_at` 之后(列序不得分叉) | `sqlite.py:53-60,123` | 新列追加到现有 22 列末尾 |
|
||||
| F8 | PG 侧建表/补列**先探测后 DDL**(权限检查早于 `IF NOT EXISTS`) | `postgres.py:174-210`, issue #3/#9 | 复用现有机制,不新增 DDL 路径 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 备选方案对比
|
||||
|
||||
### 方案 A: `tenant_id` 提列 + `meta` JSON 容器(推荐)
|
||||
|
||||
`llm_calls` 增两列: `tenant_id`(真实列,可挂 RLS、可建复合索引)与 `meta`(JSON 容器,承载任意调用方自定义 KV,**默认不建索引**)。API 增两个 keyword-only 参数。
|
||||
|
||||
**支持证据**: LiteLLM(同为 LLM 网关、同为每调用一行进 Postgres)的 `LiteLLM_SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` 两个 Json 列**没有任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野的更严格版本。六家 LLM 可观测平台(Langfuse/LangSmith/Helicone/Braintrust/Phoenix/OpenLLMetry)无一例外都是"少数物化列 + 一个 KV blob"。
|
||||
|
||||
**代价**: 下游若想再提一个高频维度(如 `business_id`)要等库发新版。这是有意接受的——见方案 C。
|
||||
|
||||
### 方案 B: 纯 `meta` JSON,不提任何列
|
||||
|
||||
最小改动、最通用。**否决**,两条独立的实证:
|
||||
|
||||
① **RLS 会静默退化**。策略挂 `meta->>'tenant_id'` 语法合法,但 PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**来规划,而 `->>`(`jsonb_object_field_text`)未标记 leakproof。pgsql-general 有实证案例(日志直接打印 `not using statistics because function ... is not leak-proof`),Tom Lane 确认根因,报告者**最终解法就是把索引列改成非 JSONB**;Tom Lane 同时警告手工标 leakproof "possibly a security problem"。
|
||||
|
||||
② **planner 对 JSONB 本就没有可用统计**(与 RLS 无关的独立问题)。`@>` 走硬编码 0.1% 选择率;Heap 的复现里真实 50% 选择率被估成 0.1%,行数低估 12 万倍,nested loop join 从 300ms 变 **584 秒**。
|
||||
|
||||
对一个「bug 会同时击穿所有下游」的库,一个在真实数据量下不可预测退化、且退化点极难诊断的方案不可选。
|
||||
|
||||
### 方案 C: 可配置提升列白名单(下游声明 `promoted_keys=[...]`,库据此建列)
|
||||
|
||||
最通用,下游不必等库发版。**否决**,理由分三层:
|
||||
|
||||
① **业界一致禁止**。dbt(`on_schema_change` 默认 `ignore`,新列静默丢弃)、Airbyte("不建议改动最终表,你的改动可能在同步中丢失")、Fivetran(用户自加的列,后续 MERGE **把值置 NULL**,官方唯一方案是建视图)——三家数据集成工具立场完全一致。
|
||||
|
||||
② **本库场景的失败模式更糟**。下游 A 配 `["tenant"]`、B 配 `["dataset"]` 共用一张表: 各自向对方的列写 NULL(尚可忍);但若两方对**同名 key 推断出不同类型**(A 认为 `run_id` 是 TEXT、B 是 BIGINT),第二个到达者的 `ADD COLUMN` 会被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、数据持续污染,是最坏的失败形态。
|
||||
|
||||
③ **与端口契约冲突**。`_COLUMNS`/`_INSERT` 从常量变成运行时拼接,标识符来自配置,SQL 注入面从零变成需要严格校验;`TelemetryRecorder` 的「22 字段冻结」与列序断言测试全部失效。
|
||||
|
||||
**旁证**: 没有任何成熟系统允许"任意 key 自动获得真实列/索引待遇"。唯一的"自动推断"派是 Elasticsearch 的 dynamic mapping,也是唯一有公开事故名的(mapping explosion: 默认 `total_fields.limit=1000`,超限整个写入请求报错;不治理则 master 节点 heap 飙升、`put-mapping` 队列堵塞)。其补救开关 `ignore_dynamic_beyond_limit` 默认仍为 `false`——官方宁可拒写也不默默膨胀。
|
||||
|
||||
### 推荐
|
||||
|
||||
**方案 A**。它同时满足 issue 的 RLS 硬需求(真实列)与人类要求的通用性(JSON 容器),且与同类系统的实际做法逐字吻合。下游需要给 `meta` 里某个 key 加速时,路径是**表达式索引**(`CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k'))`,无 schema 变更、无 ACCESS EXCLUSIVE、写入开销远低于 GIN)或库外建视图,由下游自行决定——这正是 Fivetran 给出的官方答案。
|
||||
|
||||
---
|
||||
|
||||
## 4. 设计细节
|
||||
|
||||
### 4.1 公共 API
|
||||
|
||||
四个公共方法对称新增两个 keyword-only 参数(F2/F3):
|
||||
|
||||
`chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None)`;`embed(texts, *, ..., 同两参数)`;`recognize_text(image, *, ..., 同两参数)` 与 `parse_layout(image, *, ..., 同两参数)`。
|
||||
|
||||
**OCR 两个方法都要改**——只改一个即漏,而漏掉的那半会静默写出无归属的行。
|
||||
|
||||
**为什么 `tenant_id` 独立成参而不是 `meta` 里的一个约定 key**: 它是唯一享有真实列待遇的维度,独立成参让"这个 key 特殊"在签名上自明(P4 显式优于隐式);混在 `meta` 里则需要库偷偷抽取一个魔法 key,调用方拼错 `tenant_id`/`tenantId` 不会报错、只会静默降级成普通维度——正是本 issue 抱怨的失败形态。
|
||||
|
||||
**为什么不复用 `cache_namespace`**: 语义不同。namespace 是**缓存隔离单位**(可以是项目名),租户是**数据归属**;二者在 GovDoc 恰好同值不代表概念相同。复用会让下游无法表达"同租户下多个缓存命名空间",且把缓存决策与审计归属绑死。
|
||||
|
||||
### 4.2 校验规则(全部在进洋葱之前报 `ValueError`)
|
||||
|
||||
| 项 | 规则 | 依据 |
|
||||
|---|---|---|
|
||||
| `tenant_id` | 非空字符串;长度 ≤ 128;首尾空白报错 | 空串是哨兵值的地盘(§4.4),调用方传空串多为 bug |
|
||||
| `meta` key | 非空;`[a-z0-9_.]` 且长度 ≤ 64 | 照搬 OTel semconv 字符集;Langfuse 限「仅字母数字」偏严 |
|
||||
| `meta` key 数量 | ≤ 16 | 量级参考: Salesforce 自定义索引 25、Loki labels 15、OTel 属性 128(对库偏宽) |
|
||||
| `meta` value | 仅 `str`/`int`/`float`/`bool`;嵌套需调用方自行序列化 | OTel AnyValue 的可移植子集;后端普遍只可靠支持标量 |
|
||||
| `meta` value: float | **必须 `math.isfinite`**;`nan`/`inf`/`-inf` 报错 | 见下 |
|
||||
| `meta` value 长度 | `str` ≤ 256 字符 | 对齐 Sentry tag 的 200、Langfuse 的 200 量级 |
|
||||
| 保留前缀 | key 以 `pg_` 开头 → 报错 | LangSmith `ls_`、Traceloop `traceloop.`、Helicone `Helicone-` 同款。**库本次不写入任何 `pg_` key**,纯预留防未来撞名 |
|
||||
|
||||
**非有限 float 必须在入口拒绝(Codex 审查发现)**。`json.dumps({'k': float('nan')})` 产出 `{"k": NaN}`——这是 Python 的扩展语法,**不是合法 JSON**,PG 的 JSONB 会拒收。若放行,一个调用方的输入错误会变成遥测写入失败,再被 F6 的降级吞成 warning,即**把调用方的 bug 转化为静默丢数据**,恰好违反 P5。故两处同时收口: 入口用 `math.isfinite` 校验,序列化用 `json.dumps(..., allow_nan=False)`(实测该参数会对非有限值抛 `ValueError`),后者是入口失守时的第二道闸而非主防线。
|
||||
|
||||
**超限必须报错,不得静默丢弃**。Langfuse 的做法是 value 超 200 字符直接丢弃——这条**不抄**,违反 P5「严禁默认值掩盖错误」。报错点选在**两个公共入口**(`chat()` 与 `embed()`)而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 `overlay` 保护键的现有先例同构(`client.py:223` 的 `validate_request_overlay`),校验函数同样共用一份,不在两个入口各写一遍。
|
||||
|
||||
### 4.3 内部流转: 三条独立链路
|
||||
|
||||
`ChatRequest` 增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] = field(default_factory=dict)`(F3)。二者是**只读快照**,库内中间件永不修改——与 `sampling` 字段同一纪律(issue #4 决策 A)。
|
||||
|
||||
`TelemetryEmitter._record` 是唯一的 recorder 调用点(F5),向 recorder 多传两个参数;三个 emit 入口(`emit_attempt`/`emit_cache_hit`/`emit_terminal_failure`)统一从 `request` 读取,不各自组装。
|
||||
|
||||
**embedding 走的是另一条链,必须单独贯穿(Codex 审查发现)**。`EmbeddingClient` 不经过 chat 洋葱: `embed()` → `_embed_batch()` → `_attempt()` → `_emit()`,而 `_emit()` 在 `embedding.py:360` **现场构造** `ChatRequest` 仅为复用同一个 Emitter,当前只填了 `session_id`/`parent_call_id`。若只改 `chat()`,结果是 chat 行有维度而 embed 行恒为空——**恰好落空 issue 第 2 条诉求**(两条路径都要能传)。故新维度须沿这四层逐层透传,并在 `_emit()` 构造 `ChatRequest` 时填入。
|
||||
|
||||
**OCR 是第三条链,同构同办**(2026-08-17 范围补正)。`recognize_text()`/`parse_layout()` → `_call()` → `_attempt()` → `_emit()`,同样在 `_emit()`(`ocr.py:398`)现场构造 `ChatRequest`。两个公共方法都是入口,都要校验并透传。
|
||||
|
||||
**不顺手重构这两条链的参数列表**: 它们已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 与 OCR 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。
|
||||
|
||||
**缓存命中行与终态失败行同样带维度**: 前者读 `request` 而非缓存中的历史响应(维度是"本次调用由谁发起",不是历史那次);后者虽无具体源,但租户归属是已知的——这两行恰恰是审计最需要的(缓存命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
|
||||
|
||||
**不进缓存 key**(F1)。三条理由: ①`cache_namespace` 已负责隔离,重复; ②进 key 会让全部存量缓存冷启动; ③`meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
|
||||
|
||||
### 4.4 存储层
|
||||
|
||||
两端各追加两列到现有 22 列**末尾**(F7),经现有 `_BACKFILL` 机制补列(F8):
|
||||
|
||||
- Postgres: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta JSONB NOT NULL DEFAULT '{}'::jsonb`
|
||||
- SQLite: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta TEXT NOT NULL DEFAULT '{}'`
|
||||
|
||||
**为什么 `NOT NULL DEFAULT ''` 而不是可空**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见,且静默跳过不报错**。NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**。用哨兵空串则老行归属显式可查(`COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出还有多少行未归属)。同时 PG 11+ 加带非易失默认值的列**不重写全表**(值存进 `pg_attribute.attmissingval`),SQLite 加列是元数据操作,且 SQLite 硬性要求 `NOT NULL` 列必须有非 NULL 常量默认值——三条约束在这个写法上同时满足。
|
||||
|
||||
**`meta` 序列化**: `json.dumps(ensure_ascii=False)`,与既有 `messages` 列同口径。空 dict 落 `'{}'` 而非 NULL,保持"缺省即空容器"的单一语义。
|
||||
|
||||
**库不自动建索引**。`CREATE INDEX` 是 DDL,非 `CONCURRENTLY` 会锁写,而 `CONCURRENTLY` 不能在事务里跑。与不自动 ENABLE RLS 同源(§4.5),交下游执行。文档给出模板: `(tenant_id, created_at)` 复合索引——列序判据是**启用 RLS 后 policy 会给每一条查询隐式追加 `tenant_id = ...` 等值谓词**,它出现在 100% 的谓词里,必然是前导列(Supabase 实测: policy 引用列加索引 171ms → <0.1ms)。
|
||||
|
||||
### 4.5 RLS: 库止步于列 + 模板
|
||||
|
||||
**库绝不执行 `ENABLE`/`FORCE ROW LEVEL SECURITY` 与 `CREATE POLICY`**,只在文档提供可复制的 DDL 模板。四条理由:
|
||||
|
||||
① **default-deny 会击穿非多租户下游**。启用 RLS 而无匹配 policy → 零行可见/可写,静默不报错。三个下游里只有 GovDoc 是多租户,Video-Tree 与 CHSAnalyzer 都不是;库若自动启用,这两家升级后遥测**全量写失败**,再叠加 F6 的静默降级 = **无声全局丢数据**。这才是本 issue「不可逆」担忧的真正落点。
|
||||
|
||||
② **库无权知道角色拓扑**。policy 必须绑定角色,且 AWS 官方要求应用角色**非属主且无 `BYPASSRLS`**;库拿到的只是一条连接串。
|
||||
|
||||
③ **权限不对等**。`CREATE POLICY`/`ALTER TABLE` 要求表属主;按最佳实践部署时库的运行时角色恰好不是属主。
|
||||
|
||||
④ **SQLite 无 RLS**,承诺 RLS 会让两个后端语义不对等;只承诺"列"则两端一致。
|
||||
|
||||
**同类先例一致**: graphile-worker、Ent+Atlas、Citus 官方的 django-multitenant 都把 policy 授权留给使用方;未找到任何"库自动为下游表启用 RLS"的正面先例。
|
||||
|
||||
文档必须同时告知三个陷阱: 表属主默认**豁免** RLS(需 `FORCE`);租户上下文只能用 `set_config(..., true)` 且**必须在显式事务内**(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG **只发 warning 不报错**,表现为策略永远拿不到租户 → fail-closed 到零行);policy 必须同时写 `USING` 与 `WITH CHECK`,只写前者则租户 A 能插入标着 B 的行。
|
||||
|
||||
模板正文(交付物是 wiki 用户文档的一节,此处定稿口径):
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
|
||||
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
|
||||
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
|
||||
ON llm_calls (tenant_id, created_at);
|
||||
```
|
||||
|
||||
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——两者合起来使**未设租户 = 零行**(fail-closed),而不是全部行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 旧版行为审计
|
||||
|
||||
本次不是重写/迁移类任务(无 `reference/` 旧模块被替换),但触及三项既有契约,逐条声明:
|
||||
|
||||
| 契约 | 处置 |
|
||||
|---|---|
|
||||
| `TelemetryRecorder` 22 字段冻结 | **替换为 24 字段**。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改 |
|
||||
| `llm_calls` 22 列 / 列序 | **保留**列序纪律,新列追加末尾;旧表经 `_BACKFILL` 补列,补列失败仍只降级为逐行丢弃(不置 `_failed`) |
|
||||
| `chat()`/`embed()` 签名 | **保留**"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动 |
|
||||
|
||||
**有意放弃**: 无。**未声明的隐式丢弃**: 无。
|
||||
|
||||
---
|
||||
|
||||
## 6. 非功能维度
|
||||
|
||||
**并发与取消**: 新增字段是不可变快照,随请求在各自链路内流转,无共享可变状态,并发调用互不干扰。取消路径不变——`TelemetryMW` 捕获 `CancelledError` 时的 `emit_terminal_failure` 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验在两个公共入口的同步代码里完成(chat 侧在进洋葱之前,embed 侧在切批之前),不涉及 await,无取消窗口。
|
||||
|
||||
**降级方向**: 分两段,方向相反且都符合铁律。**校验失败 → 报错**(`ValueError`,在 `chat()`/`embed()` 入口,调用方可见);**遥测写失败 → 静默降级 warning**(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。
|
||||
|
||||
**幂等与重复**: 不变。`call_id` 仍是主键,`ON CONFLICT DO NOTHING`/`INSERT OR IGNORE` 语义不受影响。同一 `call_id` 重复写入仍被忽略,新增两列不引入新的重复语义。
|
||||
|
||||
**持久化与原子性**: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。
|
||||
|
||||
`meta` 的 JSON 序列化在 emitter 内完成。**初稿曾断言"序列化失败不可达",此论断已被 Codex 审查推翻并修正**: 非有限 float 能通过"值是 `float`"这类朴素类型检查,却产出 PG 拒收的 `NaN`/`Infinity` 字面量,于是失败会落到 emitter 的降级 try 里被吞成 warning——调用方的输入错误变成静默丢遥测。修正后是真正的双层收口: 入口 `math.isfinite` 拒绝(主防线,调用方可见),序列化 `allow_nan=False`(第二道闸)。这里记下推翻过程,是因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式本身容易复发。
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误分类与测试策略
|
||||
|
||||
**错误分类**: 校验失败抛 `ValueError`,**不属**四分类——与 `overlay` 保护键的现有先例一致(构造期错误,发生在洋葱之外,`RetryMW` 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。
|
||||
|
||||
**测试策略**(合并前需先失败后通过的证据):
|
||||
|
||||
单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/`pg_` 前缀拒绝/**非有限 float**),每条断言**报错而非静默丢弃**(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);`ChatRequest` 快照不可变;三个 emit 入口都带上维度(尤其**缓存命中行与终态失败行**——这两条最容易被漏,而它们恰是审计刚需)。
|
||||
|
||||
**三条链路各测一遍**——`chat()`、`embed()`、OCR 两方法都必须有"传入维度 → 遥测行带该维度"的用例。embed 与 OCR 尤其不能省: 它们各经四层透传,任一层漏传都不会报错、只会让维度恒为空。批量切批时**每一批的行都应带同一份维度**(维度属于本次 `embed()` 调用,不随批次变化);OCR 的 `recognize_text` 与 `parse_layout` **各测一个**,只测一个会漏掉另一个的透传缺口。
|
||||
|
||||
非有限 float 单独一条: 断言 `chat(meta={"x": float("nan")})` 抛 `ValueError` 而**不是**写入时降级成 warning——这是 §6 记录的那个被推翻论断的机械化守卫。
|
||||
|
||||
集成层——真实 SQLite 与真实 Postgres 各跑一遍: 新建库列齐全;**旧表(22 列)经 `_BACKFILL` 补列后能写入**,且老行 `tenant_id` 读出为哨兵空串而非 NULL(这是 §4.4 不可逆性论证的机械化验收);补列权限不足时逐行降级而非判死(沿用 issue #9 的既有测试形态)。
|
||||
|
||||
契约层——`TelemetryRecorder` 端口 24 字段与两个 recorder 的 `_COLUMNS` 逐字对齐(现有列序断言测试扩展);`meta` 空 dict 落 `'{}'` 而非 NULL。
|
||||
|
||||
**不测**: RLS 行为本身(库不执行 RLS DDL,那是下游部署的验收项);索引效果(库不建索引)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 开放问题(留待人类审批时确认)
|
||||
|
||||
1. `meta` key 数量上限取 16 是量级推断(Loki 15 / Salesforce 25 / OTel 128),无本项目实测依据。若下游有明确诉求可调,但**必须有一个有限上限**。
|
||||
2. `tenant_id` 长度上限 128 同为推断值。
|
||||
3. 调研另外提出「`_BACKFILL` 自动 ALTER 应降级为默认关闭」(Hangfire `EnableHeavyMigrations` 先例: 防止不受控升级造成长停机或死锁;APScheduler 4.x 则是读到不认识的 schema 版本直接 `RuntimeError` 拒绝启动)。此议题与本 issue 同源(都源于库自管下游 schema)但**属独立架构变更**,按反 gold-plating 不纳入本次。**已另开 issue #13**(2026-08-17)。
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue10-error-body-retention
|
||||
title: "HTTP 错误响应体留存(Issue #10)"
|
||||
date: 2026-08-16
|
||||
---
|
||||
|
||||
# HTTP 错误响应体留存(Issue #10)
|
||||
|
||||
**来源**: Gitea issue #10(CHSAnalyzer3 现场,1050 张影像批处理中 1 张 400 被判确定性失败、事后无从查证)|**范围**: `errors.py` + 两个 transport|**全文**: `designs/2026-08-16-issue10-error-body-retention-design.md`|**相关**: [[design:issue8-stall-budget]](同为下游实测反馈驱动的治理修正)
|
||||
|
||||
## 问题
|
||||
|
||||
网关拒绝一次调用时,它说的话在 transport 翻译层被丢弃,进程中不再有任何副本:该模块无 logger、异常类无承载字段、库遥测只写 message。三条留存通道同时为空,故"永久查不到"。
|
||||
|
||||
## 根因(Issue 前提的关键修正)
|
||||
|
||||
Issue 建议"给异常加 `body_text` 字段,下游就能记进遥测"——**只做这一半解决不了它自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`retry.py:552` → `telemetry.py` → `sqlite.py` 的 `error TEXT` 列),即**异常 message**;新增字段不进库的遥测表。下游说的"写进遥测表"是他们自己的埋点。
|
||||
|
||||
缺陷范围也大于 issue 所述:实为 6 处同构——`_status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 两支(读了 body 判类型却不带),以及 `monkey_ocr._classify_status` 全部分支(message 只有 `HTTP {status}`)。Issue 场景"读表格"极可能正落在 OCR 路径。
|
||||
|
||||
## 选定方案
|
||||
|
||||
**摘要在翻译层算一次,同一份串同时进 message 与新增的基类字段**——前者解决"事后可查"(走既有遥测列,零 DDL),后者解决下游结构化留存。
|
||||
|
||||
| 决策 | 理由 |
|
||||
|---|---|
|
||||
| 字段加在 `PolyGatewayError` 基类,非 `RequestRejectedError` | 这些错误全由同一个 HTTP 响应翻译而来,"对方说了什么"与"属于哪一类"正交;只加子类,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 人类门 |
|
||||
| 与 `ResultInvalidError.raw_text` 的界限写进 docstring | `body_text` = 非 2xx 的拒绝理由;`raw_text` = 2xx 但不可解析的模型输出。两个"原文字段"不钉死必被混用 |
|
||||
| 新建 `transports/_http_errors.py` 共用摘要口径 | 两 transport 各有分类逻辑(OCR 无 429 细分,有意保留),但摘要必须同一份,否则就是下一个"只修一半" |
|
||||
| `_status_to_error` 改表驱动 | 五分支各拼各的 message,加摘要即五处重复;查表 + 单点拼装后代码更短 |
|
||||
| 摘要 = 折叠空白 + 总长 ≤ 2048,超出则**保留头 1400 + 尾 600**,中段记省略字数 | 折叠是因错误体常是缩进 JSON,拼进 message 会炸成多行。2048 对齐 k8s client-go 的 `maxUnstructuredResponseTextBytes`(唯一同场景先例);**头尾保留取自 `reprlib`**——JSON 错误体的 `code`/`request_id` 收尾,头部硬切正好切掉向网关追查唯一有用的部分(人类质疑 + 2026-08-16 调研,初稿的 500 + 头部硬切已废) |
|
||||
| 429 不设例外 | 例外就是下一个复发点;`insufficient_quota` 那支的配额细节全在 body 里 |
|
||||
| 400 治理语义不动,只补文档 | 见下 |
|
||||
|
||||
## 400 语义:不改行为,本次修复本身就是答复
|
||||
|
||||
Issue 给出有力证据(同字节 15 次重发全成功、`prompt_tokens=0`、2996ms 远低于同批成功最快的 7366ms),说明那次 400 来自中转服务抖动而非坏输入。**仍不改分类**:400 重试对直连供应商是纯浪费,而"中转也回 400"是部署拓扑引入的信息损失,库从状态码无从分辨;默认改可重试 = 让所有直连用户为一种部署形态买单。
|
||||
|
||||
但 body 留存后**下游能自己区分**——中转抖动体与供应商 `invalid_request_error` 体形态不同。库不替下游判断,把判断所需的信息交出去。配套在 docstring 与 ARCHITECTURE §6.2 加一句中转拓扑提醒。
|
||||
|
||||
## 被否决的备选
|
||||
|
||||
| 备选 | 否决理由 |
|
||||
|---|---|
|
||||
| 遥测端口加一列(22 → 23 字段) | 端口签名变更 + 双后端 DDL + 下游 ALTER + 列序契约全线改动,为一个诊断串付出跨三项目迁移;复用既有 `error` 列可达成同样可查证性 |
|
||||
| 只打一条 WARNING 日志(issue 方向二) | 日志轮转后仍查不到,而痛点恰是"事后";且 4xx/5xx 在批处理下可能极高频 |
|
||||
| 截断放进异常构造器 | 下游自建异常的文本被悄悄改写(违反 P4),且 message 侧仍需单独算一次,反出现两条规范化路径 |
|
||||
| 错误分类映射可插拔 | Issue 场景确实指向它,但当前只有一个使用方且已用自己的兜底分类解决;`ProviderProfile` 无此扩展点,加它是子系统级设计。YAGNI |
|
||||
|
||||
## 有意不夹带(留独立 issue)
|
||||
|
||||
- `_status_to_error` 的 `operation` 硬编码 `"chat"`,而 `embed()` 也调它 → embedding 的 HTTP 错误在遥测里被标成 chat。
|
||||
- `_complete_stream` 的 `aread()` 对错误响应体无大小上限,超大错误体可打爆内存(既有风险,留存后更显眼)。
|
||||
|
||||
两项都在本次触及的函数附近,但均不服务本 issue 目标,且各需独立行为讨论。
|
||||
|
||||
## 验收主张
|
||||
|
||||
一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要——这条端到端断言是本设计成立与否的唯一硬判据,其余用例为覆盖性(状态码参数化、截断边界 2048/2049、**尾部关键字段可见**、空白折叠、空体不拼悬空分隔符、非 UTF-8 不炸、流式路径、OCR 路径含 `ResponseNotRead` 降级)。
|
||||
|
||||
**发布约束**:版本 1.2.0,且 README 安装 pin 必须由 `==1.1.*` 改为 `>=1.2,<2`——否则照 README 安装的下游静默停在 1.1.2,拿不到本修复。
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue11-caller-dimensions
|
||||
title: "调用方自定义维度设计(issue #11)"
|
||||
date: 2026-08-17
|
||||
---
|
||||
|
||||
# 调用方自定义维度设计(issue #11)
|
||||
|
||||
正文: `2026-08-17-issue11-caller-dimensions-design.md`。状态: **已人类审批(2026-08-17)**,进入 writing-plans。
|
||||
|
||||
审批时三个待定项按设计原值定稿,人类未提出改动: `meta` key 数量上限 16 / `str` value 上限 256 / `tenant_id` 上限 128(量级推断,无本项目实测依据);库不自动建索引、不自动启用 RLS(GovDoc 需 DBA 执行模板 SQL 才拿到数据库层隔离);`_BACKFILL` 自动 ALTER 降级议题**不纳入本次**。
|
||||
|
||||
- **选定方案**: `tenant_id` 提真实列(RLS 硬需求)+ `meta` JSON 容器承载任意调用方自定义 KV(**默认不建索引**)。四个公共方法(`chat`/`embed`/`recognize_text`/`parse_layout`)各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。
|
||||
- **范围(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开,**已建 issue #12**。
|
||||
- **范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖 **chat / embed / OCR 三条**遥测链路。issue 与设计初稿都只说了前两条,但 `OcrClient` 经同一 emitter 写遥测(`ocr.py:426`)且行落**同一张表**,漏掉会让同表内一部分行有归属、一部分永远空白,不可逆性论证对其同样成立(同 issue #10 判断)。
|
||||
- **为什么必须提列而不能纯 JSON**: 两条独立实证。① RLS 挂 `meta->>'tenant_id'` 语法合法但会静默退化——PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**规划,而 `->>` 未标 leakproof;pgsql-general 实证案例的最终解法就是"索引列改成非 JSONB",Tom Lane 警告手工标 leakproof 是安全问题。② 与 RLS 无关的独立问题: planner 对 JSONB 本就无可用统计,`@>` 走硬编码 0.1% 选择率,Heap 复现里行数低估 12 万倍、join 从 300ms 变 584 秒。
|
||||
- **为什么不做"可配置提升列白名单"**: dbt/Airbyte/Fivetran 三家一致禁止用户自定义列(Fivetran 的后续 MERGE 直接把用户列置 NULL,官方方案是建视图)。本库场景更糟: 两个下游对同名 key 推断出不同类型时,第二个到达者的 `ADD COLUMN` 被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、持续污染。且 `_COLUMNS`/`_INSERT` 从常量变运行时拼接,SQL 注入面从零出现,端口"22 字段冻结"与列序断言全部失效。
|
||||
- **同类系统佐证**: LiteLLM(同为 LLM 网关、同为每调用一行进 PG)的 `SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` **无任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野。六家 LLM 可观测平台无一例外都是"少数物化列 + 一个 KV blob"。没有任何成熟系统允许任意 key 自动获得列/索引待遇;唯一的自动推断派 ES dynamic mapping 也是唯一有公开事故名的(mapping explosion)。
|
||||
- **库止步于列 + policy 模板,绝不自动 ENABLE RLS**: 启用 RLS 而无匹配 policy 是 **default-deny**(零行可写,静默不报错)。三个下游里只有 GovDoc 多租户,库若自动启用,另两家升级后遥测全量写失败,叠加"遥测写失败静默降级"铁律 = **无声全局丢数据**——这才是 issue「不可逆」担忧的真正落点。另三条理由: 库无权知道角色拓扑;按最佳实践部署时库的运行时角色恰好不是表属主、无权 `CREATE POLICY`;SQLite 无 RLS,承诺它会让两后端语义不对等。先例(graphile-worker/Ent+Atlas/django-multitenant)一致把 policy 授权留给使用方。
|
||||
- **哨兵值而非 NULL**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见且静默跳过**,故 NULL 的 `tenant_id` 不是"未归属"而是**对所有人永久不可见的黑洞**。用 `NOT NULL DEFAULT ''` 则老行可一条 SQL 审计;同时满足 PG 11+ 加非易失默认值列不重写全表、SQLite 要求 NOT NULL 列必须有非 NULL 常量默认值。
|
||||
- **超限报错而非静默丢弃**: Langfuse 的"value 超 200 字符直接丢弃"**不抄**,违反 P5。报错点在 `chat()` 入口而非遥测写入点——遥测层一切失败都被降级成 warning,校验放那里等于没有校验(同 `overlay` 保护键先例)。
|
||||
- **不进缓存 key**: `cache_namespace` 已是必填的租户隔离维度并已进 key(ARCH §7.5),重复;且进 key 会让存量缓存全量冷启动。
|
||||
- **被否决备选**: 纯 `meta` JSON 不提列(RLS 静默退化);可配置提升列白名单(多下游共表静默写错类型);复用 `cache_namespace` 传租户(缓存隔离单位 ≠ 数据归属,会让下游无法表达"同租户多命名空间");`tenant_id` 混在 `meta` 里当约定 key(拼错不报错,静默降级成普通维度)。
|
||||
- **审查留痕(Codex,2026-08-17)**: 报 4 项,逐条核实后**全部采纳**。① `embed()` 路径覆盖不足——`EmbeddingClient` 不走 chat 洋葱,`_emit()` 在 `embedding.py:360` 现场构造 `ChatRequest`,只改 chat 会导致 embed 行维度恒空,恰好落空 issue 第 2 条诉求;② **非有限 float 会击穿"序列化不可达"论断**——`json.dumps` 把 `nan` 写成 `NaN` 字面量(非合法 JSON,PG JSONB 拒收),失败会被降级吞成 warning,即调用方输入错误转化为静默丢遥测;实测确认后改为入口 `math.isfinite` + 序列化 `allow_nan=False` 双层收口;③ 校验入口表述只写 `chat()`,与双路径 API 不一致;④ §4.5 承诺"提供 RLS 模板"却只给了索引模板,已补上含 `FORCE`/`USING`+`WITH CHECK`/`NULLIF(current_setting(...))` 的完整定稿。第 ② 条的推翻过程已写进正文 §6,因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式容易复发。
|
||||
- **另开议题(已建 issue #13)**: `_BACKFILL` 自动 ALTER 是否应降级为默认关闭(Hangfire `EnableHeavyMigrations` 先例、APScheduler 4.x 版本不认识即拒绝启动)——与本 issue 同源但属独立架构变更,按反 gold-plating 不纳入本次。
|
||||
@@ -150,6 +150,21 @@
|
||||
"id": "plan:issue8-stall-budget-plan",
|
||||
"label": "issue #8 实施计划: stall 非生产性等待口径",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:issue10-error-body-retention",
|
||||
"label": "HTTP 错误响应体留存(Issue #10)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:issue10-error-body-retention-plan",
|
||||
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "plan:issue11-caller-dimensions",
|
||||
"label": "调用方自定义维度实现计划(issue #11)",
|
||||
"type": "plan"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
@@ -271,6 +286,20 @@
|
||||
"relation": "implements",
|
||||
"evidence": "T1-T6 实施该设计,含 §3.6 订正",
|
||||
"added": "2026-08-06T14:58:01.673693+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:issue10-error-body-retention-plan",
|
||||
"target": "design:issue10-error-body-retention",
|
||||
"relation": "implements",
|
||||
"evidence": "7 任务覆盖设计 G1-G4 与 §7 全部验收用例",
|
||||
"added": "2026-08-16T09:50:57.830855+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:issue11-caller-dimensions",
|
||||
"target": "design:issue11-caller-dimensions",
|
||||
"relation": "implements",
|
||||
"evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路",
|
||||
"added": "2026-08-17T10:09:08.967997+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
-3
@@ -1,8 +1,8 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-08-07 15:11 UTC
|
||||
> 自动生成,更新时间:2026-08-17 10:09 UTC
|
||||
|
||||
## design (26)
|
||||
## design (30)
|
||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||
@@ -15,9 +15,12 @@
|
||||
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
|
||||
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
|
||||
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
|
||||
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
|
||||
- [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design`
|
||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention`
|
||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||
@@ -28,6 +31,7 @@
|
||||
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
||||
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||
|
||||
## finding (12)
|
||||
@@ -44,7 +48,7 @@
|
||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||
|
||||
## plan (21)
|
||||
## plan (25)
|
||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||
@@ -55,6 +59,8 @@
|
||||
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
|
||||
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
|
||||
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
|
||||
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
|
||||
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
|
||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||
@@ -63,8 +69,10 @@
|
||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
||||
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||
|
||||
## schema (1)
|
||||
|
||||
@@ -97,3 +97,12 @@
|
||||
- [2026-08-07 15:20 UTC] 新增 design: 建表前先探测,判死只认「确定写不进去」 (design:issue9-telemetry-ddl-probe)
|
||||
- [2026-08-07 15:20 UTC] 重建索引: 62 篇页面
|
||||
- [2026-08-07 15:11 UTC] 重建索引: 62 篇页面
|
||||
- [2026-08-16 09:09 UTC] 新增 design: HTTP 错误响应体留存(Issue #10) (design:issue10-error-body-retention)
|
||||
- [2026-08-16 09:12 UTC] 重建索引: 64 篇页面
|
||||
- [2026-08-16 09:50 UTC] 新增 plan: 实现计划: HTTP 错误响应体留存(Issue #10) (plan:issue10-error-body-retention-plan)
|
||||
- [2026-08-16 09:50 UTC] 新增边: plan:issue10-error-body-retention-plan --implements--> design:issue10-error-body-retention
|
||||
- [2026-08-16 09:50 UTC] 重建索引: 66 篇页面
|
||||
- [2026-08-17 09:53 UTC] 重建索引: 68 篇页面
|
||||
- [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions)
|
||||
- [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions
|
||||
- [2026-08-17 10:09 UTC] 重建索引: 70 篇页面
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# 实现计划: HTTP 错误响应体留存(Issue #10)
|
||||
|
||||
- **设计**: `research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md`(**已批准 2026-08-16**)
|
||||
- **分支**: `feat/issue-10-error-body-retention`
|
||||
- **目标**: 网关拒绝一次调用时,它说的话必须能在库自己的遥测表里被事后查到。
|
||||
- **方案概述**: transport 翻译层把 HTTP 错误响应体折叠空白并按头尾策略摘要,**同一份串**同时拼进异常 message(经既有 `error` 列落遥测)与新增的基类字段 `body_text`(供下游结构化留存)。覆盖两个 transport 的全部非 2xx 分支。不改任何状态码→分类的映射。
|
||||
- **涉及技术**: Python 3.11 / httpx / pytest。无新增依赖。
|
||||
- **保真校验**: 本计划**不涉及** `reference/` 参考实现迁移——错误分类映射逐条不变,保真体现为"既有分类断言全部保留、无一条被改写"(Task 3/4 验收项)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/errors.py` | 改 | 基类 `PolyGatewayError` 新增 `body_text` 字段;与 `raw_text` 的界限 docstring;`RequestRejectedError` 补中转拓扑提醒 |
|
||||
| `src/polygateway/transports/_http_errors.py` | **新建** | 摘要口径单一实现:`summarize_body` / `compose_message` / `response_body` + 三个常量 |
|
||||
| `src/polygateway/transports/openai_compat.py` | 改 | `_status_to_error` 表驱动重写;`_translate_429` 收 `ctx` |
|
||||
| `src/polygateway/transports/monkey_ocr.py` | 改 | `_classify_status` 带摘要 |
|
||||
| `tests/unit/test_http_error_body.py` | **新建** | 摘要单元的纯函数用例(截断边界、头尾保留、折叠、幂等) |
|
||||
| `tests/unit/test_openai_compat.py` | 改 | 状态码参数化断言 message + 字段;超长 `insufficient_quota` 回归 |
|
||||
| `tests/unit/test_monkey_ocr.py` | 改 | OCR 分支同款 + `ResponseNotRead` 降级 |
|
||||
| `tests/unit/test_errors.py` | 改 | `body_text` 默认值与可传性 |
|
||||
| `tests/integration/test_governance_stack.py` | 改 | **端到端验收**:400 调用后 SQLite `error` 列含摘要 |
|
||||
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` / `research-wiki/ARCHITECTURE.md` | 改 | 文档与 1.2.0 版本号 |
|
||||
|
||||
## 关键接口(跨任务消费,必须逐字一致)
|
||||
|
||||
```python
|
||||
# src/polygateway/transports/_http_errors.py
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx # response_body 的类型与 ResponseNotRead 都来自它
|
||||
|
||||
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
|
||||
_HEAD_CHARS = 1400
|
||||
_TAIL_CHARS = 600
|
||||
|
||||
|
||||
def summarize_body(text: str) -> str:
|
||||
"""折叠空白后按头尾策略摘要;空/空白入参返回空串。"""
|
||||
collapsed = " ".join(text.split())
|
||||
if len(collapsed) <= _ERROR_BODY_CAP:
|
||||
return collapsed
|
||||
omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS
|
||||
return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}"
|
||||
|
||||
|
||||
def compose_message(message: str, summary: str) -> str:
|
||||
"""摘要非空才拼后缀,避免悬空分隔符。"""
|
||||
return f"{message} | {summary}" if summary else message
|
||||
|
||||
|
||||
def response_body(response: httpx.Response) -> str:
|
||||
"""取已缓冲的响应文本;未读缓冲一律降级空串,绝不触发网络读。"""
|
||||
try:
|
||||
return response.text
|
||||
except httpx.ResponseNotRead:
|
||||
return ""
|
||||
```
|
||||
|
||||
```python
|
||||
# src/polygateway/errors.py
|
||||
class PolyGatewayError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
source_name: str | None = None,
|
||||
status_code: int | None = None,
|
||||
operation: str | None = None,
|
||||
body_text: str = "",
|
||||
) -> None:
|
||||
```
|
||||
|
||||
```python
|
||||
# src/polygateway/transports/openai_compat.py
|
||||
# 既有 errors 导入(:18-23)须补入 PolyGatewayError —— 当前只导了四个子类,
|
||||
# 直接写 _classify 的返回注解会让 ruff 报 F821 未定义名。
|
||||
from polygateway.errors import (
|
||||
PolyGatewayError, # ← 新增
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||
|
||||
|
||||
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
|
||||
"""状态码 → (错误类, message 标签);映射与 1.1.2 逐条相同。"""
|
||||
if status in (401, 403):
|
||||
return SourceDeadError, "凭据失效/欠费"
|
||||
if status == 400:
|
||||
return RequestRejectedError, "请求被拒"
|
||||
if status >= 500:
|
||||
return TransientError, "瞬时错误"
|
||||
return RequestRejectedError, "客户端错误"
|
||||
|
||||
|
||||
def _status_to_error(
|
||||
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
|
||||
) -> Exception:
|
||||
summary = summarize_body(body_text) # 全函数只算一次
|
||||
ctx: dict[str, Any] = {
|
||||
"source_name": source.name,
|
||||
"status_code": status,
|
||||
"operation": "chat",
|
||||
"body_text": summary,
|
||||
}
|
||||
if status == 429:
|
||||
return _translate_429(source, body_text, headers, ctx) # 传**原文**,见下
|
||||
cls, label = _classify(status)
|
||||
return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
|
||||
```
|
||||
|
||||
> **实现红线**:`_translate_429` 判 `insufficient_quota` 必须解析**未截断的原文** `body_text`,不得改用 `summary`。摘要会破坏 JSON 结构,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`——那是把一个诊断改进变成治理 bug。Task 3 有专门的回归用例钉死这条。
|
||||
|
||||
## 任务清单
|
||||
|
||||
### - [ ] Task 1: 内核新增 `body_text` 字段
|
||||
|
||||
**改**: `src/polygateway/errors.py`
|
||||
|
||||
- `PolyGatewayError.__init__` 按上文签名新增 `body_text: str = ""`,存为实例属性。
|
||||
- 类 docstring 增补与 `ResultInvalidError.raw_text` 的界限:`body_text` = 非 2xx 的 HTTP 错误响应体摘要(对方拒绝的理由);`raw_text` = 2xx 但内容不可解析时的模型输出。并写明"可能包含请求回显,已截断"。
|
||||
- **不动** `TransientError` / `SourceDeadError` / `RequestRejectedError` / `ResultInvalidError` / `GatewayUnavailableError` 的任何既有签名与行为。
|
||||
|
||||
**测试**(`tests/unit/test_errors.py`,扩展 `:29` 的四类构造形态参数化):
|
||||
- 四个 transport 错误类默认 `body_text == ""`;显式传入后可读回。
|
||||
- `GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` 的 `body_text` 恒为 `""`(它们不经 HTTP 响应翻译)。
|
||||
|
||||
**验收**: 新增字段不改变任何既有异常的 `str()` 输出。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 2: 共享摘要单元
|
||||
|
||||
**新建**: `src/polygateway/transports/_http_errors.py`(按上文"关键接口"逐字实现,**含其中的 `import httpx`**,加中文模块/函数 docstring 解释**为什么**折叠空白、为什么头尾保留、为什么 `response_body` 必须降级)
|
||||
|
||||
**新建测试**: `tests/unit/test_http_error_body.py`
|
||||
|
||||
| 用例 | 断言 |
|
||||
|---|---|
|
||||
| 短体原样 | `summarize_body('{"a":1}') == '{"a":1}'` |
|
||||
| 空白折叠 | 多行缩进 JSON → 单行,无连续空格 |
|
||||
| 空 / 纯空白入参 | 返回 `""` |
|
||||
| 长度恰 2048 | 原样返回,无标记 |
|
||||
| 长度 2049 | 走头尾策略 |
|
||||
| 超长体头尾 | 前 1400 字符 == 原文前 1400;**末 600 字符 == 原文末 600**;中段标记内 N == `len(原文) - 2000` |
|
||||
| **尾部关键字段可见**(设计 §7 用例 3c) | 以 issue 真实样本尾部 `"code":"invalid_parameter_error"}}` 收尾构造超长体 → 断言该串出现在摘要中 |
|
||||
| 幂等 | `summarize_body(summarize_body(x)) == summarize_body(x)`(标记不嵌套) |
|
||||
| `compose_message` | 摘要为空时返回原 message 不变;非空时以竖线分隔符拼接 |
|
||||
| `response_body` 降级 | `httpx.Response(400, stream=<未读 SyncByteStream>)` → 返回 `""` 且不抛(构造法见下) |
|
||||
|
||||
未读响应的构造(已实测可用):
|
||||
|
||||
```python
|
||||
class _Unread(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"body"
|
||||
|
||||
resp = httpx.Response(400, stream=_Unread()) # 未 read → .text 抛 ResponseNotRead
|
||||
```
|
||||
|
||||
**验收**: 摘要总长恒 ≤ `2000 + len(标记)`;头尾各自与原文逐字对应。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_http_error_body.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 3: openai_compat 翻译层收口
|
||||
|
||||
**改**: `src/polygateway/transports/openai_compat.py`
|
||||
|
||||
- 新增 `_classify`,`_status_to_error` 按上文骨架重写(五分支各拼各的 message → 查表 + 单点拼装)。
|
||||
- `_translate_429` 签名改为 `(source, body_text, headers, ctx)`,两支 message 各自追加 `compose_message` 后缀,构造改用 `**ctx`;**`json.loads` 仍读原文 `body_text`**。
|
||||
- message 主体逐字保持 1.1.2 原样(`凭据失效/欠费: {status}` / `请求被拒: 400` / `瞬时错误: {status}` / `客户端错误: {status}` / `配额耗尽(insufficient_quota)` / `限速: 429`),只在末尾追加 ` | {摘要}`。
|
||||
- 三个调用点(`:402` embed、`:417` stream、`:509` 非流式)签名不变,**不改动**。
|
||||
- **补 import**:`PolyGatewayError`(errors)与 `compose_message` / `summarize_body`(`._http_errors`),见上文关键接口——漏补则 `make lint` 报 F821(Codex 审查 2026-08-16 提出)。
|
||||
- **不改** `operation` 硬编码 `"chat"`(设计 §5.4 有意留给独立 issue)。
|
||||
|
||||
**测试**(`tests/unit/test_openai_compat.py`,沿用既有 `_transport_for(handler)` + `httpx.MockTransport`):
|
||||
|
||||
| # | 用例 | 断言 |
|
||||
|---|---|---|
|
||||
| 3.1 | 状态码参数化 400 / 401 / 403 / 404 / 500 / 503,handler 返回带真实样本体 | 异常类型与 1.1.2 **逐条相同**;message 含摘要;`exc.body_text` == 摘要 |
|
||||
| 3.2 | 429 普通限速(body 无 `insufficient_quota`) | `TransientError`,message 含摘要,`retry_after_s` 解析不受影响 |
|
||||
| 3.3 | 429 + `insufficient_quota` | `SourceDeadError`,message 含摘要 |
|
||||
| 3.4 | **回归红线**:429 + `insufficient_quota` 且 body 长度 > 2048(前置大量填充字段) | 仍判 `SourceDeadError`——证明类型判定读的是原文而非摘要 |
|
||||
| 3.5 | 空 body 的 400 | message 无悬空分隔符,`body_text == ""` |
|
||||
| 3.6 | 非 JSON body、非 UTF-8 字节 body | 不抛额外异常,分类不变 |
|
||||
| 3.7 | 流式路径(handler 对 stream 请求返回 400 + body) | 经 `_complete_stream:415-417` 抛出的异常同样带摘要 |
|
||||
| 3.8 | embedding 路径(`transport.embed(...)` 遇 400) | 同样带摘要 |
|
||||
|
||||
**验收**: 既有测试零修改通过(除 3.x 新增外);`test_openai_compat.py:558`(match 源名)仍 PASS。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 4: monkey_ocr 同款收口
|
||||
|
||||
**改**: `src/polygateway/transports/monkey_ocr.py`
|
||||
|
||||
- `_classify_status`:`summary = summarize_body(response_body(exc.response))`,三支 message 统一经 `compose_message` 追加后缀,`ctx` 带 `body_text=summary`。
|
||||
- message 主体保持 `f"{source_name} OCR {operation} HTTP {status}"` 不变。
|
||||
- 429/5xx → `TransientError`、401/403 → `SourceDeadError`、其余 → `RequestRejectedError` 的映射**逐条不变**(OCR 无 429 细分是设计有意保留,见模块 docstring `:53-54`)。
|
||||
|
||||
**测试**(`tests/unit/test_monkey_ocr.py`):
|
||||
- 扩展 `:300` 的状态码参数化:各分支 message 含摘要且 `body_text` 非空,分类不变。
|
||||
- `ResponseNotRead` 降级:`exc.response` 为未读流 → `body_text == ""`,message 无悬空分隔符,**分类仍正确**(不得因取 body 失败而改变错误类型或抛出 httpx 异常)。
|
||||
|
||||
**验收**: `:195` 与 `:300` 既有断言不被改写。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_monkey_ocr.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 5: 端到端遥测验收(**本计划的硬判据**)
|
||||
|
||||
**改**: `tests/integration/test_governance_stack.py`
|
||||
|
||||
新增用例,沿用既有 `_full_client(handler, telemetry=SQLiteRecorder(...))` 与 `:135` 的 `SELECT error FROM llm_calls` 断言模式:
|
||||
|
||||
- handler 对 chat 请求返回 `httpx.Response(400, content=<issue #10 真实样本体>)`。
|
||||
- `client.chat(...)` 抛 `RequestRejectedError`(400 不重试不换源,行为不变)。
|
||||
- `recorder.close()` 后查 `SELECT error FROM llm_calls`:该行 `error` 串**含样本体里的 `InvalidParameter` 与结尾的 `invalid_parameter_error`**。
|
||||
|
||||
真实样本体(取自 issue #10 原文,一字不改):
|
||||
|
||||
```json
|
||||
{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal and cannot be opened","type":"invalid_request_error","param":"","code":"invalid_parameter_error"}}
|
||||
```
|
||||
|
||||
**验收**: 这条断言在 Task 1-4 之前**必然失败**(1.1.2 的 `error` 列只有 `"qwen_1 请求被拒: 400"`),之后通过——这就是本 issue 的"先失败后通过"证据主体,执行时须保留失败输出截图/文本进提交说明。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/integration/test_governance_stack.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 6: 文档与版本
|
||||
|
||||
**改**:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `src/polygateway/errors.py` | `RequestRejectedError` docstring 加一句:经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类(设计 §5.2) |
|
||||
| `research-wiki/ARCHITECTURE.md` §6.2 | 同一提醒 + 注明四分类错误自 1.2.0 起携带 `body_text` |
|
||||
| `CHANGELOG.md` | 新增 `## 1.2.0(2026-08-16)` 段:行为变更(message 追加摘要 → 遥测 `error` 列变长)、新增字段、不变项(分类映射零变更、错误面零变更) |
|
||||
| `README.md:34` | 安装 pin `==1.1.*` → **`>=1.2,<2`**(2026-08-16 人类定夺;漏改则下游静默停在 1.1.2) |
|
||||
| `pyproject.toml` + `src/polygateway/__init__.py` | 版本号 `1.1.2` → `1.2.0`,**两处必须一致** |
|
||||
|
||||
**验收**: `grep -rn "1\.1\.\*" README.md` 零命中;两处版本号一致。
|
||||
**验证**: `conda run -n PolyGateway python -c "import polygateway; print(polygateway.__version__)"` → `1.2.0`。
|
||||
|
||||
### - [ ] Task 7: 合并前全量门
|
||||
|
||||
1. `make lint`(ruff + import-linter)→ 零违规,**重点确认新建 `transports/_http_errors.py` 未触发洋葱分层契约**。
|
||||
2. `make test` 全套件 → 全 PASS,覆盖率不低于既有水平。
|
||||
3. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3 Phase 2 硬门):逐条核对 Task 1-6 验收项与本会话工具输出。
|
||||
4. `finishing-a-development-branch` 合并回 main(`--no-ff`),合并后在 main 上重跑 `make lint` 与全套件。
|
||||
|
||||
**发布**(合并后)严格按 CLAUDE.md §4.4.1 九步执行,不在本计划展开;其中步骤 1(更新 README)已在 Task 6 前置完成,**构建前须再次确认 pin 已是 `>=1.2,<2`**。
|
||||
|
||||
## 执行顺序与提交点
|
||||
|
||||
```
|
||||
Task 1 ──┐
|
||||
├── Task 3 ──┐
|
||||
Task 2 ──┴── Task 4 ──┴── Task 5 ── Task 6 ── Task 7
|
||||
```
|
||||
|
||||
Task 1 与 2 可并行(互不依赖);Task 3、4 都依赖 1+2;Task 5 依赖 3;Task 6 独立于代码但须在 Task 7 之前。每个 Task 一次语义化提交(`commit` skill),Task 5 的提交说明须附"修复前失败、修复后通过"的实际输出。
|
||||
@@ -0,0 +1,269 @@
|
||||
# 实现计划: 调用方自定义维度(issue #11)
|
||||
|
||||
- **目标**: 让调用方能在每次调用上附带租户标识与任意自定义维度,并落进遥测表——`tenant_id` 为真实列(可挂 RLS),其余进 `meta` JSON 容器。
|
||||
- **方案概述**: `llm_calls` 增两列(`tenant_id TEXT NOT NULL DEFAULT ''`、`meta` JSON);三个公共入口(`chat`/`embed`/OCR 两方法)各增两个带默认值的 keyword-only 参数;校验在入口收口并抛裸 `ValueError`;`TelemetryRecorder` 端口 22 → 24 字段。库不建索引、不启用 RLS,只交付 policy 模板。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md`(已人类审批 2026-08-17)。
|
||||
- **涉及技术**: Python 3.11+、frozen dataclass、asyncpg、sqlite3、pytest。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。
|
||||
|
||||
## 范围: 三条遥测链路(2026-08-17 人类追认)
|
||||
|
||||
设计初稿只覆盖 `chat()` 与 `embed()`(issue 只诉求这两条)。写计划时核实代码发现**第三条链路**: `OcrClient` 同样经 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 完全同构。OCR 行与 chat 行落在**同一张表**,不处理则同表内一部分行有租户归属、一部分永远空白,且同样不可逆。
|
||||
|
||||
**人类已追认纳入正式范围**(2026-08-17),设计文档 §1.2 已同步补正。Task 6 是必做项,**不是可跳过的分支**——与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/types.py` | 修改 | 新增 `validate_caller_dimensions()`;`ChatRequest` 增两字段 |
|
||||
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 22 → 24 字段 |
|
||||
| `src/polygateway/telemetry/sqlite.py` | 修改 | DDL 增两列、`_BACKFILL_COLUMNS` 增两项、`_COLUMNS` 增两项 |
|
||||
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上(`_DDL`/`_BACKFILL`/`_COLUMNS`) |
|
||||
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 归一化并透传;三个 emit 入口从 request 读取 |
|
||||
| `src/polygateway/client.py` | 修改 | `chat()` 增两参数并校验 |
|
||||
| `src/polygateway/embedding.py` | 修改 | `embed()` 增两参数;沿 `_embed_batch`/`_attempt`/`_emit` 透传 |
|
||||
| `src/polygateway/ocr.py` | 修改 | `recognize_text`/`parse_layout` 增两参数;沿 `_call`/`_attempt`/`_emit` 透传 |
|
||||
| `tests/unit/test_types.py` | 修改 | 校验函数红线 |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 三个 emit 入口带维度 |
|
||||
| `tests/unit/test_client.py`、`test_embedding.py`、`test_ocr_client.py` | 修改 | 三条链路各自的端到端透传 |
|
||||
| `tests/unit/test_ports.py` | 修改 | 端口 24 字段契约 |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG 补列与写入 |
|
||||
| `README.md`、`CHANGELOG.md`、Gitea wiki | 修改 | 能力表、RLS 模板、版本说明 |
|
||||
|
||||
**依赖顺序**: Task 1 → Task 2 → Task 3 → (Task 4 / 5 / 6 可并行) → Task 7 → Task 8。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
校验函数(`types.py`,紧邻 `validate_request_overlay` 放置,同款风格):
|
||||
|
||||
```python
|
||||
def validate_caller_dimensions(
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any] | None,
|
||||
*,
|
||||
origin: str,
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
"""校验调用方维度并返回浅拷贝;origin 用于把错误指回调用点。"""
|
||||
```
|
||||
|
||||
`ChatRequest` 新字段(必须带默认值,ARCH §5.1 约定①):
|
||||
|
||||
```python
|
||||
tenant_id: str | None = None
|
||||
meta: Mapping[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
`TelemetryRecorder.record_llm_call` 新增两个**无默认值**参数(`ports.py` 现有纪律),排在 `reasoning_tokens` 之后:
|
||||
|
||||
```python
|
||||
tenant_id: str, # 已归一化: None → ''
|
||||
meta: str, # 已序列化: 空 dict → '{}'
|
||||
```
|
||||
|
||||
**归一化在 emitter 完成,不在 recorder**——与 `sampling` 列由 `canonical_sampling_json()` 在 emitter 侧定型是同一先例。recorder 只负责落库,不做语义判断。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 校验函数与请求字段
|
||||
|
||||
**文件**: `src/polygateway/types.py`(修改)、`tests/unit/test_types.py`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
在 `validate_request_overlay` 之后新增 `validate_caller_dimensions()`,按 Phase 组织(照搬既有风格):
|
||||
- Phase 1 `tenant_id`: `None` 直接放行;非 `str` 报错;**`tenant_id != tenant_id.strip()` 报错**(首尾空白一律拒绝,不是"strip 后为空才拒绝"——`" t1"` 与 `"t1"` 会在 RLS policy 的等值比较下变成两个不同租户,静默漏数据);`strip()` 后为空亦报错(空串是哨兵值的地盘);长度 > 128 报错。
|
||||
- Phase 2 `meta` 键形态: 非 `str` 报错;不匹配 `^[a-z0-9_.]{1,64}$` 报错;以 `pg_` 开头报错(保留前缀)。
|
||||
- Phase 3 `meta` 键数量: > 16 报错。
|
||||
- Phase 4 `meta` 值: 类型不属 `(str, int, float, bool)` 报错(注意 `bool` 是 `int` 子类,先判 `bool` 无妨,两者都合法);`float` 且 `not math.isfinite(v)` 报错;`str` 且长度 > 256 报错。
|
||||
- 返回 `(tenant_id, dict(meta or {}))` —— 拷贝,防调用方复用同一 dict 逐次改值造成竞态(同 `overlay` 先例)。
|
||||
|
||||
`ChatRequest` 增两字段(见"关键接口")。字段 docstring 说明: 只读快照,库内中间件永不修改;`meta` 不进缓存 key(`cache_namespace` 已负责租户隔离,ARCH §7.5)。
|
||||
|
||||
**`ChatRequest.meta` 必须保存校验函数返回的那个浅拷贝**,不是调用方传入的原 dict——否则调用方复用同一 dict 逐次改值会让已在洋葱中流转的请求跟着变(同 `overlay` 拷贝语义的理由)。
|
||||
|
||||
**验收标准**: 每条红线抛 `ValueError` 且消息含 `origin`;合法输入返回浅拷贝且与入参不是同一对象。
|
||||
|
||||
**测试要求**(先失败后通过):
|
||||
逐条红线各一个用例——`tenant_id` 空串/纯空白/**`" t1"`/`"t1 "`(首尾空白)**/超长/非 str;`meta` 键非 str/含大写/含连字符/超 64 字符/`pg_` 前缀/17 个键;值为 `list`/`dict`/`None`/`nan`/`inf`/`-inf`/超 256 字符的 str。另加合法路径用例: `tenant_id=None` + `meta={}` 放行、`meta` 值为 `bool`/`int`/`float` 有限值放行、返回值是拷贝(改返回值不影响入参)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_types.py -v` → 全 PASS
|
||||
|
||||
**另需一条缓存隔离测试**(放 `tests/unit/test_cache.py` 或 `test_client.py`): 相同 `messages` + 相同 `cache_namespace`、**仅 `meta` 不同**的两次调用,第二次**仍应命中缓存**。设计明确 `meta` 不进缓存 key(`cache_namespace` 已负责租户隔离);没有这条测试,实现者顺手把 `meta` 并进 key 不会被任何断言拦住,后果是存量缓存全量冷启动且此后命中率持续偏低——这类退化不报错、只表现为变慢。
|
||||
|
||||
- [ ] 提交点: `feat: validate the dimensions a caller may attach to a call`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 端口与两个遥测后端 schema
|
||||
|
||||
**文件**: `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`tests/unit/test_ports.py`(均修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
`ports.py`: `record_llm_call` 增 `tenant_id: str` 与 `meta: str`(无默认值),docstring 的"22 字段冻结"改为 24 并说明新字段已归一化。
|
||||
|
||||
`sqlite.py` 三处同步改(顺序必须一致):
|
||||
- `_DDL` 在 `reasoning_tokens` 之后追加 `tenant_id TEXT NOT NULL DEFAULT ''` 与 `meta TEXT NOT NULL DEFAULT '{}'`;
|
||||
- `_BACKFILL_COLUMNS` 追加 `("tenant_id", "TEXT NOT NULL DEFAULT ''")` 与 `("meta", "TEXT NOT NULL DEFAULT '{}'")` —— SQLite 硬性要求 `NOT NULL` 列必须带非 NULL 常量默认值,缺默认值会报 `Cannot add a NOT NULL column with default value NULL`;
|
||||
- `_COLUMNS` 追加两项。
|
||||
|
||||
`postgres.py` 同三处:
|
||||
- `_DDL` 追加 `tenant_id TEXT NOT NULL DEFAULT ''` 与 `meta JSONB NOT NULL DEFAULT '{}'::jsonb`;
|
||||
- `_BACKFILL` 追加两条 `ALTER TABLE llm_calls ADD COLUMN ...`(默认值均为非易失常量,PG 11+ 不重写全表);
|
||||
- `_COLUMNS` 追加两项。
|
||||
|
||||
**新列必须排在末尾**(`created_at` 与既有补列之后)——旧表只能 ALTER 追加,新建库若插在前面两条路径的物理列序会分叉(`sqlite.py:53` 既有注释)。
|
||||
|
||||
**验收标准**: 两个后端的 `_COLUMNS` 逐字同名同序;新建库与旧表补列后列集合一致。
|
||||
|
||||
**测试要求**(先失败后通过): 扩展现有列序断言测试,断言两后端 `_COLUMNS` 相等且末两项为 `("tenant_id", "meta")`;`test_ports.py` 断言 `record_llm_call` 的参数集合含新两项且**无默认值**(用 `inspect.signature` 实测,不凭记忆)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: give the telemetry table a tenant column and a meta container`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Emitter 透传与归一化
|
||||
|
||||
**文件**: `src/polygateway/middleware/telemetry.py`(修改)、`tests/unit/test_telemetry.py`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
`_record` 增两个形参,**位置排在现有末参 `reasoning_tokens` 之后**(它是 keyword-only,顺序不影响调用,但与 `_COLUMNS`/端口的追加位置保持一致便于逐行比对):
|
||||
|
||||
```python
|
||||
async def _record(
|
||||
self, *, ...,
|
||||
reasoning_tokens: int | None,
|
||||
tenant_id: str | None, # 新增: 未归一化,None 合法
|
||||
meta: Mapping[str, Any], # 新增: 未序列化,空 dict 合法
|
||||
) -> None:
|
||||
```
|
||||
|
||||
在传给 recorder 前归一化: `tenant_id or ''`;`json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)`,空 dict 直接用字面量 `'{}'`。
|
||||
|
||||
`allow_nan=False` 是第二道闸(主防线是 Task 1 的入口校验)——`json.dumps` 默认把 `nan` 写成 `NaN` 字面量,那不是合法 JSON,PG 的 JSONB 会拒收,失败会被 emitter 的降级 try 吞成 warning,即把调用方的输入错误变成静默丢遥测。
|
||||
|
||||
三个 emit 入口统一从 `request.tenant_id` / `request.meta` 读取,不各自组装(遥测调用点收敛铁律):
|
||||
- `emit_attempt`、`emit_terminal_failure`: 直接读 `request`;
|
||||
- `emit_cache_hit`: **同样读 `request` 而非缓存中的历史响应**——维度是"本次调用由谁发起",不是历史那次。
|
||||
|
||||
**验收标准**: 三条路径写出的行都带维度;`tenant_id=None` 落 `''`;`meta={}` 落 `'{}'` 而非 NULL。
|
||||
|
||||
**测试要求**(先失败后通过): 用 fake recorder 断言三个入口各自收到的 `tenant_id`/`meta` 值;`emit_cache_hit` 单独一个用例——构造"请求带租户 A、缓存中的历史响应属于租户 B"的场景,断言落库的是 **A**(这是最容易实现反的一处);`meta` 序列化后键有序(`sort_keys=True`,便于跨行比对)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions through the single telemetry helper`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `chat()` 公共入口
|
||||
|
||||
**文件**: `src/polygateway/client.py`(修改)、`tests/unit/test_client.py`(修改)
|
||||
|
||||
**行为**: `chat()` 签名末尾增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`(带默认值的 keyword-only,签名冻结承诺不破)。在既有 `validate_request_overlay` 调用旁调 `validate_caller_dimensions(..., origin="chat(tenant_id=..., meta=...)")`,把返回值填进 `ChatRequest`。校验必须在**进洋葱之前**——洋葱内的一切失败都会被遥测层降级成 warning,校验放里面等于没有校验。
|
||||
|
||||
**验收标准**: 不传两参数时行为与改动前逐字一致(既有调用点零改动);传入非法值时 `chat()` 抛 `ValueError` 且**未产生任何遥测行**。
|
||||
|
||||
**测试要求**(先失败后通过): 端到端——`chat(..., tenant_id="t1", meta={"batch": "b-42"})` 后 fake recorder 收到的行带这两个值;非法 `meta` 抛 `ValueError` 且 recorder **零调用**(断言"校验早于遥测",这是 §4.2 的核心承诺);不传参数时 recorder 收到 `''` 与 `'{}'`。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: let chat() take a tenant and caller-defined dimensions`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `embed()` 链路透传
|
||||
|
||||
**文件**: `src/polygateway/embedding.py`(修改)、`tests/unit/test_embedding.py`(修改)
|
||||
|
||||
**行为**: `embed()` 增两个 keyword-only 参数并在入口校验(`origin="embed(tenant_id=..., meta=...)"`),沿 `_embed_batch()` → `_attempt()` → `_emit()` **逐层透传**,在 `_emit()`(`embedding.py:360`)构造 `ChatRequest` 时填入。
|
||||
|
||||
该链路已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数。**不顺手把它们收成值对象**——那会改动 embedding 全部内部签名,属任务外重构。本次只做加法。
|
||||
|
||||
**验收标准**: 多批(`texts` 长度 > `batch_size`)时**每一批的行都带同一份维度**——维度属于本次 `embed()` 调用,不随批次变化。
|
||||
|
||||
**测试要求**(先失败后通过): 单批与多批各一个用例,断言 fake recorder 收到的**每一行**都带维度(多批用例要断言行数 > 1 且全部一致,否则"只有第一批带维度"的实现会漏网);非法值抛 `ValueError` 且零遥测。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_embedding.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions down the embedding chain`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: OCR 链路透传
|
||||
|
||||
**文件**: `src/polygateway/ocr.py`(修改)、`tests/unit/test_ocr_client.py`(修改)
|
||||
|
||||
**行为**: `recognize_text()` 与 `parse_layout()` 各增两个 keyword-only 参数并在入口校验(`origin` 分别标明方法名),沿 `_call()` → `_attempt()` → `_emit()` 透传,在 `_emit()`(`ocr.py:398`)构造 `ChatRequest` 时填入。结构与 Task 5 同构。
|
||||
|
||||
**验收标准**: 两个公共方法都覆盖(只改一个即漏)。
|
||||
|
||||
**测试要求**(先失败后通过): 两个方法各一个用例,断言遥测行带维度;非法值抛 `ValueError` 且零遥测。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ocr_client.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions through the OCR chain`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 真实后端集成验收
|
||||
|
||||
**文件**: `tests/integration/test_postgres_telemetry.py`(修改)、`tests/unit/test_telemetry.py`(补 SQLite 真实文件用例)
|
||||
|
||||
**行为**: 覆盖三件事,每件两个后端各测一遍。
|
||||
|
||||
1. **新建库**: 表列齐全,写入后读回维度一致。
|
||||
2. **旧表补列(不可逆性的机械化验收)**: 手工建一张 **22 列的旧表**并插入一行,再用当前 recorder 打开它 → 补列成功、新行写入成功、**老行的 `tenant_id` 读出为空串而非 NULL**。这条直接对应 issue 的核心论点(先启用后加列,老行归属无法还原);断言"是空串"而非"是 NULL",因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞。
|
||||
3. **补列失败的降级方向**: 补列失败时逐行降级丢弃而非判死(沿用 issue #9 既有测试形态,不新造机制)。**两端的失败构造方式不同,不可笼统写"各测一遍"**:
|
||||
- **Postgres**: 用只有 `SELECT, INSERT ON llm_calls` 权限的角色连接——`ALTER TABLE` 的 ownership 检查早于 `IF NOT EXISTS` 的存在性判断,故必然失败。断言: 记 warning、`_failed` **未**置位、后续 INSERT 仍尝试。
|
||||
- **SQLite**: 无角色权限模型,等价构造是**文件只读**(`chmod 444` 或以 `file:...?mode=ro` 打开)。但只读库连 INSERT 也做不了,故此处只断言"补列失败不清空 `self._conn`、不抛出 `__init__`"(即 `sqlite.py:112` 那条既有纪律),**不断言"写入仍成功"**——那在只读库上本就不可能。
|
||||
|
||||
**验收标准**: PG 与 SQLite 行为对称;补列走既有 `_BACKFILL`,不新增 DDL 路径。
|
||||
|
||||
**测试要求**(先失败后通过): 上述三项即测试本体,**同样适用红绿证据门**——先写出断言看它因缺列/缺维度而失败,再实现至通过,保留失败输出。Postgres 用真实实例(CLAUDE.md §4.6: Redis/PG 相关测试不 mock)。
|
||||
|
||||
**验证命令**:
|
||||
`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS
|
||||
`conda run -n PolyGateway pytest tests/ -q` → 全套件 PASS(**命令末尾不接管道**,否则退出码失真)
|
||||
|
||||
- [ ] 提交点: `test: prove old telemetry tables gain the tenant column safely`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 文档同步
|
||||
|
||||
**文件**: `README.md`、`CHANGELOG.md`、Gitea wiki 的 `指南-遥测与成本` 与 `参考-公共API` 两页
|
||||
|
||||
按 `docs-convention.md` §2「新公共 API / 新能力」行,须同步「对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG」。本次**扩写** `指南-遥测与成本`(新增"多租户与自定义维度"一节)而非新建页,故**侧边栏与 `Home.md` 不动**——新增页才需要同步导航。若执行时判断内容多到该独立成页(如 `指南-多租户`),则必须一并改 `_Sidebar.md` 与 `Home.md` 分流表。
|
||||
|
||||
**行为**:
|
||||
|
||||
- **CHANGELOG**: 新增"未发布"段,写清新增两列、两个新参数(三条链路)、校验规则与上限数值、**以及库不建索引/不启用 RLS 的边界**。
|
||||
- **README**: 能力表补调用方维度;数字型断言若涉及遥测字段数,用 `inspect.signature` **实测**后再写(发布流程 §4.4.1 第 1 步的教训)。
|
||||
- **`参考-公共API`**: 更新 `chat()`、`embed()`(以及 Task 6 若执行则含 OCR 两方法)的签名——该页纪律是"以源码实测为准",改前先对照实际签名,不凭计划文本写。
|
||||
- **`指南-遥测与成本`**: 新增一节"多租户与自定义维度",含设计 §4.5 定稿的 RLS 模板(`ENABLE` + `FORCE` + `USING`/`WITH CHECK` 双写 + `NULLIF(current_setting(..., true), '')`)与复合索引 `(tenant_id, created_at)`,并写明三个陷阱: 表属主默认豁免 RLS;租户上下文必须在**显式事务内**用 `set_config(..., true)`(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG 只发 warning 不报错,表现为 fail-closed 到零行);只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行。
|
||||
- wiki 必须明确: **执行这些 DDL 是下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个普通列,没有数据库层强制。
|
||||
|
||||
**验收标准**: 三处文档对"库做什么、下游做什么"的表述一致,不出现"库自动启用 RLS"之类的措辞。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway make lint` → PASS;人工核对 wiki 页面渲染。
|
||||
|
||||
- [ ] 提交点: `docs: document caller dimensions and the RLS template`
|
||||
|
||||
---
|
||||
|
||||
## 全局验收
|
||||
|
||||
- [ ] `conda run -n PolyGateway make lint` → PASS(含 import-linter 依赖契约)
|
||||
- [ ] `conda run -n PolyGateway make test` → PASS,覆盖率不低于改动前
|
||||
- [ ] 派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 Phase 2 合并前硬门)
|
||||
- [ ] 三条链路各自的"传入维度 → 落库"证据齐全(chat / embed / OCR)
|
||||
- [ ] 旧表补列后老行读出空串的证据(issue 核心论点的验收)
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:issue10-error-body-retention-plan
|
||||
title: "实现计划: HTTP 错误响应体留存(Issue #10)"
|
||||
date: 2026-08-16
|
||||
---
|
||||
|
||||
# 实现计划: HTTP 错误响应体留存(Issue #10)
|
||||
|
||||
**全文**: `plans/2026-08-16-issue10-error-body-retention.md`|**实现**: [[design:issue10-error-body-retention]]|**分支**: `feat/issue-10-error-body-retention`
|
||||
|
||||
## 任务分解
|
||||
|
||||
| # | 任务 | 产出 |
|
||||
|---|---|---|
|
||||
| 1 | 内核字段 | `PolyGatewayError.body_text`(默认空串)+ 与 `raw_text` 的界限 docstring |
|
||||
| 2 | 共享摘要单元 | 新建 `transports/_http_errors.py`:`summarize_body` / `compose_message` / `response_body` |
|
||||
| 3 | openai_compat 收口 | `_status_to_error` 表驱动;429 判类型仍读原文 |
|
||||
| 4 | monkey_ocr 收口 | `_classify_status` 同款,含 `ResponseNotRead` 降级 |
|
||||
| 5 | **端到端验收** | 400 调用后 SQLite `error` 列含摘要——本计划的硬判据 |
|
||||
| 6 | 文档与版本 | 1.2.0、README pin `>=1.2,<2`、CHANGELOG、ARCHITECTURE §6.2 |
|
||||
| 7 | 合并前门 | lint + 全套件 + 全新上下文 verifier |
|
||||
|
||||
依赖:1‖2 → 3‖4 → 5 → 6 → 7。
|
||||
|
||||
## 计划期钉死的两条实现红线
|
||||
|
||||
1. **429 判 `insufficient_quota` 必须解析未截断原文**,不得改用摘要——摘要会破坏 JSON,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`,把诊断改进变成治理 bug。Task 3.4 有专门回归用例。
|
||||
2. **message 主体逐字保持 1.1.2 原样**,只在末尾追加摘要后缀;状态码→分类映射逐条不变,验收要求既有分类断言零改写。
|
||||
|
||||
## 保真校验
|
||||
|
||||
不涉及 `reference/` 迁移。错误分类映射不变,保真体现为"既有分类断言全部保留"。
|
||||
|
||||
## 执行期观察
|
||||
|
||||
Task 计划提交时 pre-commit 钩子的全套件跑出现一次 `tests/e2e/test_compat_projects.py::TestGovDocOnboarding::test_call_site_shape_runs_governed` 失败,单独跑与 e2e 全目录跑(7 passed / 23.30s,每例 2-3.5s)均通过,重跑全套件亦通过 → 判为真实网关抖动,非回归。该用例正是 [[design:issue8-stall-budget]] 当年记录的三个漂移用例之一,e2e 打真实网关的固有 flaky 面仍在。
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:issue11-caller-dimensions
|
||||
title: "调用方自定义维度实现计划(issue #11)"
|
||||
date: 2026-08-17
|
||||
---
|
||||
|
||||
# 调用方自定义维度实现计划(issue #11)
|
||||
|
||||
正文: `2026-08-17-issue11-caller-dimensions.md`(269 行,8 个任务)。实现 `design:issue11-caller-dimensions`。
|
||||
|
||||
- **任务顺序**: 端口与两个遥测后端(Task 2)先于三条调用链(Task 4/5/6)落地——后者写入的字段必须已有列可落。Task 1(校验函数 + `ChatRequest` 字段)是全部任务的前置。
|
||||
- **计划阶段的新发现**: 设计只覆盖 `chat()`/`embed()`,核实代码发现**第三条遥测链路**——`OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),`_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,与 embedding 同构。OCR 行与 chat 行落**同一张表**,漏掉则多租户审计链缺一块且同样不可逆。列为 Task 6,**人类已追认纳入正式范围**(设计 §1.2 同步补正)(与 issue #10 先例一致: 那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修),是必做项。
|
||||
- **把 issue 的核心论点钉成测试**: Task 7 要求手工建 22 列旧表 → 用当前 recorder 打开 → 断言老行 `tenant_id` 读回**空串而非 NULL**。这直接验收 issue「先启用后加列则归属无法还原」的论点,且断言方向选空串是因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞,而非"未归属"。
|
||||
- **几处易实现反的地方已写进验收**: `emit_cache_hit` 必须读**本次请求**的维度而非缓存中历史响应的(构造"请求属租户 A、缓存历史属租户 B"的用例断言落 A);`embed()` 多批时**每一批**的行都要带同一份维度(只断言首行会漏掉"只有第一批带维度"的实现);非法输入必须 `ValueError` 且 recorder **零调用**(证明校验早于遥测)。
|
||||
- **不做的事**: 不把 embedding/OCR 链上已有的四个同类参数收成值对象(任务外重构);不建索引、不启用 RLS(库只交付模板,执行是下游 DBA 职责)。
|
||||
|
||||
@@ -32,7 +32,7 @@ from polygateway.types import (
|
||||
SourceConfig,
|
||||
)
|
||||
|
||||
__version__ = "1.1.2"
|
||||
__version__ = "1.2.1"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
|
||||
@@ -34,7 +34,12 @@ from polygateway.sources import (
|
||||
SourceCooldownMemo,
|
||||
)
|
||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||
from polygateway.types import ChatRequest, LLMResponse, validate_request_overlay
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
LLMResponse,
|
||||
validate_caller_dimensions,
|
||||
validate_request_overlay,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Iterable, Mapping
|
||||
@@ -205,12 +210,18 @@ class GatewayClient:
|
||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||
stream: bool = True,
|
||||
overlay: Mapping[str, Any] | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||
|
||||
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||
参数不影响既有调用点(issue #4)。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
||||
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
||||
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
||||
"""
|
||||
if structured is not None and not self._structured_available:
|
||||
raise ImportError(
|
||||
@@ -221,6 +232,12 @@ class GatewayClient:
|
||||
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
|
||||
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
|
||||
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
|
||||
# 同理必须在洋葱之外: 洋葱内的一切失败都被遥测层降级成 warning(库铁律
|
||||
# 「遥测写失败降级不冒泡」),校验放里面等于没有校验——非法维度会变成
|
||||
# 静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
|
||||
)
|
||||
request = ChatRequest(
|
||||
messages=messages,
|
||||
session_id=session_id,
|
||||
@@ -231,6 +248,8 @@ class GatewayClient:
|
||||
stream=stream,
|
||||
overlay=sampling,
|
||||
sampling=sampling,
|
||||
tenant_id=dimension_tenant_id,
|
||||
meta=dimensions,
|
||||
)
|
||||
return await self._handler(request)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -47,6 +47,7 @@ from polygateway.types import (
|
||||
EmbeddingResponse,
|
||||
LLMResponse,
|
||||
strip_unsupported_extra_body,
|
||||
validate_caller_dimensions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -145,10 +146,22 @@ class EmbeddingClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> EmbeddingResponse:
|
||||
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。"""
|
||||
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
|
||||
本次调用而非某一批,故每批的遥测行都带同一份维度。
|
||||
"""
|
||||
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
|
||||
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
|
||||
# 必须在切批之前校验: 洋葱/链路内的一切失败都被遥测层降级成 warning
|
||||
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验——非法维度
|
||||
# 会变成静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
|
||||
)
|
||||
if not texts:
|
||||
return EmbeddingResponse(
|
||||
vectors=[],
|
||||
@@ -167,7 +180,11 @@ class EmbeddingClient:
|
||||
for start in range(0, len(texts), self._batch_size):
|
||||
outcomes.append(
|
||||
await self._embed_batch(
|
||||
texts[start : start + self._batch_size], session_id, parent_call_id
|
||||
texts[start : start + self._batch_size],
|
||||
session_id,
|
||||
parent_call_id,
|
||||
dimension_tenant_id,
|
||||
dimensions,
|
||||
)
|
||||
)
|
||||
return self._merge(outcomes)
|
||||
@@ -175,7 +192,12 @@ class EmbeddingClient:
|
||||
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
|
||||
|
||||
async def _embed_batch(
|
||||
self, batch: list[str], session_id: str | None, parent_call_id: str | None
|
||||
self,
|
||||
batch: list[str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _BatchOutcome:
|
||||
fails = 0
|
||||
reasons: dict[str, str] = {}
|
||||
@@ -187,7 +209,9 @@ class EmbeddingClient:
|
||||
await self._on_no_runnable(gate_rejections, reasons, clock)
|
||||
continue
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(batch, *picked, reasons, session_id, parent_call_id)
|
||||
outcome = await self._attempt(
|
||||
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
|
||||
)
|
||||
if isinstance(outcome, _BatchOutcome):
|
||||
return outcome
|
||||
fails += 1
|
||||
@@ -266,6 +290,8 @@ class EmbeddingClient:
|
||||
reasons: dict[str, str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _BatchOutcome | _FailedBatch:
|
||||
call_id = str(uuid.uuid4())
|
||||
started = self._now()
|
||||
@@ -286,17 +312,45 @@ class EmbeddingClient:
|
||||
await self._record_quietly(self._breaker.record_success(entry))
|
||||
await self._record_quietly(self._quota.mark_progress())
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, result)
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
result,
|
||||
)
|
||||
return _BatchOutcome(result, source, call_id, latency_ms)
|
||||
except (RequestRejectedError, ResultInvalidError) as exc:
|
||||
await self._gate_on_terminal(exc, entry)
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
if entry.is_probe:
|
||||
await self._record_quietly(self._breaker.release_probe(entry))
|
||||
await self._emit(
|
||||
batch, source, call_id, started, session_id, parent_call_id, error="cancelled"
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error="cancelled",
|
||||
)
|
||||
raise
|
||||
except (SourceDeadError, TransientError) as exc:
|
||||
@@ -307,7 +361,17 @@ class EmbeddingClient:
|
||||
if not dead:
|
||||
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
|
||||
actual = source.effective_est_tokens()
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
return _FailedBatch(exc, immediate=dead)
|
||||
finally:
|
||||
await self._settle_and_release(permit, actual)
|
||||
@@ -351,16 +415,22 @@ class EmbeddingClient:
|
||||
started: float,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
result: EmbeddingTransportResult | None = None,
|
||||
error: object | None = None,
|
||||
) -> None:
|
||||
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
|
||||
if self._emitter is None:
|
||||
return
|
||||
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(embedding 不走 chat
|
||||
# 洋葱),故调用方维度必须在这里显式填回,否则 embed 行的维度恒为空
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in batch],
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
tenant_id=tenant_id,
|
||||
meta=meta,
|
||||
)
|
||||
response = None
|
||||
if result is not None:
|
||||
|
||||
@@ -35,7 +35,26 @@ SOURCE_REASONS = frozenset(
|
||||
|
||||
|
||||
class PolyGatewayError(Exception):
|
||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。"""
|
||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
|
||||
|
||||
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
||||
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
|
||||
|
||||
============== ==================================================
|
||||
``body_text`` **非 2xx** 的 HTTP 错误响应体: 对方**拒绝**的理由
|
||||
``raw_text`` **2xx** 但内容不可解析时的模型输出原文
|
||||
============== ==================================================
|
||||
|
||||
加在基类而非某个子类,是因为这些错误全部由同一个 HTTP 响应翻译而来——
|
||||
"对方说了什么"与"它属于哪一类"正交。scope 级错误(`GatewayUnavailableError`
|
||||
一族)继承到的恒空值不是噪音,而是"没有单一响应体可言"的如实表达。
|
||||
|
||||
**内容已由 transport 层截断**(`transports/_http_errors.summarize_body`),
|
||||
且可能包含网关对请求的回显——库不做脱敏: 它不知道下游哪些字段敏感,
|
||||
猜测式脱敏只会同时丢掉诊断价值与安全性。
|
||||
|
||||
本字段是**旁路数据**,不参与任何治理判定(重试/换源/熔断计数/限流结算)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -44,11 +63,13 @@ class PolyGatewayError(Exception):
|
||||
source_name: str | None = None,
|
||||
status_code: int | None = None,
|
||||
operation: str | None = None,
|
||||
body_text: str = "",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.source_name = source_name
|
||||
self.status_code = status_code
|
||||
self.operation = operation
|
||||
self.body_text = body_text
|
||||
|
||||
|
||||
class TransientError(PolyGatewayError):
|
||||
@@ -64,7 +85,16 @@ class SourceDeadError(PolyGatewayError):
|
||||
|
||||
|
||||
class RequestRejectedError(PolyGatewayError):
|
||||
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。"""
|
||||
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。
|
||||
|
||||
**经中转部署时请注意**(issue #10 下游实测): 第三方 API 中转服务自身抖动
|
||||
时也会回 400,从状态码上与供应商说"你的输入有问题"无法区分。下游曾观测到
|
||||
同一份字节(sha256 一致)重发 15 次全部成功,且失败那次 `prompt_tokens=0`、
|
||||
耗时远低于任何成功调用——请求在推理开始前就被挡了。本库仍按确定性失败处理
|
||||
(对直连供应商而言重试只会白烧配额),批处理场景的下游宜自备兜底分类;
|
||||
`body_text` 即为此提供判据: 中转抖动的响应体与供应商的 `invalid_request_error`
|
||||
形态不同。
|
||||
"""
|
||||
|
||||
|
||||
class ResultInvalidError(PolyGatewayError):
|
||||
|
||||
@@ -26,13 +26,35 @@ from polygateway.middleware.cache import digest_messages
|
||||
from polygateway.types import canonical_sampling_json, merge_sampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from polygateway.ports import CallNext, TelemetryRecorder
|
||||
from polygateway.pricing import PricingTable
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||
|
||||
|
||||
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
||||
"""把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。
|
||||
|
||||
`sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重;
|
||||
`ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计。
|
||||
|
||||
`allow_nan=False` 是**第二道闸**(主防线是 `types.validate_caller_dimensions`
|
||||
在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法
|
||||
JSON。这道闸真正的价值在 **SQLite 侧**——PG 的 JSONB 本来就会拒收 `NaN`,而
|
||||
SQLite 的 `meta` 是 TEXT 列**不做任何 JSON 校验**,没有这道闸就会把 `NaN`
|
||||
这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析。
|
||||
|
||||
注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级
|
||||
`try` 内被求值,异常会被那里的 `except Exception` 接住 → 落 warning、整行
|
||||
遥测丢弃。即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"。
|
||||
"""
|
||||
if not meta:
|
||||
return "{}"
|
||||
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AttemptUsage:
|
||||
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
|
||||
@@ -72,7 +94,7 @@ class _AttemptUsage:
|
||||
|
||||
|
||||
class TelemetryEmitter:
|
||||
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
|
||||
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
|
||||
self._recorder = recorder
|
||||
@@ -111,6 +133,8 @@ class TelemetryEmitter:
|
||||
reasoning_tokens=usage.reasoning_tokens,
|
||||
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
||||
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
)
|
||||
|
||||
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
||||
@@ -139,6 +163,11 @@ class TelemetryEmitter:
|
||||
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
||||
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
# 与上面的 model/prompt_tokens 相反,维度读 request 而非 response:
|
||||
# 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用
|
||||
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
)
|
||||
|
||||
async def emit_terminal_failure(
|
||||
@@ -166,6 +195,9 @@ class TelemetryEmitter:
|
||||
reasoning_tokens=None,
|
||||
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
)
|
||||
|
||||
async def _record(
|
||||
@@ -190,6 +222,9 @@ class TelemetryEmitter:
|
||||
model_reported: str | None,
|
||||
sampling: str | None,
|
||||
reasoning_tokens: int | None,
|
||||
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any],
|
||||
) -> None:
|
||||
try:
|
||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||
@@ -231,6 +266,10 @@ class TelemetryEmitter:
|
||||
model_reported=model_reported,
|
||||
sampling=sampling,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
# 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下
|
||||
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
|
||||
tenant_id=tenant_id or "",
|
||||
meta=_canonical_meta_json(meta),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
+82
-10
@@ -18,7 +18,7 @@ import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -46,6 +46,7 @@ from polygateway.types import (
|
||||
OcrTextResult,
|
||||
Usage,
|
||||
strip_unsupported_extra_body,
|
||||
validate_caller_dimensions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -142,9 +143,21 @@ class OcrClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> OcrTextResult:
|
||||
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。"""
|
||||
outcome = await self._call("text", image, session_id, parent_call_id)
|
||||
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||
"""
|
||||
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
|
||||
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
|
||||
)
|
||||
outcome = await self._call(
|
||||
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
|
||||
)
|
||||
result = outcome.result
|
||||
return OcrTextResult(
|
||||
text=result.text,
|
||||
@@ -161,9 +174,20 @@ class OcrClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> OcrLayoutResult:
|
||||
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。"""
|
||||
outcome = await self._call("layout", image, session_id, parent_call_id)
|
||||
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||
"""
|
||||
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
|
||||
)
|
||||
outcome = await self._call(
|
||||
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
|
||||
)
|
||||
result = outcome.result
|
||||
return OcrLayoutResult(
|
||||
elements=result.elements,
|
||||
@@ -195,6 +219,8 @@ class OcrClient:
|
||||
image: bytes,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _AttemptOutcome:
|
||||
if not isinstance(image, bytes):
|
||||
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
||||
@@ -213,7 +239,7 @@ class OcrClient:
|
||||
continue
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(
|
||||
kind, image, *picked, reasons, session_id, parent_call_id
|
||||
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
|
||||
)
|
||||
if isinstance(outcome, _AttemptOutcome):
|
||||
return outcome
|
||||
@@ -294,9 +320,13 @@ class OcrClient:
|
||||
reasons: dict[str, str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _AttemptOutcome | _FailedAttempt:
|
||||
call_id = str(uuid.uuid4())
|
||||
started = self._now()
|
||||
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
|
||||
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
|
||||
try:
|
||||
result = await self._invoke(kind, image, source, call_id)
|
||||
await self._record_quietly(self._breaker.record_success(entry))
|
||||
@@ -304,20 +334,47 @@ class OcrClient:
|
||||
self._feed_outcome(source.name, ok=True)
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, result
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
result,
|
||||
)
|
||||
return _AttemptOutcome(result, source, call_id, latency_ms)
|
||||
except (RequestRejectedError, ResultInvalidError) as exc:
|
||||
await self._gate_on_terminal(exc, entry)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
if entry.is_probe:
|
||||
await self._record_quietly(self._breaker.release_probe(entry))
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error="cancelled"
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error="cancelled",
|
||||
)
|
||||
raise
|
||||
except (SourceDeadError, TransientError) as exc:
|
||||
@@ -327,7 +384,16 @@ class OcrClient:
|
||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||
self._feed_outcome(source.name, ok=False)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
return _FailedAttempt(exc, immediate=dead)
|
||||
finally:
|
||||
@@ -389,16 +455,22 @@ class OcrClient:
|
||||
started: float,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
|
||||
error: object | None = None,
|
||||
) -> None:
|
||||
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
|
||||
if self._emitter is None:
|
||||
return
|
||||
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
|
||||
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
tenant_id=tenant_id,
|
||||
meta=meta,
|
||||
)
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
response = None
|
||||
|
||||
@@ -245,10 +245,15 @@ class StructuredOutputStrategy(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class TelemetryRecorder(Protocol):
|
||||
"""遥测后端;22 字段冻结(M1 设计 §4.4 + issue #3/#4),唯一调用点是 TelemetryEmitter。
|
||||
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。
|
||||
|
||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||
|
||||
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
|
||||
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
|
||||
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
|
||||
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
|
||||
"""
|
||||
|
||||
async def record_llm_call(
|
||||
@@ -276,4 +281,6 @@ class TelemetryRecorder(Protocol):
|
||||
model_reported: str | None,
|
||||
sampling: str | None,
|
||||
reasoning_tokens: int | None,
|
||||
tenant_id: str,
|
||||
meta: str,
|
||||
) -> None: ...
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
① 结构性失败 → warning 一次后永久降级(所有写入短路);
|
||||
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
||||
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
||||
构造不连库(lazy),22 列 schema 与 SQLite 版同名同序。
|
||||
构造不连库(lazy),24 列 schema 与 SQLite 版同名同序。
|
||||
|
||||
**"结构性"的判据是「确定写不进去」,不是「初始化时出过错」**(issue #9):
|
||||
只有建池失败(重试要在业务路径上内联吞掉 connect 超时)与"表确定不存在
|
||||
@@ -48,7 +48,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -58,6 +60,15 @@ _BACKFILL = (
|
||||
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
|
||||
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
|
||||
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
|
||||
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
|
||||
(
|
||||
"tenant_id",
|
||||
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ''",
|
||||
),
|
||||
(
|
||||
"meta",
|
||||
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb",
|
||||
),
|
||||
)
|
||||
|
||||
# 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析
|
||||
@@ -92,6 +103,8 @@ _COLUMNS = (
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
|
||||
@@ -46,7 +46,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -57,6 +59,10 @@ _BACKFILL_COLUMNS = (
|
||||
("model_reported", "TEXT"),
|
||||
("sampling", "TEXT"),
|
||||
("reasoning_tokens", "INTEGER"),
|
||||
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
|
||||
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
|
||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
("meta", "TEXT NOT NULL DEFAULT '{}'"),
|
||||
)
|
||||
|
||||
_COLUMNS = (
|
||||
@@ -82,6 +88,8 @@ _COLUMNS = (
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
@@ -137,7 +145,7 @@ class SQLiteRecorder:
|
||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;字段集合即 21 字段冻结签名(ports.TelemetryRecorder)。"""
|
||||
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。"""
|
||||
if self._conn is None:
|
||||
return
|
||||
row = tuple(fields[col] for col in _COLUMNS)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""HTTP 错误响应体的取用与摘要(issue #10 设计 §3.2)。
|
||||
|
||||
两个 transport 各有自己的状态码分类逻辑(OCR 有意不做 429 细分),但**摘要口径
|
||||
必须是同一份**——issue #10 的教训正是"只有一个分支用了响应体",一处例外就是
|
||||
下一次事后查不到原因。故本模块是全库唯一的摘要实现,不得在别处复制。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
_ERROR_BODY_CAP = 2048
|
||||
"""摘要总长上限(**字符**,含省略标记在内)。
|
||||
|
||||
取值对齐 Kubernetes client-go `rest/request.go` 的 `maxUnstructuredResponseTextBytes
|
||||
= 2048`——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。按字符而非
|
||||
字节切,多字节字符不会被切成半个;`error` 列是 TEXT,无定长约束,不需要字节口径。
|
||||
"""
|
||||
|
||||
_HEAD_CHARS = 1400
|
||||
_TAIL_CHARS = 600
|
||||
|
||||
|
||||
def summarize_body(text: str) -> str:
|
||||
"""折叠空白后按头尾策略摘要;空/空白入参返回空串。
|
||||
|
||||
**折叠空白**不是洁癖: 错误体常是缩进 JSON,原样拼进 message 会把一行日志
|
||||
炸成多行、把遥测列变得不可读。
|
||||
|
||||
**保头保尾**而非头部硬切: 截断的对象是结构化 JSON,信息分布头重尾也重——
|
||||
人话(`message`)在前,机器可判的 `type`/`code`/`param`/`request_id` 在后。
|
||||
k8s/Sentry 用头部硬切是因为它们截的是任意文本;本函数截的是错误 JSON,
|
||||
头部硬切正好切掉向网关方追查时唯一有用的那部分。策略取自标准库 `reprlib`
|
||||
对"给人读的长字符串"的处置。
|
||||
|
||||
**标记记下省略字数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。
|
||||
"""
|
||||
collapsed = " ".join(text.split())
|
||||
if len(collapsed) <= _ERROR_BODY_CAP:
|
||||
return collapsed
|
||||
omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS
|
||||
return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}"
|
||||
|
||||
|
||||
def compose_message(message: str, summary: str) -> str:
|
||||
"""摘要非空才拼后缀,避免留下悬空的分隔符。
|
||||
|
||||
分隔符取 ` | ` 而非既有的 `: `,让"库说的话"与"网关说的话"一眼可分。
|
||||
"""
|
||||
return f"{message} | {summary}" if summary else message
|
||||
|
||||
|
||||
def response_body(response: httpx.Response) -> str:
|
||||
"""取**已缓冲**的响应文本;未读缓冲一律降级空串。
|
||||
|
||||
绝不在此触发网络读: 那会在错误路径上凭空插入一次可能挂住的 IO。降级方向
|
||||
与缓存/遥测同档(库铁律)——诊断信息缺失不得把一次本可正确分类的失败变成
|
||||
不可分类的崩溃,那正是 `ResponseNotRead` 泄漏出四分类之外的后果。
|
||||
"""
|
||||
try:
|
||||
return response.text
|
||||
except httpx.ResponseNotRead:
|
||||
return ""
|
||||
@@ -24,6 +24,11 @@ from polygateway.errors import (
|
||||
SourceDeadError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.transports._http_errors import (
|
||||
compose_message,
|
||||
response_body,
|
||||
summarize_body,
|
||||
)
|
||||
from polygateway.types import (
|
||||
OcrLayoutElement,
|
||||
OcrLayoutTransportResult,
|
||||
@@ -74,13 +79,20 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
|
||||
def _classify_status(
|
||||
exc: httpx.HTTPStatusError, source_name: str, operation: str
|
||||
) -> TransientError | SourceDeadError | RequestRejectedError:
|
||||
"""HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。
|
||||
|
||||
分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份——
|
||||
"只有一个分支用了响应体"正是 issue #10 的成因。
|
||||
"""
|
||||
status = exc.response.status_code
|
||||
summary = summarize_body(response_body(exc.response))
|
||||
ctx: dict[str, Any] = {
|
||||
"source_name": source_name,
|
||||
"status_code": status,
|
||||
"operation": operation,
|
||||
"body_text": summary,
|
||||
}
|
||||
message = f"{source_name} OCR {operation} HTTP {status}"
|
||||
message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary)
|
||||
if status >= 500 or status == 429:
|
||||
return TransientError(message, **ctx)
|
||||
if status in (401, 403):
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import httpx
|
||||
|
||||
from polygateway.errors import (
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
@@ -30,6 +31,7 @@ from polygateway.providers import (
|
||||
resolve_thinking,
|
||||
)
|
||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -107,40 +109,59 @@ def _parse_retry_after(raw: str | None) -> float | None:
|
||||
return seconds if seconds > 0 else None
|
||||
|
||||
|
||||
def _translate_429(source: SourceConfig, body_text: str, headers: Mapping[str, str]) -> Exception:
|
||||
def _translate_429(
|
||||
source: SourceConfig, body_text: str, headers: Mapping[str, str], ctx: dict[str, Any]
|
||||
) -> Exception:
|
||||
"""429 细分。`body_text` 必须是**未截断的原文**——`ctx["body_text"]` 是摘要,
|
||||
头尾保留会破坏 JSON 结构,拿它解析会让超长 body 的配额耗尽退化成普通限速
|
||||
(该源不再 force_open),把一个诊断改进变成治理 bug(issue #10 实现红线)。
|
||||
"""
|
||||
try:
|
||||
err_type = json.loads(body_text).get("error", {}).get("type", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
err_type = ""
|
||||
summary = ctx["body_text"]
|
||||
if err_type == "insufficient_quota":
|
||||
return SourceDeadError(
|
||||
f"{source.name} 配额耗尽(insufficient_quota)",
|
||||
source_name=source.name,
|
||||
status_code=429,
|
||||
operation="chat",
|
||||
compose_message(f"{source.name} 配额耗尽(insufficient_quota)", summary), **ctx
|
||||
)
|
||||
return TransientError(
|
||||
f"{source.name} 限速: 429",
|
||||
compose_message(f"{source.name} 限速: 429", summary),
|
||||
retry_after_s=_parse_retry_after(headers.get("retry-after")),
|
||||
source_name=source.name,
|
||||
status_code=429,
|
||||
operation="chat",
|
||||
**ctx,
|
||||
)
|
||||
|
||||
|
||||
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
|
||||
"""状态码 → (错误类, message 标签);映射与 ARCH §6.2 逐条相同,本次零变更。"""
|
||||
if status in (401, 403):
|
||||
return SourceDeadError, "凭据失效/欠费"
|
||||
if status == 400:
|
||||
return RequestRejectedError, "请求被拒"
|
||||
if status >= 500:
|
||||
return TransientError, "瞬时错误"
|
||||
return RequestRejectedError, "客户端错误"
|
||||
|
||||
|
||||
def _status_to_error(
|
||||
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
|
||||
) -> Exception:
|
||||
ctx: dict[str, Any] = {"source_name": source.name, "status_code": status, "operation": "chat"}
|
||||
if status in (401, 403):
|
||||
return SourceDeadError(f"{source.name} 凭据失效/欠费: {status}", **ctx)
|
||||
if status == 400:
|
||||
return RequestRejectedError(f"{source.name} 请求被拒: 400", **ctx)
|
||||
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
|
||||
|
||||
摘要只算一次,message 与 `body_text` 共用同一份串: 两份不同长度会让"遥测里
|
||||
看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
|
||||
"""
|
||||
summary = summarize_body(body_text)
|
||||
ctx: dict[str, Any] = {
|
||||
"source_name": source.name,
|
||||
"status_code": status,
|
||||
"operation": "chat",
|
||||
"body_text": summary,
|
||||
}
|
||||
if status == 429:
|
||||
return _translate_429(source, body_text, headers)
|
||||
if status >= 500:
|
||||
return TransientError(f"{source.name} 瞬时错误: {status}", **ctx)
|
||||
return RequestRejectedError(f"{source.name} 客户端错误: {status}", **ctx)
|
||||
return _translate_429(source, body_text, headers, ctx)
|
||||
cls, label = _classify(status)
|
||||
return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
|
||||
|
||||
|
||||
def _strip_think(content: str) -> tuple[str, str]:
|
||||
|
||||
@@ -6,6 +6,8 @@ fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
@@ -31,6 +33,18 @@ USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
|
||||
_EST_TOKENS_QUOTA_DIVISOR = 60
|
||||
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
|
||||
|
||||
_TENANT_ID_MAX_LEN = 128
|
||||
_META_MAX_KEYS = 16
|
||||
_META_VALUE_MAX_LEN = 256
|
||||
_META_RESERVED_PREFIX = "pg_"
|
||||
_META_KEY_RE = re.compile(r"[a-z0-9_.]{1,64}")
|
||||
"""调用方维度的形态上限(issue #11 §4.2)。
|
||||
|
||||
数值取自同类系统的量级(Loki labels 15 / Salesforce 自定义索引 25 /
|
||||
Sentry tag 200 字符),非本项目实测;键字符集照搬 OTel semconv。
|
||||
`pg_` 前缀留给库将来的内建维度——同类做法见 LangSmith 的 `ls_`、
|
||||
Traceloop 的 `traceloop.`;本版库自身不写入任何该前缀的键。"""
|
||||
|
||||
|
||||
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
|
||||
"""校验采样参数覆盖层并返回浅拷贝;origin 用于把错误指回配置/调用点。
|
||||
@@ -59,6 +73,87 @@ def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict
|
||||
return dict(overlay)
|
||||
|
||||
|
||||
def validate_caller_dimensions(
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any] | None,
|
||||
*,
|
||||
origin: str,
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
"""校验调用方自定义维度并返回浅拷贝;origin 用于把错误指回调用点(issue #11 §4.2)。
|
||||
|
||||
一切超限**报错而非静默丢弃**(P5): 同类系统里 Langfuse 对超长 value 直接
|
||||
丢掉,那会让调用方以为记上了而实际没有。报错点必须在进洋葱之前——洋葱内
|
||||
的失败都被遥测层降级成 warning,校验放那里等于没有校验。
|
||||
"""
|
||||
_validate_tenant_id(tenant_id, origin)
|
||||
if meta is None:
|
||||
return tenant_id, {}
|
||||
# 键形态须先于值校验: 非 str 键若拖到值校验之后,会以更晦涩的形态报出来
|
||||
_validate_meta_keys(meta, origin)
|
||||
_validate_meta_values(meta, origin)
|
||||
return tenant_id, dict(meta)
|
||||
|
||||
|
||||
def _validate_tenant_id(tenant_id: str | None, origin: str) -> None:
|
||||
"""租户标识形态;首尾空白**拒绝而非 strip**(见函数体注释)。"""
|
||||
if tenant_id is None:
|
||||
return
|
||||
if not isinstance(tenant_id, str):
|
||||
raise ValueError(f"{origin} 的 tenant_id 必须是 str: {tenant_id!r}")
|
||||
# 悄悄 strip 会让 " t1" 变成 "t1": 二者在 RLS policy 的等值比较下是两个
|
||||
# 不同租户,替调用方改写值等于把它的行藏进另一个租户,且不报错
|
||||
if tenant_id != tenant_id.strip():
|
||||
raise ValueError(
|
||||
f"{origin} 的 tenant_id 不得含首尾空白: {tenant_id!r}"
|
||||
"(RLS 等值比较下它与去空白版本是两个租户)"
|
||||
)
|
||||
if not tenant_id:
|
||||
raise ValueError(f"{origin} 的 tenant_id 不得为空串(空串是未归属行的哨兵值)")
|
||||
if len(tenant_id) > _TENANT_ID_MAX_LEN:
|
||||
raise ValueError(f"{origin} 的 tenant_id 超长(上限 {_TENANT_ID_MAX_LEN}): {len(tenant_id)}")
|
||||
|
||||
|
||||
def _validate_meta_keys(meta: Mapping[str, Any], origin: str) -> None:
|
||||
"""键形态与数量;键集合被假定为低基数且稳定,故收紧到 OTel semconv 字符集。"""
|
||||
# 数量闸先于逐键校验: 这道闸要防的正是"整个请求体被塞进 meta"的形态,
|
||||
# 那时逐键正则会先跑上万次才报出真正的原因,拖慢的恰是出错路径
|
||||
if len(meta) > _META_MAX_KEYS:
|
||||
raise ValueError(f"{origin} 的 meta 键数超限(上限 {_META_MAX_KEYS}): {len(meta)}")
|
||||
for key in meta:
|
||||
if not isinstance(key, str):
|
||||
raise ValueError(f"{origin} 的 meta 键必须是 str: {key!r}")
|
||||
if key.startswith(_META_RESERVED_PREFIX):
|
||||
raise ValueError(
|
||||
f"{origin} 的 meta 键 {key!r} 使用了保留前缀 {_META_RESERVED_PREFIX!r}"
|
||||
"(留给库将来的内建维度,避免与调用方的键撞名)"
|
||||
)
|
||||
if not _META_KEY_RE.fullmatch(key):
|
||||
raise ValueError(
|
||||
f"{origin} 的 meta 键 {key!r} 不合法: 只允许小写字母/数字/下划线/点,长度 1-64"
|
||||
)
|
||||
|
||||
|
||||
def _validate_meta_values(meta: Mapping[str, Any], origin: str) -> None:
|
||||
"""值只收扁平标量;非有限 float 必须挡在这里。
|
||||
|
||||
`json.dumps` 会把 `nan`/`inf` 写成 `NaN`/`Infinity` 字面量——不是合法 JSON,
|
||||
PG 的 JSONB 拒收。放行则写入失败会被遥测的降级 try 吞成 warning,即把调用方
|
||||
的输入错误转成静默丢遥测(Codex 审查推翻了初稿"序列化不可达"的论断)。
|
||||
"""
|
||||
for key, value in meta.items():
|
||||
if not isinstance(value, (str, int, float, bool)):
|
||||
raise ValueError(
|
||||
f"{origin} 的 meta 值必须是 str/int/float/bool: {key}={value!r}"
|
||||
"(嵌套结构请调用方自行序列化)"
|
||||
)
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise ValueError(f"{origin} 的 meta 值不得是 nan/inf: {key}={value!r}(非合法 JSON)")
|
||||
if isinstance(value, str) and len(value) > _META_VALUE_MAX_LEN:
|
||||
raise ValueError(
|
||||
f"{origin} 的 meta 值超长(上限 {_META_VALUE_MAX_LEN}): {key} 长 {len(value)}"
|
||||
)
|
||||
|
||||
|
||||
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。"""
|
||||
return {**extra_body, **sampling}
|
||||
@@ -129,6 +224,21 @@ class ChatRequest:
|
||||
不同深度取值不同;缓存 key 与三个遥测入口需要一个跨层恒定的读取点,否则
|
||||
同一列在不同行口径分叉。"""
|
||||
|
||||
# —— 调用方自定义维度(issue #11;追加在末尾,不扰动既有字段的位置构造)——
|
||||
tenant_id: str | None = None
|
||||
"""调用方的租户标识,进遥测的 `tenant_id` 真实列(issue #11)。
|
||||
|
||||
独立成字段而非混进 `meta`,因为它是唯一享有真实列待遇的维度——可挂 RLS、
|
||||
可进复合索引。混在 `meta` 里则调用方拼错(`tenantId`)不会报错,只会静默
|
||||
降级成一个普通维度,正是本 issue 抱怨的失败形态。"""
|
||||
|
||||
meta: Mapping[str, Any] = field(default_factory=dict)
|
||||
"""调用方自定义维度的只读快照,库不解释其含义,库内中间件**永不修改**。
|
||||
|
||||
**不进缓存 key**: 租户隔离已由 `cache_namespace` 负责并已进 key(ARCH §7.5),
|
||||
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
|
||||
语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Usage:
|
||||
|
||||
@@ -11,7 +11,12 @@ import sqlite3
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from polygateway import CircuitOpenError, GatewayClient, TransientError
|
||||
from polygateway import (
|
||||
CircuitOpenError,
|
||||
GatewayClient,
|
||||
RequestRejectedError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.cache import InMemoryCache
|
||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||
@@ -173,6 +178,40 @@ class TestTelemetryAcrossPaths:
|
||||
assert len({cid for _, cid in rows}) == 2 # call_id 逐次独立
|
||||
|
||||
|
||||
class TestRejectionReasonIsQueryable:
|
||||
"""issue #10 的验收主张: 400 之后,网关说的话必须能在遥测表里查到。
|
||||
|
||||
下游一轮 1050 张影像的批处理里,1 张在读表格时收到 400 被判确定性失败,
|
||||
事后"这张图到底哪里不合规"无从查起——响应体在 transport 翻译层就没了。
|
||||
"""
|
||||
|
||||
# issue #10 原文给出的真实响应体(一字不改)
|
||||
_BODY = (
|
||||
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
|
||||
'and cannot be opened","type":"invalid_request_error","param":"",'
|
||||
'"code":"invalid_parameter_error"}}'
|
||||
)
|
||||
|
||||
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
client = _full_client(
|
||||
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
|
||||
)
|
||||
|
||||
with pytest.raises(RequestRejectedError):
|
||||
await client.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
recorder.close()
|
||||
rows = sqlite3.connect(tmp_path / "t.db").execute("SELECT error FROM llm_calls").fetchall()
|
||||
assert rows, "400 必须留下遥测行(遥测必录)"
|
||||
errors = " ".join(r[0] or "" for r in rows)
|
||||
# 修复前这里只有 "qwen_1 请求被拒: 400"——诊断信息一个字都不在
|
||||
assert "InvalidParameter" in errors
|
||||
assert "The image format is illegal" in errors
|
||||
# 尾部的 code 才是向网关方追查的凭据,头部硬切正好会丢掉它
|
||||
assert "invalid_parameter_error" in errors
|
||||
|
||||
|
||||
class TestStructuredThroughStack:
|
||||
async def test_feedback_reask_passes_through_governance(self):
|
||||
"""重问经过内层治理: 第二次真实请求同样被限流/熔断记账。"""
|
||||
|
||||
@@ -45,6 +45,8 @@ _EXPECTED_COLUMNS = [
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
]
|
||||
|
||||
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
||||
@@ -110,6 +112,9 @@ async def _record_minimal(
|
||||
"model_reported": None,
|
||||
"sampling": None,
|
||||
"reasoning_tokens": None,
|
||||
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
|
||||
"tenant_id": "",
|
||||
"meta": "{}",
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
@@ -385,3 +390,241 @@ class TestLeastPrivilegeDeployment:
|
||||
assert schema # teardown 会连表带角色删净
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
|
||||
_PRE_TENANT_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER
|
||||
)
|
||||
"""
|
||||
|
||||
_PRE_TENANT_INSERT = (
|
||||
"INSERT INTO {schema}.llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
|
||||
"VALUES ($1, 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
|
||||
)
|
||||
|
||||
|
||||
def _search_path_dsn(dsn: str, schema: str) -> str:
|
||||
sep = "&" if "?" in dsn else "?"
|
||||
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def captured_warnings():
|
||||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。
|
||||
|
||||
名字避开裸 `warnings`: 那会遮蔽标准库模块名,本文件将来任何一次
|
||||
`import warnings` 都会与它静默互相顶掉,而报错点离真因很远。
|
||||
"""
|
||||
from loguru import logger
|
||||
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
yield messages
|
||||
logger.remove(sink_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pre_tenant_schema(dsn):
|
||||
"""自建临时 schema 里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
|
||||
|
||||
绝不碰共享的 public.llm_calls——本机那张表早已被 `_BACKFILL` 真实补过列,
|
||||
指望它还是旧形态的测试第二次跑就会空转。schema 名带 uuid,可重复运行。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_pre_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(_PRE_TENANT_DDL.format(schema=name))
|
||||
await conn.execute(_PRE_TENANT_INSERT.format(schema=name), _cid("old"))
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fresh_schema(dsn):
|
||||
"""空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_new_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def least_privilege_pre_tenant_dsn(dsn):
|
||||
"""22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。
|
||||
|
||||
与 `least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表
|
||||
(测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去
|
||||
并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_lppre_{uuid4().hex[:8]}"
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
if not await admin.fetchval(
|
||||
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
|
||||
):
|
||||
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
|
||||
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
|
||||
await admin.execute(f"CREATE SCHEMA {name}")
|
||||
await admin.execute(_PRE_TENANT_DDL.format(schema=name)) # 表属主是 admin,不是应用账号
|
||||
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
|
||||
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
|
||||
finally:
|
||||
await admin.close()
|
||||
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
|
||||
yield _search_path_dsn(low, name)
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
|
||||
await admin.execute(f"DROP OWNED BY {name}")
|
||||
await admin.execute(f"DROP ROLE IF EXISTS {name}")
|
||||
finally:
|
||||
await admin.close()
|
||||
|
||||
|
||||
class TestCallerDimensionsAcceptance:
|
||||
"""issue #11 的机械化验收(PG 侧,真实实例): 新建库 / 旧表补列 / 补列失败方向。"""
|
||||
|
||||
async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema):
|
||||
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
||||
fresh_dsn, schema = fresh_schema
|
||||
recorder = PostgresRecorder(fresh_dsn)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||
)
|
||||
cols = await _fetch(
|
||||
fresh_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||
schema,
|
||||
)
|
||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||
rows = await _fetch(
|
||||
fresh_dsn,
|
||||
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1",
|
||||
_cid("dim"),
|
||||
)
|
||||
assert rows[0]["tenant_id"] == "tenant-a"
|
||||
assert json.loads(rows[0]["meta"]) == {"batch": "b7"}
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable(
|
||||
self, pre_tenant_schema
|
||||
):
|
||||
"""22 字段旧表补列后,新行带维度,而**老行的 tenant_id 是空串而非 NULL**。
|
||||
|
||||
这条直接验收 issue #11 的核心论点(先启用落库、后加列,补列之前的行没有
|
||||
租户归属)。断言方向必须是空串: PG 的 RLS `USING` 表达式对返回 false **或
|
||||
NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",而是对
|
||||
所有人永久不可见的黑洞;哨兵空串则能被一条 `COUNT(*) WHERE tenant_id = ''`
|
||||
审计出来,历史欠账是可见、可量化、可补录的。
|
||||
"""
|
||||
schema_dsn, schema = pre_tenant_schema
|
||||
recorder = PostgresRecorder(schema_dsn)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
)
|
||||
cols = await _fetch(
|
||||
schema_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||
schema,
|
||||
)
|
||||
# 22 → 24 个 recorder 字段(加 created_at 共 25 个物理列),且新列追加在末尾
|
||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||
rows = await _fetch(
|
||||
schema_dsn,
|
||||
"SELECT call_id, tenant_id, meta FROM llm_calls "
|
||||
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
|
||||
[_cid("new"), _cid("old")],
|
||||
)
|
||||
by_id = {r["call_id"]: r for r in rows}
|
||||
assert by_id[_cid("new")]["tenant_id"] == "tenant-a"
|
||||
assert json.loads(by_id[_cid("new")]["meta"]) == {"k": 1}
|
||||
assert by_id[_cid("old")]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||
assert json.loads(by_id[_cid("old")]["meta"]) == {}
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_alter_is_denied_for_a_role_that_can_still_insert(
|
||||
self, least_privilege_pre_tenant_dsn
|
||||
):
|
||||
"""库外事实先钉死: 表存在、写得进去,补列的 ALTER 仍被拒(ownership 检查早于存在性判断)。
|
||||
|
||||
没有这条,下面那个降级用例可能因为 ALTER 其实成功了而变成"永远通过"的空断言。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(least_privilege_pre_tenant_dsn, timeout=10)
|
||||
try:
|
||||
assert await conn.fetchval("SELECT to_regclass('llm_calls')") is not None
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS tenant_id TEXT")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def test_backfill_failure_degrades_per_row_not_wholesale(
|
||||
self, least_privilege_pre_tenant_dsn, captured_warnings
|
||||
):
|
||||
"""补列失败的降级方向: 记 warning、不置 `_failed`、后续 INSERT 仍照发。
|
||||
|
||||
置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多),
|
||||
且一旦 DBA 补上列也不会自愈——必须等重启。
|
||||
"""
|
||||
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
|
||||
assert recorder._failed is False
|
||||
assert any("补列失败" in m for m in captured_warnings)
|
||||
# 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据
|
||||
assert any("写入失败" in m for m in captured_warnings)
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
@@ -184,6 +184,72 @@ class TestSamplingOverlay:
|
||||
assert [c["seed"] for c in captured] == [1, 2]
|
||||
|
||||
|
||||
class _MemoryRecorder:
|
||||
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
|
||||
|
||||
def __init__(self):
|
||||
self.rows = []
|
||||
|
||||
async def record_llm_call(self, **fields):
|
||||
self.rows.append(fields)
|
||||
|
||||
|
||||
class TestCallerDimensions:
|
||||
"""调用方自定义维度进遥测(issue #11 Task 4)。"""
|
||||
|
||||
_MSG = [{"role": "user", "content": "hi"}]
|
||||
|
||||
async def test_dimensions_reach_telemetry_row(self):
|
||||
recorder = _MemoryRecorder()
|
||||
async with _client(telemetry=recorder) as client:
|
||||
await client.chat(self._MSG, tenant_id="t1", meta={"batch": "b-42"})
|
||||
row = recorder.rows[-1]
|
||||
assert row["tenant_id"] == "t1"
|
||||
assert json.loads(row["meta"]) == {"batch": "b-42"}
|
||||
|
||||
async def test_default_path_writes_sentinels(self):
|
||||
"""不传两参数时落哨兵值而非 NULL(§4.4: NULL 在 RLS 下是永久不可见的黑洞)。"""
|
||||
recorder = _MemoryRecorder()
|
||||
async with _client(telemetry=recorder) as client:
|
||||
await client.chat(self._MSG)
|
||||
row = recorder.rows[-1]
|
||||
assert row["tenant_id"] == "" and row["meta"] == "{}"
|
||||
|
||||
async def test_invalid_meta_key_rejected_before_any_telemetry(self):
|
||||
"""校验早于遥测(§4.2 核心承诺): 放进洋葱就会被降级成 warning 而调用照常发出。"""
|
||||
recorder = _MemoryRecorder()
|
||||
async with _client(telemetry=recorder) as client:
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
await client.chat(self._MSG, meta={"BAD-KEY": 1})
|
||||
assert recorder.rows == []
|
||||
|
||||
async def test_non_finite_float_rejected_before_any_telemetry(self):
|
||||
"""nan 产出的是 PG 拒收的非法 JSON;放行等于把调用方 bug 变成静默丢遥测(§6)。"""
|
||||
recorder = _MemoryRecorder()
|
||||
async with _client(telemetry=recorder) as client:
|
||||
with pytest.raises(ValueError, match="nan"):
|
||||
await client.chat(self._MSG, meta={"k": float("nan")})
|
||||
assert recorder.rows == []
|
||||
|
||||
async def test_meta_does_not_enter_cache_key(self):
|
||||
"""仅 meta 不同必须仍命中缓存(F1): 进 key 会让存量缓存全量冷启动且不报错。
|
||||
|
||||
带对照组: 只断言"命中"的话,缓存 key 退化成常量(忽略一切输入)时本用例
|
||||
照样绿——那是恒真断言。故再改一个**确实进 key** 的维度(namespace)断言
|
||||
miss,证明 key 仍在区分输入,"meta 不进 key"才是被测出来的结论。
|
||||
"""
|
||||
cache = InMemoryCache() # 两个 client 共用一份存储,否则对照组的 miss 是白来的
|
||||
client = _client(cache=cache, cache_namespace="proj", cache_ttl_s=3600)
|
||||
async with client:
|
||||
first = await client.chat(self._MSG, meta={"batch": "b-1"})
|
||||
second = await client.chat(self._MSG, meta={"batch": "b-2"})
|
||||
assert first.cache_hit is False and second.cache_hit is True
|
||||
|
||||
other_ns = _client(cache=cache, cache_namespace="other", cache_ttl_s=3600)
|
||||
async with other_ns:
|
||||
assert (await other_ns.chat(self._MSG, meta={"batch": "b-1"})).cache_hit is False
|
||||
|
||||
|
||||
class TestModelFingerprint:
|
||||
"""配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""
|
||||
|
||||
|
||||
@@ -412,6 +412,45 @@ class TestEmbedTelemetry:
|
||||
assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库
|
||||
|
||||
|
||||
class TestEmbedCallerDimensions:
|
||||
"""issue #11: 调用方自定义维度必须沿 embed 链四层透传到每一行遥测。"""
|
||||
|
||||
async def test_single_batch_row_carries_dimensions(self):
|
||||
rec = _MemoryRecorder()
|
||||
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
|
||||
await client.embed(["a"], tenant_id="t1", meta={"batch": "b-42"})
|
||||
assert rec.rows[0]["tenant_id"] == "t1"
|
||||
assert rec.rows[0]["meta"] == '{"batch": "b-42"}'
|
||||
|
||||
async def test_every_batch_row_carries_the_same_dimensions(self):
|
||||
"""维度属于本次 `embed()` 调用,不随批次变化。
|
||||
|
||||
只断言首行会漏掉"只有第一批带维度"的实现——那正是逐层透传最容易漏的形态。
|
||||
"""
|
||||
rec = _MemoryRecorder()
|
||||
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=1, telemetry=rec)
|
||||
await client.embed(["a", "b", "c"], tenant_id="t1", meta={"batch": "b-42"})
|
||||
assert len(rec.rows) == 3 # 切成三批,每批一行
|
||||
assert [r["tenant_id"] for r in rec.rows] == ["t1", "t1", "t1"]
|
||||
assert [r["meta"] for r in rec.rows] == ['{"batch": "b-42"}'] * 3
|
||||
|
||||
async def test_invalid_meta_rejected_before_any_telemetry(self):
|
||||
"""校验在切批之前: 遥测层的失败都被降级成 warning,放下游等于没有校验。"""
|
||||
rec = _MemoryRecorder()
|
||||
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
await client.embed(["a"], meta={"Bad Key": 1})
|
||||
assert rec.rows == []
|
||||
assert client._transport.calls == [] # 连调用都没发出
|
||||
|
||||
async def test_defaults_land_as_sentinels(self):
|
||||
rec = _MemoryRecorder()
|
||||
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
|
||||
await client.embed(["a"])
|
||||
assert rec.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
|
||||
assert rec.rows[0]["meta"] == "{}"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _captured_warnings():
|
||||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||
|
||||
@@ -34,6 +34,43 @@ class TestBaseShape:
|
||||
assert TransientError("429", retry_after_s=2.5).retry_after_s == 2.5
|
||||
|
||||
|
||||
class TestBodyText:
|
||||
"""issue #10: 非 2xx 的响应体摘要必须有承载处,否则拒绝理由事后不可查。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cls", (PolyGatewayError, TransientError, SourceDeadError, RequestRejectedError)
|
||||
)
|
||||
def test_defaults_empty_and_accepts_summary(self, cls):
|
||||
assert cls("boom").body_text == ""
|
||||
assert cls("boom", body_text='{"error":{"code":"bad"}}').body_text == (
|
||||
'{"error":{"code":"bad"}}'
|
||||
)
|
||||
|
||||
def test_result_invalid_keeps_both_fields_apart(self):
|
||||
"""`body_text`(非 2xx 的拒绝理由)与 `raw_text`(2xx 的不可解析输出)不得混用。"""
|
||||
exc = ResultInvalidError("bad json", raw_text="{oops", body_text="")
|
||||
assert exc.raw_text == "{oops"
|
||||
assert exc.body_text == ""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
(
|
||||
AllSourcesExhausted(scope="llm", reason="stalled", retry_after_s=1.0),
|
||||
CircuitOpenError(scope="llm", retry_after_s=1.0),
|
||||
GovernanceBackendError("redis down", scope="llm"),
|
||||
),
|
||||
)
|
||||
def test_scope_level_errors_carry_no_body(self, exc):
|
||||
"""scope 级失败没有单一响应体可言,空串是如实表达而非噪音。"""
|
||||
assert exc.body_text == ""
|
||||
|
||||
def test_body_text_does_not_leak_into_str(self):
|
||||
"""字段是旁路数据: 加了它不得改变任何既有异常的 str() 输出。"""
|
||||
assert str(RequestRejectedError("qwen_1 请求被拒: 400", body_text="whatever")) == (
|
||||
"qwen_1 请求被拒: 400"
|
||||
)
|
||||
|
||||
|
||||
class TestResultInvalid:
|
||||
def test_carries_diagnosis(self):
|
||||
exc = ResultInvalidError(
|
||||
@@ -134,9 +171,7 @@ class TestGovernanceBackendReason:
|
||||
assert "governance_backend_down" in SCOPE_REASONS
|
||||
|
||||
def test_gateway_unavailable_accepts_the_new_reason(self):
|
||||
exc = AllSourcesExhausted(
|
||||
scope="LLM", reason="governance_backend_down", retry_after_s=0.0
|
||||
)
|
||||
exc = AllSourcesExhausted(scope="LLM", reason="governance_backend_down", retry_after_s=0.0)
|
||||
assert exc.reason == "governance_backend_down"
|
||||
|
||||
def test_retry_after_default_is_non_zero(self):
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""HTTP 错误响应体摘要口径(issue #10 设计 §3.2/§3.4)。
|
||||
|
||||
摘要是 message 与 `body_text` 共用的**同一份串**,故它的边界行为直接决定
|
||||
遥测里看到的与下游 catch 到的是否一致——本组用例把规则钉成算术。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from polygateway.transports._http_errors import (
|
||||
_ERROR_BODY_CAP,
|
||||
_HEAD_CHARS,
|
||||
_TAIL_CHARS,
|
||||
compose_message,
|
||||
response_body,
|
||||
summarize_body,
|
||||
)
|
||||
|
||||
# issue #10 原文给出的真实响应体(一字不改),关键在于 code 收尾
|
||||
_REAL_SAMPLE = (
|
||||
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
|
||||
'and cannot be opened","type":"invalid_request_error","param":"",'
|
||||
'"code":"invalid_parameter_error"}}'
|
||||
)
|
||||
|
||||
|
||||
class TestSummarizeBody:
|
||||
def test_short_body_passes_through(self):
|
||||
assert summarize_body(_REAL_SAMPLE) == _REAL_SAMPLE
|
||||
|
||||
def test_whitespace_collapsed(self):
|
||||
"""错误体常是缩进 JSON: 不折叠会把一行日志炸成多行、遥测列不可读。"""
|
||||
assert (
|
||||
summarize_body('{\n "error": {\n "code": "x"\n }\n}')
|
||||
== '{ "error": { "code": "x" } }'
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("raw", ("", " ", "\n\t \n"))
|
||||
def test_blank_yields_empty(self, raw):
|
||||
assert summarize_body(raw) == ""
|
||||
|
||||
def test_exactly_at_cap_is_untouched(self):
|
||||
body = "x" * _ERROR_BODY_CAP
|
||||
assert summarize_body(body) == body
|
||||
|
||||
def test_one_over_cap_is_summarized(self):
|
||||
summary = summarize_body("x" * (_ERROR_BODY_CAP + 1))
|
||||
assert summary != "x" * (_ERROR_BODY_CAP + 1)
|
||||
assert "略" in summary
|
||||
|
||||
def test_head_and_tail_both_survive(self):
|
||||
"""头部硬切会丢掉尾部,而 JSON 错误体的 code/request_id 正在尾部。"""
|
||||
body = "H" * 5000 + "T" * 5000
|
||||
summary = summarize_body(body)
|
||||
assert summary[:_HEAD_CHARS] == body[:_HEAD_CHARS]
|
||||
assert summary[-_TAIL_CHARS:] == body[-_TAIL_CHARS:]
|
||||
assert f"…(略 {10000 - _HEAD_CHARS - _TAIL_CHARS} 字)…" in summary
|
||||
|
||||
def test_real_sample_tail_visible_in_oversized_body(self):
|
||||
"""设计 §7 用例 3c: 超长体里,追查网关方所需的 code 仍须可见。"""
|
||||
summary = summarize_body("PADDING" * 1000 + _REAL_SAMPLE)
|
||||
assert '"code":"invalid_parameter_error"}}' in summary
|
||||
|
||||
def test_idempotent(self):
|
||||
"""再摘要一次不得嵌套标记,否则重复经手的串会层层套娃。"""
|
||||
once = summarize_body("y" * 9999)
|
||||
assert summarize_body(once) == once
|
||||
|
||||
|
||||
class TestComposeMessage:
|
||||
def test_empty_summary_leaves_message_intact(self):
|
||||
assert compose_message("qwen_1 请求被拒: 400", "") == "qwen_1 请求被拒: 400"
|
||||
|
||||
def test_non_empty_summary_is_appended(self):
|
||||
assert compose_message("qwen_1 请求被拒: 400", "{}") == "qwen_1 请求被拒: 400 | {}"
|
||||
|
||||
|
||||
class TestResponseBody:
|
||||
def test_reads_buffered_text(self):
|
||||
assert response_body(httpx.Response(400, content=b'{"e":1}')) == '{"e":1}'
|
||||
|
||||
def test_unread_stream_degrades_to_empty(self):
|
||||
"""取不到诊断信息绝不能升级为崩溃: 未读缓冲返回空串,且不触发网络读。"""
|
||||
|
||||
class _Unread(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"body"
|
||||
|
||||
assert response_body(httpx.Response(400, stream=_Unread())) == ""
|
||||
@@ -19,7 +19,11 @@ from polygateway.errors import (
|
||||
SourceDeadError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.transports.monkey_ocr import MonkeyOcrTransport, _parse_middle_json
|
||||
from polygateway.transports.monkey_ocr import (
|
||||
MonkeyOcrTransport,
|
||||
_classify_status,
|
||||
_parse_middle_json,
|
||||
)
|
||||
from polygateway.types import SourceConfig
|
||||
|
||||
|
||||
@@ -306,6 +310,45 @@ class TestErrorTranslation:
|
||||
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
|
||||
assert ei.value.status_code == status
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "exc_type"),
|
||||
[
|
||||
(502, TransientError),
|
||||
(429, TransientError), # 与 5xx 共用分支,但仍单列: 漏分支正是 issue #10 的成因
|
||||
(401, SourceDeadError),
|
||||
(404, RequestRejectedError),
|
||||
],
|
||||
)
|
||||
async def test_body_survives_every_branch(self, status, exc_type):
|
||||
"""issue #10: OCR 侧 message 原本只有 HTTP 状态码,拒绝理由同样丢失。"""
|
||||
body = '{"detail":"unsupported image mode CMYK"}'
|
||||
t = _transport_for(_routes(text_resp=httpx.Response(status, content=body.encode())))
|
||||
with pytest.raises(exc_type) as ei:
|
||||
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
|
||||
assert ei.value.body_text == body
|
||||
assert str(ei.value).endswith(f" | {body}")
|
||||
|
||||
def test_unread_body_degrades_without_changing_class(self):
|
||||
"""取不到 body 时降级空串: 绝不能让 ResponseNotRead 逃出错误四分类。
|
||||
|
||||
直接测纯函数而非走 MockTransport——真实客户端对非 stream 请求总会读完
|
||||
响应,未读态只可能在将来给 OCR 加 stream 时出现,而那正是要防的场景。
|
||||
"""
|
||||
|
||||
class _Unread(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"body"
|
||||
|
||||
exc = httpx.HTTPStatusError(
|
||||
"404",
|
||||
request=httpx.Request("POST", "http://ocr.example/ocr/text"),
|
||||
response=httpx.Response(404, stream=_Unread()),
|
||||
)
|
||||
err = _classify_status(exc, "monkey_1", "text")
|
||||
assert isinstance(err, RequestRejectedError)
|
||||
assert err.body_text == ""
|
||||
assert str(err) == "monkey_1 OCR text HTTP 404"
|
||||
|
||||
async def test_connect_error_transient(self):
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("refused", request=request)
|
||||
|
||||
@@ -480,6 +480,59 @@ class TestTelemetry:
|
||||
assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣
|
||||
|
||||
|
||||
class TestOcrCallerDimensions:
|
||||
"""issue #11: 调用方自定义维度必须沿 OCR 链四层透传到每一行遥测。
|
||||
|
||||
OCR 行与 chat 行落在同一张 `llm_calls` 表: 不覆盖这条链会让同一张表里
|
||||
一部分行有租户归属、一部分永远空白,而"先启用后加列则归属无法还原"。
|
||||
"""
|
||||
|
||||
async def test_recognize_text_row_carries_dimensions(self):
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
||||
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
|
||||
assert recorder.rows[0]["tenant_id"] == "t1"
|
||||
assert recorder.rows[0]["meta"] == '{"batch": "b-42"}'
|
||||
|
||||
async def test_parse_layout_row_carries_dimensions(self):
|
||||
"""两个公共方法都是入口: 只测一个会漏掉另一个的透传缺口。"""
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client([_src()], ["layout"], telemetry=recorder)
|
||||
await client.parse_layout(b"jpg", tenant_id="t2", meta={"batch": "b-43"})
|
||||
assert recorder.rows[0]["tenant_id"] == "t2"
|
||||
assert recorder.rows[0]["meta"] == '{"batch": "b-43"}'
|
||||
|
||||
async def test_failed_attempt_row_also_carries_dimensions(self):
|
||||
"""失败行同样需要归属: 某租户的请求没被服务,正是审计最需要的一行。"""
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client(
|
||||
[_src()],
|
||||
[TransientError("boom", status_code=500), "text"],
|
||||
telemetry=recorder,
|
||||
)
|
||||
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
|
||||
assert len(recorder.rows) == 2 # 失败尝试 + 成功尝试
|
||||
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
|
||||
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
|
||||
|
||||
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
||||
async def test_invalid_meta_rejected_before_any_telemetry(self, method):
|
||||
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
await getattr(client, method)(b"jpg", meta={"Bad Key": 1})
|
||||
assert recorder.rows == []
|
||||
assert client._transport.calls == [] # 连调用都没发出
|
||||
|
||||
async def test_defaults_land_as_sentinels(self):
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
||||
await client.recognize_text(b"jpg")
|
||||
assert recorder.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
|
||||
assert recorder.rows[0]["meta"] == "{}"
|
||||
|
||||
|
||||
class TestAssembly:
|
||||
_ENV = {
|
||||
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
|
||||
|
||||
@@ -16,6 +16,7 @@ from polygateway.errors import (
|
||||
)
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.pricing import ModelPrice, PricingTable
|
||||
from polygateway.transports._http_errors import summarize_body
|
||||
from polygateway.transports.openai_compat import (
|
||||
OpenAICompatTransport,
|
||||
_iter_sse_deltas,
|
||||
@@ -691,6 +692,125 @@ class TestErrorTranslation:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
|
||||
|
||||
# issue #10 原文给出的真实响应体(一字不改): 关键在于 code 收尾
|
||||
_REJECT_BODY = (
|
||||
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
|
||||
'and cannot be opened","type":"invalid_request_error","param":"",'
|
||||
'"code":"invalid_parameter_error"}}'
|
||||
)
|
||||
|
||||
|
||||
class TestErrorBodyRetention:
|
||||
"""issue #10: 网关说了什么必须活着离开翻译层——message 与字段各留一份。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "exc"),
|
||||
[
|
||||
(400, RequestRejectedError),
|
||||
(401, SourceDeadError),
|
||||
(403, SourceDeadError),
|
||||
(404, RequestRejectedError),
|
||||
(500, TransientError),
|
||||
(503, TransientError),
|
||||
],
|
||||
)
|
||||
async def test_every_non_2xx_branch_keeps_the_body(self, status, exc):
|
||||
def handler(request):
|
||||
return httpx.Response(status, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(exc) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
# message 与字段共用同一份串: 遥测里看到的与下游 catch 到的不得打架
|
||||
assert str(ei.value).endswith(f" | {_REJECT_BODY}")
|
||||
assert "invalid_parameter_error" in str(ei.value)
|
||||
|
||||
async def test_rate_limited_429_keeps_body_and_retry_after(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
429,
|
||||
content=b'{"error":{"message":"per-minute cap 3"}}',
|
||||
headers={"retry-after": "2.5"},
|
||||
)
|
||||
|
||||
with pytest.raises(TransientError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert "per-minute cap 3" in str(ei.value)
|
||||
assert ei.value.retry_after_s == 2.5 # 摘要不得干扰既有解析
|
||||
|
||||
async def test_insufficient_quota_429_keeps_body(self):
|
||||
body = json.dumps(
|
||||
{"error": {"type": "insufficient_quota", "message": "daily budget spent"}}
|
||||
)
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(429, content=body.encode())
|
||||
|
||||
with pytest.raises(SourceDeadError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert "daily budget spent" in str(ei.value)
|
||||
|
||||
async def test_oversized_insufficient_quota_still_classified_dead(self):
|
||||
"""实现红线: 类型判定必须读**原文**。
|
||||
|
||||
摘要会破坏 JSON 结构,若改用摘要解析,超长 body 的配额耗尽将退化成普通
|
||||
限速——配额已耗尽的源不再 force_open,一个诊断改进就变成了治理 bug。
|
||||
|
||||
**填充必须是多个键**,不能是单个超长字符串值: 后者的截断点落在字符串
|
||||
*内部*,省略标记成了合法的字符串内容,而头尾保留又让尾部的 error 对象
|
||||
幸存——摘要照样解析得出 `insufficient_quota`,用例即告空转(2026-08-16
|
||||
verifier 变异测试发现: 按错误写法实现,全套件 824 项依然全绿)。多键
|
||||
填充让截断点落在结构记号之间,摘要才真正不可解析。
|
||||
"""
|
||||
body = json.dumps(
|
||||
{**{f"k{i}": "v" * 10 for i in range(300)}, "error": {"type": "insufficient_quota"}}
|
||||
)
|
||||
assert len(body) > 2048
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
# 判别力的前提: 摘要确实不再是合法 JSON,读它必然拿不到 type
|
||||
json.loads(summarize_body(body))
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(429, content=body.encode())
|
||||
|
||||
with pytest.raises(SourceDeadError):
|
||||
await _complete(_transport_for(handler), _source())
|
||||
|
||||
async def test_empty_body_leaves_no_dangling_separator(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=b"")
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert str(ei.value) == "qwen_1 请求被拒: 400"
|
||||
assert ei.value.body_text == ""
|
||||
|
||||
@pytest.mark.parametrize("body", [b"<html>gateway down</html>", b"\xff\xfe not utf-8"])
|
||||
async def test_non_json_and_non_utf8_bodies_do_not_explode(self, body):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=body)
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert ei.value.status_code == 400 # 分类不受 body 形态影响
|
||||
|
||||
async def test_non_stream_path_keeps_the_body(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source(), stream=False)
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
|
||||
async def test_embedding_path_keeps_the_body(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _transport_for(handler).embed(texts=["hi"], source=_source(), call_id="cid-embed")
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
async def test_aclose_idempotent(self):
|
||||
def handler(request):
|
||||
|
||||
@@ -118,6 +118,8 @@ class _DummyRecorder:
|
||||
model_reported,
|
||||
sampling,
|
||||
reasoning_tokens,
|
||||
tenant_id,
|
||||
meta,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@@ -205,6 +207,29 @@ class TestGateUpdate:
|
||||
)
|
||||
|
||||
|
||||
class TestTelemetryRecorderSignature:
|
||||
"""`record_llm_call` 的冻结签名以 `inspect.signature` 实测,不凭记忆断言。
|
||||
|
||||
该 Protocol 的纪律是新增参数**不设默认值**(ports.py docstring):库外无第三方
|
||||
实现者,而带默认值的参数会让 emitter 漏传时静默落默认值——遥测里的租户归属
|
||||
一旦静默错位,事后无从分辨是"没传"还是"就是空的"。
|
||||
"""
|
||||
|
||||
def test_caller_dimensions_are_declared(self):
|
||||
import inspect
|
||||
|
||||
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||
assert {"tenant_id", "meta"} <= set(params)
|
||||
|
||||
@pytest.mark.parametrize("name", ["tenant_id", "meta"])
|
||||
def test_caller_dimensions_have_no_default(self, name):
|
||||
import inspect
|
||||
|
||||
param = inspect.signature(TelemetryRecorder.record_llm_call).parameters[name]
|
||||
assert param.default is inspect.Parameter.empty
|
||||
assert param.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
|
||||
|
||||
class TestOcrPorts:
|
||||
"""M3 三个 OCR Protocol(设计 §3.2): runtime_checkable 结构判定。"""
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -40,6 +41,8 @@ _EXPECTED_COLUMNS = [
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
]
|
||||
|
||||
|
||||
@@ -104,11 +107,38 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
||||
"model_reported": None,
|
||||
"sampling": None,
|
||||
"reasoning_tokens": None,
|
||||
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
|
||||
"tenant_id": "",
|
||||
"meta": "{}",
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
|
||||
|
||||
class TestBackendColumnParity:
|
||||
"""两个后端的 `_COLUMNS` 必须逐字同名同序(issue #11)。
|
||||
|
||||
emitter 只组装一份 `fields`,两个后端各自按自己的 `_COLUMNS` 取值;两份清单
|
||||
一旦分叉,同一次调用在 SQLite 上写得进、在 PG 上抛 KeyError 被降级吞掉,
|
||||
差异只在换后端时才暴露。列**序**同样断言: INSERT 用位置占位符,顺序错位
|
||||
会把值写进错误的列而不报错。
|
||||
"""
|
||||
|
||||
def test_two_backends_agree_on_columns(self):
|
||||
from polygateway.telemetry.postgres import _COLUMNS as PG_COLUMNS
|
||||
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
|
||||
|
||||
assert SQLITE_COLUMNS == PG_COLUMNS
|
||||
|
||||
def test_caller_dimensions_are_appended_last(self):
|
||||
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
|
||||
from polygateway.telemetry.postgres import _COLUMNS as PG_COLUMNS
|
||||
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
|
||||
|
||||
assert SQLITE_COLUMNS[-2:] == ("tenant_id", "meta")
|
||||
assert PG_COLUMNS[-2:] == ("tenant_id", "meta")
|
||||
|
||||
|
||||
class TestSQLiteRecorder:
|
||||
async def test_schema_has_frozen_columns(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
@@ -256,6 +286,135 @@ class TestSQLiteColumnBackfill:
|
||||
recorder.close()
|
||||
|
||||
|
||||
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
|
||||
_PRE_TENANT_DDL = """
|
||||
CREATE TABLE llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms REAL,
|
||||
max_inter_token_ms REAL,
|
||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
cost REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
_PRE_TENANT_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
|
||||
"VALUES ('old-row', 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
|
||||
)
|
||||
|
||||
|
||||
def _make_pre_tenant_db(path: Path) -> None:
|
||||
"""造一个 issue #11 之前的库: 22 字段旧表 + 一行没有租户归属的历史数据。"""
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute(_PRE_TENANT_DDL)
|
||||
conn.execute(_PRE_TENANT_INSERT)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestSQLiteCallerDimensionsAcceptance:
|
||||
"""issue #11 的机械化验收(SQLite 侧,真实临时文件): 新建库 / 旧表补列 / 补列失败方向。"""
|
||||
|
||||
async def test_fresh_db_round_trips_the_dimensions(self, tmp_path):
|
||||
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
||||
db = tmp_path / "fresh.db"
|
||||
recorder = SQLiteRecorder(db)
|
||||
await _record_minimal(
|
||||
recorder, call_id="c-dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||
)
|
||||
recorder.close()
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
|
||||
row = conn.execute(
|
||||
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = 'c-dim'"
|
||||
).fetchone()
|
||||
assert row[0] == "tenant-a"
|
||||
assert json.loads(row[1]) == {"batch": "b7"}
|
||||
|
||||
async def test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable(self, tmp_path):
|
||||
"""22 字段旧表补列后,新行带维度,而**老行的 tenant_id 是空串而非 NULL**。
|
||||
|
||||
这条直接验收 issue #11 的核心论点(先启用落库、后加列,补列之前的行没有
|
||||
租户归属)。断言方向必须是空串: PG 的 RLS `USING` 表达式对返回 false **或
|
||||
NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",而是对
|
||||
所有人永久不可见的黑洞;哨兵空串则能被一条 `COUNT(*) WHERE tenant_id = ''`
|
||||
审计出来,历史欠账是可见、可量化、可补录的。
|
||||
"""
|
||||
db = tmp_path / "pre_tenant.db"
|
||||
_make_pre_tenant_db(db)
|
||||
|
||||
recorder = SQLiteRecorder(db)
|
||||
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a", meta='{"k": 1}')
|
||||
recorder.close()
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
||||
assert cols == _EXPECTED_COLUMNS # 22 → 24 个 recorder 字段(+ created_at 共 25 物理列)
|
||||
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
|
||||
assert rows["new-row"] == "tenant-a"
|
||||
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||
assert (
|
||||
conn.execute("SELECT meta FROM llm_calls WHERE call_id = 'old-row'").fetchone()[0]
|
||||
== "{}"
|
||||
)
|
||||
|
||||
async def test_readonly_file_backfill_failure_keeps_the_recorder_alive(self, tmp_path):
|
||||
"""补列失败的降级方向(SQLite 等价构造: 文件只读)。
|
||||
|
||||
SQLite 没有角色权限模型,与 PG「只有 SELECT/INSERT 权限的角色」等价的构造
|
||||
是文件本身只读。库文件必须**预先置为 WAL 且干净关闭**,否则 `__init__` 的
|
||||
`PRAGMA journal_mode=WAL` 会先撞上只读而让失败点跑到补列之前,测不到本用例
|
||||
要测的那条分支(实测: 非 WAL 库 chmod 444 后该 PRAGMA 报 readonly database)。
|
||||
只读库连 INSERT 都做不了,故这里**只断言**补列失败不清空 `_conn`、不抛出
|
||||
`__init__`(sqlite.py `_backfill_columns` 那条纪律),不断言"写入仍成功"。
|
||||
"""
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip("root 无视文件权限位,只读构造不成立")
|
||||
db = tmp_path / "readonly.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute("PRAGMA journal_mode=WAL") # 预置 WAL: 让只读连接不必改日志模式
|
||||
conn.execute(_PRE_TENANT_DDL)
|
||||
conn.execute(_PRE_TENANT_INSERT)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
db.chmod(0o444)
|
||||
|
||||
# finally 还原权限位: 任一断言先失败时,不还原会让 tmp_path 清理连带报错,
|
||||
# 把"某条断言失败"的真因盖成一个无关的 PermissionError
|
||||
try:
|
||||
recorder = SQLiteRecorder(db) # 不得抛
|
||||
assert recorder._conn is not None # 补列失败 ≠ recorder 失能
|
||||
await _record_minimal(recorder, call_id="doomed") # 只读库写不进,但不得抛
|
||||
recorder.close()
|
||||
finally:
|
||||
db.chmod(0o644)
|
||||
|
||||
stale = sqlite3.connect(db)
|
||||
assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == (
|
||||
_EXPECTED_COLUMNS[:-2]
|
||||
) # 补列确实没成功,用例不是在只读库上空转
|
||||
|
||||
|
||||
class _FakePgConn:
|
||||
"""记录执行过的语句;可让 ALTER/CREATE/探测抛错以模拟权限不足与抖动。
|
||||
|
||||
@@ -327,6 +486,8 @@ class TestPostgresBackfillDiscipline:
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
]
|
||||
|
||||
def _recorder(self, conn):
|
||||
@@ -379,6 +540,8 @@ class TestPostgresTableProbe:
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
]
|
||||
|
||||
def _recorder(self, conn):
|
||||
@@ -613,6 +776,129 @@ class TestEmitterSamplingColumn:
|
||||
assert rec.rows[0]["sampling"] is None
|
||||
|
||||
|
||||
class TestEmitterCallerDimensions:
|
||||
"""issue #11: 三个 emit 入口统一从 `request` 读维度,`_record` 落库前归一化。
|
||||
|
||||
维度只有一个读取点(`request`),否则同一列在三种行里口径分叉——那正是
|
||||
"遥测调用点收敛为单一 helper"这条铁律要防的形态。
|
||||
"""
|
||||
|
||||
_META = {"z_last": "z", "a_first": 1, "m_mid": True}
|
||||
_REQ_A = ChatRequest(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
session_id="sess-1",
|
||||
tenant_id="tenant-a",
|
||||
meta=_META,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("emit", ["attempt", "cache_hit", "terminal_failure"])
|
||||
async def test_every_entry_point_carries_the_dimensions(self, emit):
|
||||
"""三条路径写出的行都必须带维度: 漏掉任一条,该租户的账就永远对不上。"""
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec)
|
||||
if emit == "attempt":
|
||||
await emitter.emit_attempt(
|
||||
request=self._REQ_A,
|
||||
source=_source(),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(),
|
||||
error=None,
|
||||
)
|
||||
elif emit == "cache_hit":
|
||||
await emitter.emit_cache_hit(request=self._REQ_A, response=_resp(cache_hit=True))
|
||||
else:
|
||||
await emitter.emit_terminal_failure(
|
||||
request=self._REQ_A, call_id="c", latency_ms=1, error="dead"
|
||||
)
|
||||
row = rec.rows[0]
|
||||
assert row["tenant_id"] == "tenant-a"
|
||||
assert json.loads(row["meta"]) == self._META
|
||||
|
||||
async def test_cache_hit_records_the_current_caller_not_the_cached_one(self):
|
||||
"""缓存命中行的维度是"本次由谁发起",不是历史那次——最容易实现反的一处。
|
||||
|
||||
历史那次由租户 B 发起并把响应留在了缓存里;本次由租户 A 发起并命中。
|
||||
若读了历史那次的归属,租户 A 的调用会记到 B 头上,而 A 的账面凭空少一行
|
||||
——两个租户的账同时错,且错得没有任何报错。
|
||||
"""
|
||||
historical = ChatRequest(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tenant_id="tenant-b",
|
||||
meta={"batch": "old-batch"},
|
||||
)
|
||||
rec = _MemoryRecorder()
|
||||
mw = TelemetryMW(TelemetryEmitter(rec))
|
||||
|
||||
async def terminal(request):
|
||||
# 缓存层回放的是历史那次的响应对象(其 call_id 属于 historical 那次)
|
||||
return _resp(cache_hit=True, latency_ms=0, call_id="cache-cid")
|
||||
|
||||
assert historical.tenant_id == "tenant-b" # 历史归属确实不同,否则本用例是空转
|
||||
await mw(self._REQ_A, terminal)
|
||||
|
||||
row = rec.rows[0]
|
||||
assert row["cache_hit"] is True
|
||||
assert row["tenant_id"] == "tenant-a"
|
||||
assert "old-batch" not in row["meta"]
|
||||
|
||||
async def test_absent_dimensions_land_as_sentinels(self):
|
||||
"""未传维度落哨兵值: `tenant_id` 空串、`meta` 字面量 `'{}'`,都不是 NULL。
|
||||
|
||||
NULL 的 `tenant_id` 在 PG 的 RLS policy 下对所有人永久不可见(设计 §4.4),
|
||||
空串则可用一条 SQL 审计出还有多少行未归属;`meta` 同理,`'{}'` 可被
|
||||
JSON 函数直接查询,NULL 则要每条查询都额外判空。
|
||||
"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec).emit_attempt(
|
||||
request=_REQ, # tenant_id=None, meta={}
|
||||
source=_source(),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(),
|
||||
error=None,
|
||||
)
|
||||
row = rec.rows[0]
|
||||
assert row["tenant_id"] == ""
|
||||
assert row["meta"] == "{}"
|
||||
|
||||
async def test_meta_is_serialized_with_sorted_keys(self):
|
||||
"""键序固定,同一份维度在任意两行里字节一致,可直接做等值比对与去重。"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec).emit_terminal_failure(
|
||||
request=self._REQ_A, call_id="c", latency_ms=1, error="dead"
|
||||
)
|
||||
assert list(json.loads(rec.rows[0]["meta"])) == ["a_first", "m_mid", "z_last"]
|
||||
|
||||
async def test_non_ascii_meta_stays_readable(self):
|
||||
"""`ensure_ascii=False`: 中文维度按原文落库,而非 `\\uXXXX` 转义串。"""
|
||||
rec = _MemoryRecorder()
|
||||
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], meta={"dept": "研发"})
|
||||
await TelemetryEmitter(rec).emit_terminal_failure(
|
||||
request=req, call_id="c", latency_ms=1, error="dead"
|
||||
)
|
||||
assert "研发" in rec.rows[0]["meta"]
|
||||
|
||||
async def test_non_finite_meta_value_drops_the_row_instead_of_poisoning_it(self):
|
||||
"""入口失守时 `allow_nan=False` 的真实结果: 整行降级丢弃,且不抛给调用方。
|
||||
|
||||
直接构造带 `nan` 的 `ChatRequest`(绕过 `validate_caller_dimensions` 这道
|
||||
主防线,模拟将来某个新入口忘记校验)。没有 `allow_nan=False` 时,
|
||||
`json.dumps` 会写出裸 `NaN` 字面量——PG 的 JSONB 会拒收,但 **SQLite 的
|
||||
`meta` 是 TEXT 列不做校验**,那串非法 JSON 会被静默存进去,污染此后一切
|
||||
按 JSON 解析 meta 的分析。宁可丢一行遥测,也不要一行毒数据。
|
||||
|
||||
同时断言不抛: 遥测的降级方向是"静默降级"(铁律),把调用方的一次正常
|
||||
业务调用因为一个维度值炸掉,方向反了。
|
||||
"""
|
||||
rec = _MemoryRecorder()
|
||||
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], meta={"k": float("nan")})
|
||||
await TelemetryEmitter(rec).emit_terminal_failure(
|
||||
request=req, call_id="c", latency_ms=1, error="dead"
|
||||
)
|
||||
assert rec.rows == []
|
||||
|
||||
|
||||
class TestCostWithCachedTier:
|
||||
"""issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。"""
|
||||
|
||||
|
||||
@@ -398,3 +398,132 @@ class TestSourceConfigExtraBody:
|
||||
"""
|
||||
with pytest.raises(TypeError):
|
||||
hash(_make_source())
|
||||
|
||||
|
||||
class TestCallerDimensionsValidation:
|
||||
"""调用方自定义维度的入口校验(issue #11 设计 §4.2)。
|
||||
|
||||
这些红线全部要求**报错**而非静默丢弃: Langfuse 对超长 value 的做法是
|
||||
直接丢掉,本库不抄——P5 严禁默认值掩盖错误。且报错点必须在进洋葱之前,
|
||||
洋葱内的一切失败都会被遥测层降级成 warning,校验放那里等于没有校验。
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"", # 空串是哨兵值的地盘(老行/未归属)
|
||||
" ", # 纯空白 strip 后为空
|
||||
" t1", # 首尾空白: 与 "t1" 在 RLS 等值比较下是两个租户
|
||||
"t1 ",
|
||||
"x" * 129, # 上限 128
|
||||
123, # 非 str
|
||||
],
|
||||
)
|
||||
def test_bad_tenant_id_rejected(self, bad):
|
||||
"""租户标识形态错误必须当场报错,而非带着走到落库。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
validate_caller_dimensions(bad, None, origin="chat(tenant_id=...)")
|
||||
assert "chat(tenant_id=...)" in str(exc.value) # 信息须能定位来源
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"Batch", # 大写不合字符集
|
||||
"batch-id", # 连字符不合字符集
|
||||
"b" * 65, # 键长上限 64
|
||||
"pg_internal", # 保留前缀
|
||||
"", # 空键
|
||||
],
|
||||
)
|
||||
def test_bad_meta_key_rejected(self, key):
|
||||
"""键集合被假定为低基数且稳定,形态必须收紧(OTel semconv 字符集)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
validate_caller_dimensions(None, {key: "v"}, origin="test")
|
||||
|
||||
def test_non_str_meta_key_rejected(self):
|
||||
"""非 str 键无法进 JSON 对象,须先于值校验报出键的问题。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="str"):
|
||||
validate_caller_dimensions(None, {1: "a"}, origin="test")
|
||||
|
||||
def test_too_many_meta_keys_rejected(self):
|
||||
"""上限 16: 容器是审计维度,不是给调用方塞整个请求体的地方。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="16"):
|
||||
validate_caller_dimensions(None, {f"k{i}": "v" for i in range(17)}, origin="test")
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a"], {"a": 1}, None, object()])
|
||||
def test_non_scalar_meta_value_rejected(self, bad):
|
||||
"""只收扁平标量(OTel AnyValue 的可移植子集);嵌套让调用方自己序列化。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="值"):
|
||||
validate_caller_dimensions(None, {"k": bad}, origin="test")
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_non_finite_float_rejected(self, bad):
|
||||
"""json.dumps 会把它们写成 NaN/Infinity 字面量——非合法 JSON,PG JSONB 拒收。
|
||||
|
||||
放行则调用方的输入错误会在写入层失败、被遥测降级吞成 warning,
|
||||
即"输入错误"静默变成"丢遥测"(Codex 审查推翻了初稿的"不可达"论断)。
|
||||
"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
validate_caller_dimensions(None, {"k": bad}, origin="test")
|
||||
|
||||
def test_too_long_meta_value_rejected(self):
|
||||
"""字符串值上限 256(对齐 Sentry tag / Langfuse 的量级)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
validate_caller_dimensions(None, {"k": "v" * 257}, origin="test")
|
||||
|
||||
def test_absent_dimensions_pass_through(self):
|
||||
"""两者都不传是绝大多数调用点的现状,必须零摩擦放行。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
assert validate_caller_dimensions(None, None, origin="test") == (None, {})
|
||||
|
||||
@pytest.mark.parametrize("good", ["s", 1, 1.5, True, False, 0])
|
||||
def test_scalar_meta_values_accepted(self, good):
|
||||
"""bool 是 int 子类,两者都合法;0/False 不得被真值判断误杀。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
_, meta = validate_caller_dimensions(None, {"k": good}, origin="test")
|
||||
assert meta == {"k": good}
|
||||
|
||||
def test_returns_independent_copy(self):
|
||||
"""调用方复用同一 dict 逐次改值是预期模式,不拷贝会有竞态(同 overlay 决策 E)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
caller_dict = {"batch": "b-42"}
|
||||
_, meta = validate_caller_dimensions("t1", caller_dict, origin="test")
|
||||
caller_dict["batch"] = "b-99"
|
||||
assert meta == {"batch": "b-42"}
|
||||
|
||||
|
||||
class TestChatRequestDimensions:
|
||||
"""ChatRequest 承载维度的字段契约(issue #11)。"""
|
||||
|
||||
def test_defaults_are_absent_dimensions(self):
|
||||
"""新字段必须带默认值——三项目逐字段构造的 fake 才能零改动(ARCH §5.1 约定①)。"""
|
||||
request = ChatRequest(messages=[{"role": "user", "content": "x"}])
|
||||
assert request.tenant_id is None
|
||||
assert request.meta == {}
|
||||
|
||||
def test_dimensions_are_carried(self):
|
||||
"""维度随请求在洋葱内流转,是缓存 key 之外三个遥测入口的共同读取点。"""
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
tenant_id="t1",
|
||||
meta={"batch": "b-42"},
|
||||
)
|
||||
assert request.tenant_id == "t1"
|
||||
assert request.meta == {"batch": "b-42"}
|
||||
|
||||
Reference in New Issue
Block a user