Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab00aa4457 | |||
| 433039be79 | |||
| b4812e12c8 | |||
| 067b15be48 | |||
| 7b2f6105f3 | |||
| 393f2bf617 | |||
| 87c261bf73 | |||
| 300ced5dbd | |||
| a81cc91124 | |||
| e71a623b04 | |||
| 0c1165965f | |||
| af57f93adc | |||
| dae12f9a16 | |||
| b7e6943497 | |||
| 7f6a824e79 | |||
| 3eb22d2a55 | |||
| d332287b28 | |||
| 73008ad7d5 | |||
| 16fa0ca474 | |||
| c710c3a7ec | |||
| a0a33c0c01 | |||
| 47488ee4fd | |||
| d0078c1be5 | |||
| 71f1bdf26b | |||
| 8e61a66342 | |||
| 1ee74c35a8 | |||
| 4ed144c9e4 | |||
| dda55567ae | |||
| 2553fc7f34 | |||
| 6a090541be | |||
| 758a127f06 | |||
| a716f12483 | |||
| 9021425875 | |||
| bb9ef038c7 | |||
| 85892fb1b5 | |||
| e9607b2f0c | |||
| a194f4326e | |||
| f5e6fafe8d | |||
| f9b357b9d7 | |||
| 4866e6b858 | |||
| 32b92a8894 | |||
| e06cd8e8b7 | |||
| 9832dcee63 | |||
| bd9da4c911 | |||
| 848dc0aa7f | |||
| 5dfb15e6a2 | |||
| d1b3563183 | |||
| 468af53f51 | |||
| 81a901144e | |||
| 1a35d515d9 | |||
| 701a8a6841 | |||
| 78a578bf44 | |||
| 33c8e8274b | |||
| 80a8013642 | |||
| 1f13eb18ab | |||
| 603a835f60 | |||
| ed563b9ca0 | |||
| a1c4273a8b | |||
| 84230673b9 | |||
| 3cb5331950 | |||
| 7fabc792b2 | |||
| 2a50ddcf12 | |||
| de261e485d | |||
| e01420178f | |||
| abeb09f588 | |||
| 862fc3f5a9 | |||
| c920ab4b83 | |||
| 5577812a16 | |||
| 6ec9ec7056 | |||
| 5255f68900 | |||
| 58c4af28ea | |||
| bc0fcc4719 | |||
| ea9e5062e8 | |||
| c8746b1ca1 | |||
| 503c06327e | |||
| 064f22a0a0 | |||
| ea791c9f30 | |||
| 965938230a | |||
| 2bff962e48 | |||
| 6e205e9382 | |||
| 1307a02b92 | |||
| c0b544d233 | |||
| 578a144231 | |||
| 1921a067a1 | |||
| bd95a05c30 | |||
| 758229bda9 | |||
| 56acb8f3ac | |||
| ab1c47ebcc | |||
| 20a4a9ae47 | |||
| 3e869b9b39 | |||
| 8c5c23ae72 | |||
| 59d2e442e6 | |||
| a2b319f250 | |||
| 7622eb0402 | |||
| e90bb3d6a4 | |||
| 85bcc23a6b | |||
| 626bbdcc83 | |||
| 5cf225481c | |||
| e03b2afd8c | |||
| 37b4a557c2 | |||
| f5cf69a1ac | |||
| ef13ca7ea9 | |||
| 8e66a362f7 | |||
| 15f0c16782 | |||
| 28e0ea2442 | |||
| 1fb02a24e9 | |||
| 6d6b3cf59c | |||
| f90f7b036c | |||
| 9026acd7dc | |||
| 4e1f09d231 | |||
| 7834d751d0 | |||
| 69a5b5fadb | |||
| bfeda5b5e9 | |||
| eef2fdc5df | |||
| bc071c6f41 | |||
| 84c2cc11a4 | |||
| f958138e83 | |||
| e69ca4c82c | |||
| e7caa500e2 | |||
| 157a27f3bb | |||
| 59a4bc3d14 | |||
| 620b426ede |
+43
-2
@@ -2,7 +2,8 @@
|
|||||||
# 键名清单 = M1 设计文档 §8 定稿;缺关键配置直接报错,不做默认值兜底。
|
# 键名清单 = M1 设计文档 §8 定稿;缺关键配置直接报错,不做默认值兜底。
|
||||||
|
|
||||||
# ══ 多源配置: {SCOPE}__{PROVIDER}__{N}__{FIELD} ══
|
# ══ 多源配置: {SCOPE}__{PROVIDER}__{N}__{FIELD} ══
|
||||||
# PROVIDER 必须是注册表键(qwen/deepseek/openai,或 register_provider 注册后经 registry 传入)。
|
# PROVIDER 必须是注册表键(八段: qwen/deepseek/zhipu/moonshot/minimax/openai/anthropic/google,
|
||||||
|
# 或 register_provider 注册后经 registry 传入)。
|
||||||
# 必填: BASE_URL / API_KEY / MODEL / TIMEOUT_S(或用平铺 LLM_TIMEOUT 作缺省)。
|
# 必填: BASE_URL / API_KEY / MODEL / TIMEOUT_S(或用平铺 LLM_TIMEOUT 作缺省)。
|
||||||
LLM__QWEN__1__BASE_URL=
|
LLM__QWEN__1__BASE_URL=
|
||||||
LLM__QWEN__1__API_KEY=
|
LLM__QWEN__1__API_KEY=
|
||||||
@@ -15,7 +16,21 @@ LLM__QWEN__1__TIMEOUT_S=120
|
|||||||
# LLM__QWEN__1__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生
|
# LLM__QWEN__1__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生
|
||||||
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
|
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
|
||||||
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
|
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
|
||||||
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
|
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不表态 / true=要求开启 / false=要求关闭
|
||||||
|
# 本键是语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态。
|
||||||
|
# 已登记模型须清单含 AUTO 才接受 true;nearest 不代选强度。
|
||||||
|
# 未登记仍尽力+warning,空 wire 可能零推理字节,不保证开启。
|
||||||
|
# M3 删除糖并选 medium 等表内档;M2.5/M2.7 AUTO 不再偷带 medium。
|
||||||
|
# 完整 M1–M9 与缺测见 README「1.3.4 推理配置迁移」。
|
||||||
|
# 有受管意图时 EXTRA_BODY/overlay 推理控制同值也拒绝;raw-only 须退出所有意图。
|
||||||
|
# LLM__QWEN__1__REASONING_EFFORT=auto # 本源默认推理档位;缺省=不表态(随模型自己的默认档)
|
||||||
|
# 八档(封闭词汇): none | auto | minimal | low | medium | high | xhigh | max
|
||||||
|
# none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度
|
||||||
|
# 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low),
|
||||||
|
# 不做"后者赢"的静默兜底——两个键说同一件事,矛盾就是配置错误
|
||||||
|
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
|
||||||
|
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
|
||||||
|
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
|
||||||
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
|
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
|
||||||
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
||||||
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
||||||
@@ -71,6 +86,26 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
|||||||
# # sqlite 则是下游自己的本地文件(runs/*.db):没有 DBA、没有迁移工具、
|
# # sqlite 则是下游自己的本地文件(runs/*.db):没有 DBA、没有迁移工具、
|
||||||
# # 没有第二个系统碰它,ALTER 是毫秒级元数据操作,强加手工 SQL 步骤是净损失。
|
# # 没有第二个系统碰它,ALTER 是毫秒级元数据操作,强加手工 SQL 步骤是净损失。
|
||||||
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
|
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
|
||||||
|
# PGW_TELEMETRY_PG_POOL_MAX=4 # postgres 遥测池的连接上限,须 >= 1;缺省 4。**闲时占 0 条**——
|
||||||
|
# # 池按需建连(min_size=0),不预占;这一格是忙时的天花板,不是常驻量。
|
||||||
|
# # 调参口径(以实测为准,不要按 pool_max/RTT 估算):跨内网 RTT ≈ 123ms 的
|
||||||
|
# # 实验室 PG 上,pool_max=4 实测约 **15.6 行/秒**(50 行并发批耗时 3.2s),
|
||||||
|
# # 即每条连接约 4 行/秒 —— 一次 INSERT 的实际往返比一次 `SELECT 1` 重一倍,
|
||||||
|
# # 按单次 RTT 估会乐观一倍。要放大就按这个实测值线性折算(pool_max=8 ≈ 31 行/秒)。
|
||||||
|
# # 缺省 4 在缺省 5s 预算下能吞下约 50 行的突发(余量约 1.5 倍);超预算的行被丢弃
|
||||||
|
# # 并计入 telemetry_status.dropped_rows —— 丢一条遥测好过拖垮业务调用。
|
||||||
|
# # 注意告警口径: 池饱和丢的行走**行级丢弃**,telemetry_status.degraded 保持
|
||||||
|
# # False(后端并没有挂,是本进程并发超了),只有 dropped_rows 增长。只按
|
||||||
|
# # degraded 告警会完全看不见这一类丢行 —— 对账要两个字段一起看。
|
||||||
|
# # 什么时候该调大: 单进程遥测写入并发经常超过 4(高频短调用、批量并发),
|
||||||
|
# # 或多个 client 显式共享同一个 recorder(并发在这里汇聚,应按 client 数放大)。
|
||||||
|
# PGW_TELEMETRY_PG_WRITE_TIMEOUT_S=5.0 # 一次遥测写入的硬预算(秒),须 > 0;缺省 5.0。同时用作建连、
|
||||||
|
# # acquire 与「准备 + 取连接 + 执行」整段的上界:超时即丢弃该行,
|
||||||
|
# # 绝不让遥测无界地挂在业务路径上。实测参考: 稳态写入 123ms、
|
||||||
|
# # 首次写入含建连 513ms —— 5s 对正常路径是极宽松的上限,它防的是
|
||||||
|
# # 池满排队与后端假死这类"不会自己结束"的等待。
|
||||||
|
# # 与之配套的两个不可配内部常量: 连接释放上界 1s(超时即 terminate)、
|
||||||
|
# # 环境级降级的冷却期 60s(到期自动重试一次,成功即恢复)。
|
||||||
# PGW_TELEMETRY_TEXT_CAP=2000 # 遥测落库正文的字符上限,须 > 0;**不设 = 不截断**(缺省,逐字节留全文)。
|
# PGW_TELEMETRY_TEXT_CAP=2000 # 遥测落库正文的字符上限,须 > 0;**不设 = 不截断**(缺省,逐字节留全文)。
|
||||||
# # 作用于 messages 的每条文本 content、多模态 text part、response 与 thinking;
|
# # 作用于 messages 的每条文本 content、多模态 text part、response 与 thinking;
|
||||||
# # 超出部分头部保留、尾部换成 `…(略 N 字)`。多模态 image_url 的 sha256 摘要不受影响。
|
# # 超出部分头部保留、尾部换成 `…(略 N 字)`。多模态 image_url 的 sha256 摘要不受影响。
|
||||||
@@ -78,9 +113,15 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
|||||||
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
|
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
|
||||||
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
|
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
|
||||||
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
|
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
|
||||||
|
# # 1.3.5 补充: error_body(网关响应正文摘要)沿用库内 summarize_body 上限,
|
||||||
|
# # 结构化耗尽终态行的 error 说明自带有界限长(不含模型原始正文);
|
||||||
|
# # **两者都不在本键的覆盖面内**,估算留存面时要单独计。
|
||||||
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
||||||
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
|
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
|
||||||
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
|
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
|
||||||
|
# 推理语义/fallback/能力表/wire 变化前须换从未使用的新 namespace 或 salt。
|
||||||
|
# 保留租户前缀与 epoch;per-call 覆盖也要迁移,只改此处无效。
|
||||||
|
# 未迁移仍可回放旧语义并绕过新拒绝;回滚旧身份会重见旧值,库不自动隔离。
|
||||||
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
|
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
|
||||||
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
|
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
|
||||||
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
|
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
|
||||||
|
|||||||
+317
-7
@@ -1,5 +1,316 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.3.5(2026-09-09)
|
||||||
|
|
||||||
|
把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19、#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。
|
||||||
|
|
||||||
|
### 公共面四项变更
|
||||||
|
|
||||||
|
| # | 位置 | 变更 | 谁会当场断 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `polygateway.CallStats` | 新导出的 frozen dataclass(`logical_call_id` / `attempts` / `total_latency_ms`) | 无(纯新增) |
|
||||||
|
| 2 | `LLMResponse` / `EmbeddingResponse` / `OcrTextResult` / `OcrLayoutResult` | 各追加**末尾**字段 `call_stats: CallStats \| None = None` | 按位置解包这四个类型的代码 |
|
||||||
|
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增 **10 个无默认值 keyword-only 参数**(26 → 36 参) | 任何自建 recorder——且**在装配期当场报错**,不再是运行期静默丢行 |
|
||||||
|
| 4 | 遥测表 `llm_calls` | 追加 10 列(INSERT 字段 26 → 36,物理列 27 → 37);新增 `event_kind='terminal_failure'` 行 | 按旧口径计失败调用数的 SQL |
|
||||||
|
|
||||||
|
第 3 条的装配期报错是有意的:`_record` 的 `except Exception` 会把旧签名 recorder 的 `TypeError` 吞成 warning,后果是下游升级后 **100% 丢遥测且调用照常成功**。降级方向的铁律管的是运行期写失败,不是装配错误。`**fields` 形态的 recorder 不受影响。
|
||||||
|
|
||||||
|
### 下游必须做的事
|
||||||
|
|
||||||
|
| 动作 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| **计失败调用改 `WHERE event_kind = 'terminal_failure'`** | 一次逻辑调用恰好一条终态行。`error IS NOT NULL` 跨尝试行与终态行,升级后计数会变大 |
|
||||||
|
| `AVG(latency_ms)` 按 `event_kind` 分组 | 终态行的 `latency_ms` 是整个逻辑调用的总耗时,与单次尝试不同量纲 |
|
||||||
|
| 自建 recorder 补齐 10 参 | 或改成 `**fields`;同时同步自己的 schema 与 INSERT 字段 |
|
||||||
|
| PG manual 档下游补列 | `telemetry_schema_sql("postgres")` 自取;不补则这 10 个维度按现有列裁剪后静默不落库(库发一条点名警告) |
|
||||||
|
| 注意失败行可能带 `http_status_code = 200` | MonkeyOCR 的 `success != true` 就是 200 下的失败,该列不可当成败判据 |
|
||||||
|
|
||||||
|
费用口径**不变**:终态行 `cost IS NULL`、`usage_source='unavailable'`、token 为 0,不参与 `SUM(cost)`。400 密集负载下错误行会翻倍(既有 attempt 错误行 + 新增终态行),这是已批准的下游可见变化。
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- 新列全部可空、无默认值、追加在现有末列之后;**旧行不回填**(NULL 表达「补列之前根本没记过这件事」),滚动升级期新旧进程可写同一张表。
|
||||||
|
- `error_body` 沿用 `summarize_body` 上限,结构化耗尽终态行的 `error` 带有界说明(不含模型原始正文);**两者都不在 `PGW_TELEMETRY_TEXT_CAP` 覆盖面内**。
|
||||||
|
- 修正历史误标:`embed()` 非 200 的失败现在报 `operation="embedding"`。遥测新列 `operation` 由调用点给定,与 `exc.operation` 是两个语义。
|
||||||
|
- 重试预算与退避、429 免预算与 stall 算法、取消结算、限流/熔断语义、缓存 key 公式一字未改。
|
||||||
|
|
||||||
|
## 1.3.4(2026-09-09)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **patch 版号不代表无迁移成本。** 已登记但不含 AUTO 的 True/auto 配置及受管+raw 双来源现在明确拒绝;受影响缓存必须在首次新语义读写前显式换 namespace/salt。升级步骤见 [README 迁移节](README.md#134-推理配置迁移)。
|
||||||
|
|
||||||
|
- **推理意图**:已登记 AUTO 必须为能力清单成员,True 糖同约束;nearest 不代选强度。MiniMax on_base 改空,M3 要显式选 medium 等登记档;M2.5/M2.7 空 wire 流式 AUTO 各5轮定向复验通过,不代表全矩阵覆盖。未知仍尽力+warning,不保证开启。
|
||||||
|
- **所有权**:受管意图下两层 raw 推理控制同值/被遮蔽也拒绝;raw-only 与普通采样浅覆盖保留。自定义 on_base 禁止偷带强度。
|
||||||
|
- **缓存迁移前置**:受影响调用更换从未承载旧语义的 namespace/salt;同版本 fallback、能力表、wire 变化亦需迁移。不加自动指纹,未迁移仍可回放旧语义。M1–M9、per-call/多源/回滚示例见 README。
|
||||||
|
- **测试证据**:默认 FAIL,不整类 skip;404 仅完整独立证据可未覆盖,公共身份缺失无独立证据 FAIL。逐轮脱敏,UNKNOWN/SKIP/缺轮不算关闭覆盖;不可关闭与预期拒绝独立判定。缺型号级 400 机器字段基线仍 FAIL,不编造白名单。
|
||||||
|
- **遥测守卫**:真实客户端/临时 SQLite 的 embedding、OCR 双入口成败 NULL、chat 阳性与四种行来源回归;不新增 schema、生产端口或成功 SSE 捕获器。
|
||||||
|
|
||||||
|
**验收例外**:2026-09-09 用户正式批准不再补全模型矩阵,失败/UNKNOWN/不可达/缺轮及缺下游现行配置证据作为本版例外保留,不冒称 PASS。embedding 实测 503 不符合严格404未覆盖条件,仍为 FAIL;claude-opus-5 开启档位命题仍 FAIL,不能把 HTTP 200 当推理开启证明。独立审查、红绿/变异、日常与定向实测均按适用范围复用;具体失败、网络诊断及原始证据见[验证记录](research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md)。该例外不取消下游缓存迁移前置,也不代表合并后检查、上传或外部包验收已执行。
|
||||||
|
|
||||||
|
## 1.3.3(2026-09-05)
|
||||||
|
|
||||||
|
推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。
|
||||||
|
|
||||||
|
**版号是 patch(2026-09-05 人类指令,不因破坏性变更走 minor),但本版含五处破坏性变更与四条行为变更。** patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故全部置于最前。
|
||||||
|
|
||||||
|
### 请先读这一条(一):五处破坏性变更
|
||||||
|
|
||||||
|
| # | 位置 | 变更 | 谁会当场断 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `ThinkingCapability` | 构造签名 `can_disable: bool` → `supported_efforts: tuple[Effort, ...]` | 自建能力表的调用方(**关键字与位置两种构造都断**) |
|
||||||
|
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
|
||||||
|
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort`(25 → 26 参) | 任何自建 recorder 实现 |
|
||||||
|
| 4 | `thinking.resolve_thinking()` | 第三参数由 `bool` 换成 `Effort`,**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 直调它的读侧代码一律断 |
|
||||||
|
| 5 | `providers.ProviderProfile` | 两个字段 `thinking_on` / `thinking_off` → 单字段 `thinking: ThinkingWire` | 自建 profile 的调用方 |
|
||||||
|
|
||||||
|
第 1 条的 `can_disable` **保留为只读派生属性**(`Effort.NONE in supported_efforts`),只读它的代码一行不用改;**构造则两种写法都断**:
|
||||||
|
|
||||||
|
| 1.3.2 的写法 | 升级后 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ThinkingCapability(can_disable=True, evidence="…")`(库自己那张表用的就是它) | `TypeError: ... got an unexpected keyword argument 'can_disable'` |
|
||||||
|
| `ThinkingCapability(True, "…")` | `TypeError: 'bool' object is not iterable`——断在 `__post_init__` 的去重校验里,错误信息看不出真实原因 |
|
||||||
|
| 迁移写法 | `ThinkingCapability(supported_efforts=(Effort.NONE, Effort.AUTO), evidence="…")` |
|
||||||
|
|
||||||
|
第 2、3 条按这两个端口的既有纪律**不设默认值**:库外没有第三方实现者,带默认值只会让漏传时静默落一个默认值。第 4 条的新返回值是 `ThinkingResolution(payload, applied_effort)`——原来那个 mapping 现在是 `.payload`,多出来的 `.applied_effort` 是开了 `nearest` 映射后**真正发出去**的那一档。
|
||||||
|
|
||||||
|
### 请先读这一条(二):不改一行代码也会变的四条行为
|
||||||
|
|
||||||
|
| # | 变更 | 影响 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `glm-5.3` / `glm-5.3-flash` / `gemini-3.1-pro` **首次进入能力表**,且三者都登记为**关不掉推理** | **本版唯一会打断存量配置的一条。** 1.3.2 里这三个型号未登记,给它们配 `ENABLE_THINKING=false` 会按 provider 形态尽力注入并**放行**(只发一条 warning);本版在**装配期**抛 `ThinkingUnsupportedError`。并排实测:`deepseek/glm-5.3 + ENABLE_THINKING=false` 在 1.3.2 返回 `{"thinking": {"type": "disabled"}}`,在本版当场报错 |
|
||||||
|
| 2 | `openai` 段的**开启**方向由「形态未知即装配期报错」放宽为 `on_base={}` | 把任意兼容厂商挂在 `openai` 段下并配 `ENABLE_THINKING=true` 的下游:1.3.2 在装配期报错,本版放行且**一个字节都不注入**——走模型自己的默认档。若该模型默认不推理,这个配置既不报错也不开推理(见下方「已知限制」) |
|
||||||
|
| 3 | `openai` 段的**关闭**方向由「形态未知即装配期报错」放宽为 `{"reasoning_effort": "none"}` | 同上但配 `ENABLE_THINKING=false` 的下游:1.3.2 在装配期报错,本版下发这个片段。放宽的依据是 `reasoning_effort` 是 OpenAI **官方**字段而非厂商方言,经网关的兼容端点不会把它打到不认识它的厂商 |
|
||||||
|
| 4 | 缓存 key 加入 `reasoning_effort` | 只有**新配** `REASONING_EFFORT` 的源冷启动一次;只配 `ENABLE_THINKING` 或什么都没配的源,key 字面量逐字不变(已按 1.3.2 的实现逐字比对) |
|
||||||
|
|
||||||
|
第 1 条是设计上有意为之:调用方要的是「不推理」的语义保证,给不了就必须说,而不是让它继续静默烧推理 token——升级后当场失败,正是这三个型号本来就关不掉推理的证据。报错文案带一条能立刻照做的替代(该模型最省的那一档 + 该配的 env 键名),不把人推回 `extra_body` 那条绕过库的路。
|
||||||
|
|
||||||
|
**`qwen` / `deepseek` / `minimax` 三段的注入形态逐字未变。** 全量比对(4 个 1.3.2 已有的 provider 段 × 25 个模型 × `ENABLE_THINKING` 三态 = 300 种组合)显示,本版与 1.3.2 的差异**只有上表第 1、2、3 条**。`minimax` 的「开」尤其值得点名:它维持 `{"reasoning_effort": "medium"}` 逐字不变,因为真实网关实测显示 MiniMax-M3 在不带任何推理参数时**不推理**(5/5 轮),把它改成「不注入即为开」会让存量 `ENABLE_THINKING=true` 的调用静默停止推理。
|
||||||
|
|
||||||
|
### 新增能力
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 八档 `Effort`:`none` / `auto` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max` | 封闭词汇,取四家参考实现共同收敛的那一套。`none` = 要求不推理(与「不表态」是两回事),`auto` = 要求推理但不指定强度 |
|
||||||
|
| `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT` | 源级默认档。`ENABLE_THINKING` 保留,降为它的语法糖(`true` ≡ `auto`、`false` ≡ `none`、缺省 ≡ 不表态);两键语义矛盾(如 `true` + `none`)在**装配期**报错,不做「后者赢」的静默兜底 |
|
||||||
|
| `{SCOPE}__{PROVIDER}__{N}__EFFORT_FALLBACK` | `error`(缺省,报错)或 `nearest`(映射到最近档并 warning)。默认报错的理由是钱:一次静默的 `medium → max` 在部分模型上是数倍账单 |
|
||||||
|
| `chat(reasoning_effort=...)` | 请求级覆盖,优先级高于源级;裸字符串会在入口归一 |
|
||||||
|
| `LLMResponse.applied_effort` | 本次**实际**跑在哪一档(开了 `nearest` 时与请求档分叉)。字段追加在末尾,既有字段只增不改名 |
|
||||||
|
| 四个新 provider 段 `zhipu` / `moonshot` / `anthropic` / `google` | 连同 1.3.2 已有的 `qwen` / `deepseek` / `minimax` / `openai` 共**八段**。四段都是新增,不改变任何存量配置的行为 |
|
||||||
|
| 能力表由 **5 条扩到 24 条** | 1.3.2 只登记 5 个型号,其余一律走「按 provider 形态尽力注入 + warning」。本版新登记 19 个:qwen 4 款、deepseek 2 款、GLM 6 款、kimi 2 款、gpt 2 款、claude 2 款、gemini 1 款 |
|
||||||
|
| `kimi-k3` **首次登记**为可关闭 | 它在 1.3.2 未登记(配 `false` 走尽力注入 + warning,不报错)。本版实测坐实可关:请求 `none` 后短提示词 5/5 轮 + 长上下文 3/3 轮无任何推理信号、completion 恒 9 token,与同模型 max 档(rt 33-146)的锚点可分。两源分歧由此了结——OpenRouter 的 `mandatory:false` 是对的,官方档位表没列 `none` 只是没列 |
|
||||||
|
| 包根新增导出 `Effort` / `EFFORT_ORDER` / `ThinkingWire` / `ThinkingResolution` | 深路径 import 会被内部重组打断,一律从 `polygateway` 包根取 |
|
||||||
|
|
||||||
|
档位不支持时**报错必带可执行替代**:模型关不掉推理时,错误文案直接给出该模型最省的那一档和该配的 env 键名。只报错不给出路,下游只会退回 `extra_body`——而那正是 issue #20 的成因。
|
||||||
|
|
||||||
|
### 遥测:第 26 个 INSERT 字段 `reasoning_effort`
|
||||||
|
|
||||||
|
`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27)。列可空,`NULL` 表示调用方**没表态**;它与 `'none'`(明确要求不推理)是两回事,折叠成任一档都等于替上游声称了一件它没说过的事。加这一列是为了让「不同档位是不是真有用」这类压测在数据侧能分组——此前 25 列里没有任何一列能回答「这一行跑在哪档」。
|
||||||
|
|
||||||
|
**成功行与失败行不是同一把尺子。** 开了 `EFFORT_FALLBACK=nearest` 的源上,成功行记的是**映射后的实发档**(读 `response.applied_effort`);失败尝试没有响应、实发档无从得知,记的是**请求档**。故 `GROUP BY reasoning_effort` 不带 `error IS NULL` 时,两种尺子会混进同一个分组。缓存命中行与终态失败行同样只记请求档——它们手上没有选中源,源级档位与 `nearest` 映射都无从谈起。embedding / OCR 两条路径没有推理语义,该列恒 `NULL`。
|
||||||
|
|
||||||
|
补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE`,两端 DDL 与 `COLUMNS` 同源。**manual 档的下游会看到一处文案变化**:旧表的缺列告警会多点名 `reasoning_effort` 这个维度,并附上对应的 `ALTER TABLE ADD COLUMN` 语句。
|
||||||
|
|
||||||
|
### 能力表口径:24 条里 17 条经 new-api 实测、7 条仍是文档推定
|
||||||
|
|
||||||
|
`DEFAULT_CAPABILITIES` 共 24 条,每条 `evidence` 自报家门(实测日期、轮数 N、判据、锚点,或「文档推定」及其四方出处)。**读能力表请以逐条 evidence 为准,本版不存在「能力表已全部实测」这回事。** 未能实测的 7 条与原因:
|
||||||
|
|
||||||
|
| 模型 | 未覆盖的原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| `claude-opus-5`、`claude-sonnet-5` | 该渠道 claude 全系返回 429「api key 7 天限额已用完」,5/5 轮失败;`none` 档还额外依赖网关把 `reasoning_effort=none` 转成 `thinking` 关闭形态,同样未经验证 |
|
||||||
|
| `gemini-3.1-pro` | 该渠道本型号上游报错(`bad_response_status_code` / `openai_error`),5/5 轮失败,连默认档基线都没取到。默认档「官方文档说 high、OpenRouter 说 medium」两源打架**仍未决**,本版不选边 |
|
||||||
|
| `gpt-5.4` | 全账号限流(429 All available accounts are currently rate-limited),5/5 轮失败。同代的 `gpt-5.5` 已实测且与清单逐字相符,可作旁证但不是本型号的证据 |
|
||||||
|
| `glm-5`、`glm-5.1`、`glm-5.2` | 请求这三个型号时,渠道 5/5 轮把流量路由到 `glm-5.3`(issue #20 记录的 6/6 复现);拿到的行为不属于本型号,整组数据作废 |
|
||||||
|
|
||||||
|
`glm-5.2` 的下游风险要单独说:在这条渠道上给它配 `none`,库会照文档推定放行,而真正服务请求的 `glm-5.3` **关不掉推理**;运行期对账会喊,但那是事后。
|
||||||
|
|
||||||
|
另有两条与实测相关的收获值得下游知道:同一批实测发现 `zhipu` / `moonshot` 这条渠道**不校验档位值**(未登记的 `medium` 也照单收下并返回 200),故「网关没报错」在这两家上**不构成**「该档受支持」的证据;而 `openai` 那条会校验(清单外的 `max` / `minimal` 被上游 400 拒)。
|
||||||
|
|
||||||
|
### 已知限制:`auto` 不等于「强制开推理」(issue #21)
|
||||||
|
|
||||||
|
`reasoning_effort=auto`(含它的语法糖 `ENABLE_THINKING=true`)在 `on_base={}` 的三个 provider 段(`openai` / `anthropic` / `google`)上表达的是「**用模型自己的默认档**」,库不注入任何字节。若某模型默认就不推理,这个配置**既不报错也不开推理**。正解是让 `auto` 受能力表约束——模型不支持「由模型自定」时报错并指路显式档位,属公共行为变更,留到下一版(gitea issue #21)。
|
||||||
|
|
||||||
|
与之相连有一处**刻意的不一致**,请勿误读:`DEFAULT_CAPABILITIES` 里 `MiniMax-M3` 的 `supported_efforts` **不含 `auto`**(实测结论——它的默认档不推理),而 `minimax` 段的 wire 会为 `auto` 注入 `{"reasoning_effort":"medium"}` 并被放行。`resolve_thinking` 的 Phase 5 对 `auto` 无条件放行(`auto` 不是写进 `effort_key` 的取值,而是「不写 `effort_key`」),**能力表拦不住这条路**;当前是由 wire 侧的权宜之计兜住的。别把它读成「能力表能挡住 auto」。
|
||||||
|
|
||||||
|
## 1.3.2(2026-08-28)
|
||||||
|
|
||||||
|
**本版不改库代码。** `tools/` 与 `tests/` 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 **除版本号外没有任何差异**(`__version__` 与包元数据是唯一的改动)。升级它不会改变任何库行为——本版的内容是运维脚本 `tools/telemetry_retention.py` 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。
|
||||||
|
|
||||||
|
### 运维脚本:`--table` 让删除目标不再由连接环境决定(issue #18)
|
||||||
|
|
||||||
|
`tools/telemetry_retention.py` 此前删哪张表,取决于连接的 `search_path`——它的首项是 `"$user"`,所以**换个角色跑同一条命令,目标可能就换了一张表**。脚本会把解析到的限定名打出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
|
||||||
|
|
||||||
|
新增可选参数 `--table <schema>.llm_calls`:给了它,目标由参数精确解析(`to_regclass` 走引号限定名),绕开 `search_path`。
|
||||||
|
|
||||||
|
| 情形 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 不给 `--table` | **与 1.3.1 完全一致**,现有 cron 不受影响;但 `--apply` 时会多打印一行,提示目标是推断来的 |
|
||||||
|
| 表名段不是 `llm_calls` | 退出 **1**。本脚本只清理遥测表,不是通用清理器——一次 `--table audit.events` 的手误,会对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE |
|
||||||
|
| 显式指定的表不存在/不可见 | 退出 **2**,消息附一句"PG 中未加引号建的标识符在 catalog 里是小写"(大小写手误是这里的高频原因) |
|
||||||
|
| 显式指定的是分区表 | 仍退出 **3** 让路给 `DROP PARTITION`,语义未变 |
|
||||||
|
|
||||||
|
退出码契约未新增也未改动。**建议 cron 一律带上 `--table`**:那一行配置从此自己说明删的是哪张表。
|
||||||
|
|
||||||
|
### 测试隔离:从"事后观测共享表"改成"权限上做不到"
|
||||||
|
|
||||||
|
issue #18 报的是一条 PG 集成测试偶发红。查下来失败的断言并不在测被测脚本——它比对的是一张**三个迁移项目也在写**的表的前后行数,而报错时(61 行变 12 行)脚本本身被证明只动了自己的临时 schema。
|
||||||
|
|
||||||
|
行数快照承载不了它想守的属性:别人一写就假红,而外部插入恰好抵消掉一次误删时又会假绿——后一半守的正是"审计表被删空"。现在这条属性交给数据库强制:跑脚本的测试角色拥有自己的临时表、对共享表**没有任何授权**,`search_path` 万一落空就是 `permission denied` 而不是"但愿有断言发现"。共享表 `llm_calls` 至此不再被本仓库任何测试读写,killed 的测试也不会再往里留孤儿行。
|
||||||
|
|
||||||
|
对下游没有影响(测试不进包),列在这里是因为它解释了本版为何存在。
|
||||||
|
|
||||||
|
## 1.3.1(2026-08-26)
|
||||||
|
|
||||||
|
「这次调用到底推理没推理」从此是库的**一等返回值**(issue #16 + #17): `LLMResponse.thinking_observation` 三态如实作答,判不出来时说 `unknown` 而不是伪装成「没推理」,并与推理能力表持续对账。
|
||||||
|
|
||||||
|
**版号是 patch,但本版含三处会影响下游的变更**——深路径 import 断裂、端口签名扩参、一条新告警。patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故三条置于最前。
|
||||||
|
|
||||||
|
### 请先读这一条(一): `polygateway.providers` 的深路径 import 断了
|
||||||
|
|
||||||
|
推理相关的**六个符号**从 `providers.py` 移进新模块 `polygateway.thinking`。`from polygateway.providers import ...` 引用其中任何一个,升级后当场 `ImportError`:
|
||||||
|
|
||||||
|
| 从 `providers` 断掉的符号 | 改成(**推荐**) | 或 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ThinkingCapability`、`ThinkingUnsupportedError` | `from polygateway import ...` | `from polygateway.thinking import ...` |
|
||||||
|
| `get_capability`、`register_capability`、`resolve_thinking` | `from polygateway import ...` | `from polygateway.thinking import ...` |
|
||||||
|
| `DEFAULT_CAPABILITIES` | `from polygateway.thinking import DEFAULT_CAPABILITIES` | — |
|
||||||
|
|
||||||
|
**前五个请改用包根 import**: 它们此前只能深路径引用,而深路径引用正是模块重组会打断下游的原因——本版一并把它们提升到包根导出(连同本版新增的 `ThinkingObservation`,共六个新导出),给的就是一个此后不会因内部重组而变的引用点。`DEFAULT_CAPABILITIES` 有意不进包根: 它是可变注册表的当前快照,不是稳定 API 面。
|
||||||
|
|
||||||
|
`providers.py` 保留的 `ProviderProfile` / `DEFAULT_PROFILES` / `get_provider` / `register_provider` 逐字未动。
|
||||||
|
|
||||||
|
拆分本身不是顺手重构: 推理这件事从「请求侧注入什么参数」长成了「注入 + 响应侧裁定 + 两者对账」三件事,再留在 provider 注册表里,那个文件的职责就得用「和」来描述。
|
||||||
|
|
||||||
|
### 请先读这一条(二): `TelemetryRecorder.record_llm_call` 从 24 参变 25 参
|
||||||
|
|
||||||
|
新增 keyword-only 参数 `thinking_observation: str`,**且按该 Protocol 的既有纪律不设默认值**(库外没有第三方实现者,带默认值只会让 emitter 漏传时静默落一个默认值)。**自定义 recorder 实现必须同步补这个参数**,否则调用时 `TypeError`。库自带的 `SQLiteRecorder` / `PostgresRecorder` 已同步,不受影响。
|
||||||
|
|
||||||
|
`TelemetryRecorder` 之外的端口逐字未变;`TelemetryStatusProvider` 不受影响。
|
||||||
|
|
||||||
|
### 请先读这一条(三): MiniMax-M3 非流式开推理 = 付费买看不见的推理,库现在会说出来
|
||||||
|
|
||||||
|
2026-08-25 实测: M3 非流式开启推理时 `completion_tokens` 从 3 涨到 53(推理段确实产生并计费),而响应里既没有 `reasoning_content` 正文、也没有 `usage.completion_tokens_details`——**钱花了,东西一个字都拿不到**。这是上游行为,库修不了,但从本版起不再默不作声: 该档观测判为 `unknown`,并按 `(模型, 方向)` 发**一次** warning,说明「已注入开启参数,但本路径观测不到,推理内容可能已计费却不回传」。
|
||||||
|
|
||||||
|
要拿到推理正文,该模型请走**流式**路径(实测 185 字符正文完整)。
|
||||||
|
|
||||||
|
### 诊断纠正: 不是模型不推理,是 MiniMax 停报 `completion_tokens_details`
|
||||||
|
|
||||||
|
issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了这个诊断——绕开库用裸 `httpx` 抓真实响应,M3 流式开启档拿到 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
|
||||||
|
|
||||||
|
真正变的是 **MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 deepseek 在同一网关、同一 key 上照常返回),`reasoning_tokens` 因此恒为 `None`。而库把「推理是否发生」全押在这一个字段上,于是**手里握着 185 字符推理正文,却对外报告「没推理」**。
|
||||||
|
|
||||||
|
缺口的形态是本版真正要修的东西: 库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`。
|
||||||
|
|
||||||
|
### 三态,以及它为什么不能折叠成布尔
|
||||||
|
|
||||||
|
`LLMResponse.thinking_observation`(类型 `ThinkingObservation`,`StrEnum`,缺省 `unknown`)由多信号裁定,判据按**证据硬度**排序:
|
||||||
|
|
||||||
|
| 值 | 判据 |
|
||||||
|
| --- | --- |
|
||||||
|
| `observed` | 推理正文 `thinking` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
|
||||||
|
| `absent` | `reasoning_tokens == 0`——上游明确上报本次未推理,是正面证据 |
|
||||||
|
| `unknown` | 两个信号双缺,判不出来 |
|
||||||
|
|
||||||
|
**`unknown` 与 `absent` 不是一回事**,把前者折叠进后者正是本次故障的病根。`unknown` 没有证伪力: 它不能用来声称推理关掉了,也不能用来报警「没推理」。缺省取 `unknown` 使任何填不了这个字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——默认值本身不撒谎。
|
||||||
|
|
||||||
|
对下游的口径变化: 统计「未推理」**不要再写 `reasoning_tokens IS NULL OR = 0`**,那个条件在供应商停报 usage 明细后会把推理了的调用一并算进去。改按 `thinking_observation` 分组,`unknown` 独立成一档。
|
||||||
|
|
||||||
|
### 声明 × 观测对账: 能力表过期从静默错觉变成日志里的告警
|
||||||
|
|
||||||
|
推理能力表(`can_disable`)是静态声明,而静态声明**必然过期**——M3 的 evidence 曾停在 8-02 整整 23 天。过期的表现是静默错觉: 库照常注入关闭参数,模型照常推理,下游拿到推理内容却以为关了,全程无人吭声。
|
||||||
|
|
||||||
|
本版在 transport 拿到结果处做一次比较,矛盾即 warning(**不抛错**——一次观测不足以否决一次成功的调用,矛盾结果已随响应与遥测落地,处置权归下游):
|
||||||
|
|
||||||
|
| 请求方向 | 观测 | 告警内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 关闭 | `observed` | 关闭请求未被满足。能力表已登记则点出 `evidence` 日期并指路复测更新;未登记则说明本次是按 provider 形态尽力注入 |
|
||||||
|
| 开启 | `absent` | 已注入开启参数,上游却明确上报未推理 |
|
||||||
|
| 开启 | `unknown` | 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传 |
|
||||||
|
|
||||||
|
`关闭 × unknown` 与「调用方没提要求」两类**有意不表态**: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 `(源, 模型, 方向)` 只喊一次,文案点名出问题的源——多源多账号下同一模型跨 N 个源是常态,键漏掉源名会让第一个出问题的源喊完之后其余源永久静音,而告警也定位不到该查哪个网关。
|
||||||
|
|
||||||
|
**保障的覆盖面必须说清楚**: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 `observed` 触发告警);M3 非流式那种两个信号双缺的路径,没有任何保障——本版让它可见,但不能让它可判。
|
||||||
|
|
||||||
|
### 遥测新增一列 `thinking_observation`
|
||||||
|
|
||||||
|
`llm_calls` 加一列 `thinking_observation TEXT`(可空,取值 `observed` / `absent` / `unknown`),排在最末,SQLite 与 Postgres 两端 DDL 与补列语句同步。旧表按既有 backfill 路径补列: sqlite→auto 档自动补,postgres→manual 档点名缺列并给出可执行 SQL、同时按现有列裁剪 `INSERT` 继续写(不补列不会让遥测整体失效,只是少这一列)。补列失败仍只逐行降级、绝不判死。
|
||||||
|
|
||||||
|
照 README「生产部署 DDL 模板」部署的下游**不需要改模板**: 那份模板用 `LIKE llm_calls_seed` 从库自己建出的表派生列,与 `telemetry/schema.py` 同源,不存在手抄漂移(本版加了一条测试断言把这个同源性钉死)。
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- 缓存回放的 `thinking_observation` 是 `ThinkingObservation` 枚举实例而非裸字符串: JSON 复活出来的是 `str`,与字段注解分叉,`CacheMW._rehydrate` 现在显式转换。取值不在本版三态值域内时(多个项目共用同一 Redis、先升级的那个写入了新态)**降级为 `unknown` 并单独告警,响应内容照常复活**——一个纯可观测性字段不该有能力作废内容完好的缓存,否则未升级的项目会在这些 key 上每次真打网关、随后覆写回旧值,两个版本互相打对方的缓存;「整条作废」只留给真正破坏内容完整性的失败。
|
||||||
|
- M3 的推理能力 `evidence` 刷新到 2026-08-25 复测。`can_disable` **仍为 `True`**(`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立),同时补记两条限制: 推理信号在非流式路径不可观测;`enable_thinking` 与 `thinking={"type":"enabled"}` 对该模型无效,只有 `reasoning_effort` 是真开关。
|
||||||
|
- `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。
|
||||||
|
- 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。
|
||||||
|
|
||||||
|
## 1.3.0(2026-08-24)
|
||||||
|
|
||||||
|
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
|
||||||
|
|
||||||
|
根因不是"asyncpg 的默认 `min_size=10` 太大"这一条,而是四层叠加,只改默认值会留下三层:
|
||||||
|
|
||||||
|
| # | 缺陷 | 本版 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | 库对自己的资源占用从未表态 —— `create_pool(dsn, timeout=10)` 继承第三方默认值,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限":要么一次拿到 10 条,要么建池失败。这是全库唯一一处预占资源的组件 | `min_size=0` + `max_size` 可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4)+ 每次写入硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5.0s) |
|
||||||
|
| ② | 判死判据挂在"**哪一步**失败"(建池失败即永久判死),而那一步里同时藏着 DSN 写错(进程内不可能改变)与 `too many clients`(下一秒可能就好) | 判据改挂"失败是**什么性质**",永久失能收窄到只剩 DSN 不可解析一类,其余一律 60s 冷却后自动重试 |
|
||||||
|
| ③ | 降级不可恢复也不可见 —— 全程只有一条 warning,SQLite 侧连 warning 都没有 | 进入/恢复各一条日志 + 降级期间节流复述 + `client.telemetry_status` 只读快照 |
|
||||||
|
| ④ | "多个 client 共享一个 recorder"这条正道是坏的(第一个 `aclose()` 就把共享的 recorder 弄死),所以下游只能退回"每个 client 各占一份" | 全库统一"谁建的谁关"纪律,共享路径打通 |
|
||||||
|
|
||||||
|
真实实验室 PG 上的连接数实测,一眼可见差别: **修复前**建完 recorder 就是 **10** 条;**修复后**建完 recorder **0** 条 → 一次写入后 **1** 条 → 20 行并发后 **4** 条(= `pool_max`)→ `aclose()` 后回到 **0**。
|
||||||
|
|
||||||
|
### 请先读这一条(一): 最低 Python 版本提到 3.12,3.11 的部署装不上
|
||||||
|
|
||||||
|
`requires-python` 从 `>=3.11` 改为 `>=3.12`。这是本版四条要点里**唯一会让下游装不上**的变更——仍在 3.11 上的部署执行 `pip install` 会被 pip 直接拒绝,不是运行时报错,是装不了。升级 Python 或钉住 `polygateway<1.3` 二选一。
|
||||||
|
|
||||||
|
抬版本不是顺手做的: 本版的写入预算依赖 `asyncio.timeout`,而 3.11.0 / 3.11.1 的 `uncancel` 有已知缺陷,继续支持 3.11 就得退回 `wait_for` 并绕开那个缺陷。取舍是缩小支持面换掉一整块补丁代码。同批把三处泛型函数改成 PEP 695 语法(`def f[T](...)`,该语法在 3.11 是 `SyntaxError`)。
|
||||||
|
|
||||||
|
### 请先读这一条(二): 遥测的常驻连接数会从 `10 × client 数` 掉到 0,监控曲线会突变
|
||||||
|
|
||||||
|
这是纯改善,但**曲线会跳**,不要误判为故障: 连接不再于装配期预占,而是第一次写入时才建、忙时最多 `PGW_TELEMETRY_PG_POOL_MAX` 条(缺省 4)、空闲超过回收期后归 0。代价是首次写入多付一次建连(实测 ≈390ms,相对一次秒级 LLM 调用可忽略),稳态写入无差异(实测 123ms)。
|
||||||
|
|
||||||
|
`pool_max` 的调参口径请按实测折算,**不要按 `pool_max / RTT` 估算**——那会乐观一倍: 跨内网 RTT ≈ 123ms 的实验室 PG 上,`pool_max=4` 实测约 **15.6 行/秒**(50 行并发批耗时 3.2s),因为一次 `INSERT` 的实际往返比一次 `SELECT 1` 重。缺省 4 配缺省 5s 预算能吞下约 50 行的突发,余量约 1.5 倍;超预算的行被丢弃并计入 `telemetry_status.dropped_rows`——丢一条遥测好过拖垮业务调用。多个 client 共享同一个 recorder 时并发在这里汇聚,应相应放大。
|
||||||
|
|
||||||
|
### 请先读这一条(三): `aclose()` 不再关闭注入进来的组件
|
||||||
|
|
||||||
|
新纪律是**谁建的谁关,注入的一律不碰**: `from_env()` / `from_settings()` 自建的 transport / recorder / limiter / breaker / cache 照常被 `aclose()` 关掉;经构造函数**注入**进来的则一律不碰,由注入方自己关。`RedisCache` 同款(注入的 redis 客户端不再被误关)。
|
||||||
|
|
||||||
|
这修正的是一次越权——共享同一个 recorder 的多个 client 里,第一个 `aclose()` 会把其他 client 还在用的 recorder 弄死。但**若你的代码依赖了"注入之后由 client 代关",升级后会漏关**,请自行补上关闭。同一批还修掉了反方向的泄漏: 自建的 redis limiter / breaker 客户端此前**从来没有人关**(`aclose` 压根不持有它们的引用),现在会被关。
|
||||||
|
|
||||||
|
### 请先读这一条(四): 直接构造 `GatewaySettings` 的代码要补两个参数
|
||||||
|
|
||||||
|
`GatewaySettings` 新增 `telemetry_pg_pool_max: int` 与 `telemetry_pg_write_timeout_s: float` 两个**无默认值的必填**字段。走 `from_env()` / `from_settings()` 的调用方不受影响(两个新键都是可选的,env 装配路给缺省 4 与 5.0);**直接构造 `GatewaySettings(...)` 的代码——测试装配、配置改写脚本——升级后不补参数会当场 `TypeError`**。
|
||||||
|
|
||||||
|
这不是疏忽而是既有纪律: 相邻的 `telemetry_auto_migrate` / `telemetry_text_cap` 同样无默认值,缺省规则只写在 `_load_*` 一处,不与字段签名漂移(P4 显式优于隐式)。写默认值在此也不可能——这两个字段后面还跟着四个无默认值字段,加了就是 `TypeError: non-default argument follows default argument`。`dataclasses.replace(settings, ...)` 一路不受影响。
|
||||||
|
|
||||||
|
### 遥测失败的三分判据
|
||||||
|
|
||||||
|
判据两句话:**致命 = 失败原因完全在进程内部且不可变**;**行级 vs 环境级看"失败与这一行的数据有没有关系"**。
|
||||||
|
|
||||||
|
| 档 | 覆盖 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 配置级致命 | DSN 不可解析(`ClientConfigurationError`)、建池参数非法 | 永久 no-op + 一条 **error**(这是人配错了,不是 warning) |
|
||||||
|
| 环境级不可用 | 连接类 `08` / 资源不足 `53`(含 53300 too many connections)/ 管理干预 `57` / 认证 `28` / 库不存在 `3D`,以及 `42501` 无权限、`42P01` 表不存在;网络类异常;**超时类异常仅在准备期路径可达**(写入期的超时先被 `record_llm_call` 的 `except TimeoutError` 接住,按行级丢弃);表确定不存在且建不出来 | **冷却 60s 后自动重试一次**,成功即恢复。DBA 建完表、放开权限、PG 重启完毕,进程都不必重启 |
|
||||||
|
| 行级拒绝 | 其余数据与约束类错误(`22`/`23` 等),外加**唯一具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级 |
|
||||||
|
|
||||||
|
`42703` 之所以是例外: issue #13 定了更高优先级的承诺——manual 档缺列时按现有列裁剪 `INSERT` 继续写、缺列以逐行 warning 暴露,"部分列写进去了"这件事本身有价值,不该被冷却掉。
|
||||||
|
|
||||||
|
### 新增公共 API
|
||||||
|
|
||||||
|
| 名字 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GatewayClient.telemetry_status` / `EmbeddingClient.telemetry_status` / `OcrClient.telemetry_status` | `TelemetryStatus \| None` 只读属性。`None` = 未启用遥测,或注入的 recorder 不提供状态 |
|
||||||
|
| `polygateway.TelemetryStatus`(顶层导出) | frozen dataclass: `degraded` / `fatal` / `reason` / `degraded_for_s` / `dropped_rows` / `retry_after_s`。下游可据此对账或告警,不必再人工比对行数 |
|
||||||
|
| `ports.TelemetryStatusProvider` | 新增的**独立**可选端口。`TelemetryRecorder` **逐字未变**——它是 `@runtime_checkable`,往里加成员会让所有只实现 `record_llm_call` 的对象当场不再满足协议,下游的同款 `isinstance` 断言升级即断 |
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- 两个新配置键 `PGW_TELEMETRY_PG_POOL_MAX`(缺省 4,须 ≥ 1)与 `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(缺省 5.0,须 > 0)。`GatewaySettings` 相应新增两个**无默认值的必填**字段,与相邻三个遥测键(`telemetry_auto_migrate` / `telemetry_text_cap` / `telemetry_sqlite_path`)完全一致——上面那两个"缺省"只存在于 env 装配路(`_load_*` 函数),直接构造 `GatewaySettings` 的调用点必须补这两个参数,见"请先读这一条(四)"。`PostgresRecorder` 的 `pool_max` / `write_timeout_s` 是 keyword-only **必填**参数(直接构造 recorder 的调用点需补,不传即 `TypeError`)。
|
||||||
|
- `PostgresRecorder.aclose()` 现在是**有界且终局**的: 走 `asyncio.wait_for` + 超时 `terminate()`(`Pool.close()` 在 in-flight 连接未释放时会无限等,asyncpg 自己的文档就建议加 `wait_for`);关闭后写入短路且**不再复活**——此前关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入外部池的调用方以为自己管着全部连接、实际早已不是。
|
||||||
|
- 降级日志的**级别由是否致命决定**: 配置级致命(DSN 写不对)发 **ERROR**——人配错了、本进程内不会自愈,运维必须看见;其余(后端挂了、权限被收、表被删)发 WARNING——外部状态,冷却到期会自己重试。级别只在 `TelemetryStatusTracker` 一处决定,两个 recorder 共用。
|
||||||
|
- 对账请**同时看 `degraded` 与 `dropped_rows`**: 写入因本地池饱和超出预算被丢时走的是行级丢弃,`degraded` 保持 `False`(后端并没有挂,是本进程并发超了),只有 `dropped_rows` 增长。只按 `degraded` 配告警会完全看不见这一类丢行——而它恰是 `PGW_TELEMETRY_PG_POOL_MAX` 配小了的唯一信号。
|
||||||
|
- SQLite 遥测初始化失败后终于有日志了。此前 `sqlite.py` 初始化失败直接 `return`,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版**只做可见性**,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。
|
||||||
|
- 写入路径不再用 `async with pool.acquire(...)`。`Pool.release()` 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。
|
||||||
|
|
||||||
## 1.2.4(2026-08-20)
|
## 1.2.4(2026-08-20)
|
||||||
|
|
||||||
熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。
|
熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。
|
||||||
@@ -21,7 +332,6 @@
|
|||||||
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
|
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
|
||||||
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
|
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
|
||||||
|
|
||||||
|
|
||||||
## 1.2.3(2026-08-19)
|
## 1.2.3(2026-08-19)
|
||||||
|
|
||||||
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
|
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
|
||||||
@@ -44,7 +354,7 @@ CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
|||||||
照抄过就请现在查这两条:
|
照抄过就请现在查这两条:
|
||||||
|
|
||||||
| 查什么 | 中招的样子 |
|
| 查什么 | 中招的样子 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `SELECT count(*) FROM llm_calls;`,且必须用能**绕过 RLS** 的角色(superuser 或带 `BYPASSRLS` 属性的角色)——`FORCE` 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" | 启用 RLS 之后一直是 0,或从某个时刻起不再增长 |
|
| `SELECT count(*) FROM llm_calls;`,且必须用能**绕过 RLS** 的角色(superuser 或带 `BYPASSRLS` 属性的角色)——`FORCE` 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" | 启用 RLS 之后一直是 0,或从某个时刻起不再增长 |
|
||||||
| 应用日志里遥测写入的降级告警,前缀 `Postgres 遥测写入失败(丢弃该行):` | 每次调用刷一条,附带的 PG 原话是 `new row violates row-level security policy for table "llm_calls"` |
|
| 应用日志里遥测写入的降级告警,前缀 `Postgres 遥测写入失败(丢弃该行):` | 每次调用刷一条,附带的 PG 原话是 `new row violates row-level security policy for table "llm_calls"` |
|
||||||
|
|
||||||
@@ -53,7 +363,7 @@ CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
|||||||
### 破坏性变更(五项)
|
### 破坏性变更(五项)
|
||||||
|
|
||||||
| # | 变更 | 影响与应对 |
|
| # | 变更 | 影响与应对 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| ① | **Postgres 侧不再自动补列**(缺省转为 manual 档) | 库升级带来新列时,旧表不会被自动 `ALTER`:库改为发**一条** warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 `INSERT` 继续写入——**缺的那几列静默不落库**,直到有人执行那几条 SQL。要恢复旧行为设 `PGW_TELEMETRY_SCHEMA_MODE=auto`。SQLite 侧缺省不变(仍 auto),理由见下 |
|
| ① | **Postgres 侧不再自动补列**(缺省转为 manual 档) | 库升级带来新列时,旧表不会被自动 `ALTER`:库改为发**一条** warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 `INSERT` 继续写入——**缺的那几列静默不落库**,直到有人执行那几条 SQL。要恢复旧行为设 `PGW_TELEMETRY_SCHEMA_MODE=auto`。SQLite 侧缺省不变(仍 auto),理由见下 |
|
||||||
| ② | 两个 recorder 新增 **keyword-only 必填**参数 `auto_migrate` | `SQLiteRecorder(db_path, *, auto_migrate)` 与 `PostgresRecorder(dsn, *, pool=None, auto_migrate)`;直接构造 recorder 的调用点必须补这个参数,不传即 `TypeError`。**故意不给默认值**:缺省规则只写在 config 一处,不与类签名漂移 |
|
| ② | 两个 recorder 新增 **keyword-only 必填**参数 `auto_migrate` | `SQLiteRecorder(db_path, *, auto_migrate)` 与 `PostgresRecorder(dsn, *, pool=None, auto_migrate)`;直接构造 recorder 的调用点必须补这个参数,不传即 `TypeError`。**故意不给默认值**:缺省规则只写在 config 一处,不与类签名漂移 |
|
||||||
| ③ | `GatewaySettings` 新增**必填**字段 `telemetry_auto_migrate: bool` | 只影响「构造函数全量注入」这条装配路(测试/高级用法);`from_env()` / `from_settings()` 的用户零改动。`telemetry_backend="none"` 时该字段在 `__post_init__` 归一为 `False` |
|
| ③ | `GatewaySettings` 新增**必填**字段 `telemetry_auto_migrate: bool` | 只影响「构造函数全量注入」这条装配路(测试/高级用法);`from_env()` / `from_settings()` 的用户零改动。`telemetry_backend="none"` 时该字段在 `__post_init__` 归一为 `False` |
|
||||||
@@ -69,7 +379,7 @@ CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
|||||||
issue #12 交付的三样手段列在下表——它们改变的是**能做什么**,不是**默认做什么**:
|
issue #12 交付的三样手段列在下表——它们改变的是**能做什么**,不是**默认做什么**:
|
||||||
|
|
||||||
| 手段 | 内容 |
|
| 手段 | 内容 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| **`PGW_TELEMETRY_TEXT_CAP`**(可选正整数键) | 遥测落库正文的字符上限;**不设 = 不截断**(缺省)。作用面正好四处: `messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列;超出部分头部保留、尾部换成 `…(略 N 字)`。**按每条文本切,而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**覆盖面到此为止**: 调用方塞进 `tool_calls.function.arguments`、`name` 等 `content` 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留 |
|
| **`PGW_TELEMETRY_TEXT_CAP`**(可选正整数键) | 遥测落库正文的字符上限;**不设 = 不截断**(缺省)。作用面正好四处: `messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列;超出部分头部保留、尾部换成 `…(略 N 字)`。**按每条文本切,而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**覆盖面到此为止**: 调用方塞进 `tool_calls.function.arguments`、`name` 等 `content` 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留 |
|
||||||
| **`tools/telemetry_retention.py`**(独立运维脚本) | 按 `created_at` 清理过期行。**默认 dry-run**: 先打出将删行数、`created_at` 窗口与按 `tenant_id` 的分布,让运维先判断"要删的是不是我想删的",给了 `--apply` 才真动手。退出码是与调度器(cron/systemd)的契约: `0` 正常(含 dry-run)、`1` 参数错误、`2` 连接/权限/目标表不可用(**含缺 `asyncpg`**——明确报错退出,绝不静默变成"删了 0 行")、`3` 目标是 PostgreSQL 分区表,此时脚本**拒绝 DELETE**,让路给 O(1) 的 `DETACH` + `DROP PARTITION`。请用维护角色跑,不要用应用账号(模板已对它 `REVOKE UPDATE, DELETE`) |
|
| **`tools/telemetry_retention.py`**(独立运维脚本) | 按 `created_at` 清理过期行。**默认 dry-run**: 先打出将删行数、`created_at` 窗口与按 `tenant_id` 的分布,让运维先判断"要删的是不是我想删的",给了 `--apply` 才真动手。退出码是与调度器(cron/systemd)的契约: `0` 正常(含 dry-run)、`1` 参数错误、`2` 连接/权限/目标表不可用(**含缺 `asyncpg`**——明确报错退出,绝不静默变成"删了 0 行")、`3` 目标是 PostgreSQL 分区表,此时脚本**拒绝 DELETE**,让路给 O(1) 的 `DETACH` + `DROP PARTITION`。请用维护角色跑,不要用应用账号(模板已对它 `REVOKE UPDATE, DELETE`) |
|
||||||
| **README 新增「生产部署 DDL 模板(PostgreSQL)」一节** | 三角色、`created_at` RANGE 分区与 `pg_partman` retention、`REVOKE UPDATE, DELETE` 加触发器兜底、RLS、**库自己需要的最小权限**、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 `<!-- pg-template:* -->` 锚点,由 `tests/integration/test_postgres_telemetry.py` 从 README 解析出来在真实 PG 上逐条执行——**模板只有这一份**,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物 |
|
| **README 新增「生产部署 DDL 模板(PostgreSQL)」一节** | 三角色、`created_at` RANGE 分区与 `pg_partman` retention、`REVOKE UPDATE, DELETE` 加触发器兜底、RLS、**库自己需要的最小权限**、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 `<!-- pg-template:* -->` 锚点,由 `tests/integration/test_postgres_telemetry.py` 从 README 解析出来在真实 PG 上逐条执行——**模板只有这一份**,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物 |
|
||||||
@@ -116,7 +426,7 @@ issue #12 交付的三样手段列在下表——它们改变的是**能做什
|
|||||||
- **遥测表 `llm_calls` 新增两列**,排在既有 22 列**末尾**,两端类型按各自后端的原生能力取:
|
- **遥测表 `llm_calls` 新增两列**,排在既有 22 列**末尾**,两端类型按各自后端的原生能力取:
|
||||||
|
|
||||||
| 列 | Postgres | SQLite |
|
| 列 | Postgres | SQLite |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| `tenant_id` | `TEXT NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` |
|
| `tenant_id` | `TEXT NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` |
|
||||||
| `meta` | `JSONB NOT NULL DEFAULT '{}'::jsonb` | `TEXT NOT NULL DEFAULT '{}'` |
|
| `meta` | `JSONB NOT NULL DEFAULT '{}'::jsonb` | `TEXT NOT NULL DEFAULT '{}'` |
|
||||||
|
|
||||||
@@ -128,7 +438,7 @@ issue #12 交付的三样手段列在下表——它们改变的是**能做什
|
|||||||
校验在四个公共入口收口、进洋葱之前抛裸 `ValueError`,四条链路共用同一份实现:
|
校验在四个公共入口收口、进洋葱之前抛裸 `ValueError`,四条链路共用同一份实现:
|
||||||
|
|
||||||
| 项 | 规则 |
|
| 项 | 规则 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `tenant_id` | 长度 ≤ **128**;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug |
|
| `tenant_id` | 长度 ≤ **128**;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug |
|
||||||
| `meta` 键数 | ≤ **16** |
|
| `meta` 键数 | ≤ **16** |
|
||||||
| `meta` 键 | 必须匹配 `[a-z0-9_.]{1,64}`;**`pg_` 前缀保留**给库将来的内建维度(本版库自身不写任何该前缀的键) |
|
| `meta` 键 | 必须匹配 `[a-z0-9_.]{1,64}`;**`pg_` 前缀保留**给库将来的内建维度(本版库自身不写任何该前缀的键) |
|
||||||
@@ -169,7 +479,7 @@ RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文
|
|||||||
|
|
||||||
### 行为变更
|
### 行为变更
|
||||||
|
|
||||||
- **非 2xx 的 message 末尾追加 ` | {响应体摘要}`**,覆盖两个 transport 的**全部**分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 `force_open` 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。
|
- **非 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` 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。
|
- 摘要口径: 先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 **2048 字符**(对齐 Kubernetes client-go 同场景的 `maxUnstructuredResponseTextBytes`)。超长时**保留头 1400 + 尾 600**并记下省略字数——JSON 错误体的 `code` / `request_id` 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。
|
||||||
- 遥测 `error` 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。
|
- 遥测 `error` 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
|
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
|
||||||
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
|
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
|
||||||
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
|
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
|
||||||
- **技术栈**: Python 3.11+,核心仅依赖 `httpx` + `pydantic`,其余(redis/sqlite/postgres/json_repair/openai)一律 optional extras。conda 环境 `PolyGateway`。
|
- **技术栈**: Python 3.12+,核心仅依赖 `httpx` + `pydantic`,其余(redis/sqlite/postgres/json_repair/openai)一律 optional extras。conda 环境 `PolyGateway`。
|
||||||
|
|
||||||
## 2. 常用命令
|
## 2. 常用命令
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ make ci # 只读验证(check + test)
|
|||||||
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
|
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
|
||||||
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
|
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
|
||||||
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
|
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
|
||||||
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件 |
|
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件,**外加 `pytest -m slow`** ——真实网关 e2e 与 Redis 时间语义变体被 `addopts = "-m 'not slow'"` 默认排除,**不显式跑就等于没跑**(约 20-40 分钟,取决于网关快慢)。它们不进日常提交是有意的: pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把"测试红了先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉;代价是这道门必须由本清单兜住 |
|
||||||
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
|
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
|
||||||
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
|
| 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/*` |
|
| 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/*` |
|
||||||
@@ -103,6 +103,8 @@ make ci # 只读验证(check + test)
|
|||||||
|
|
||||||
Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。
|
Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。
|
||||||
|
|
||||||
|
发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。
|
||||||
|
|
||||||
### 4.5 配置管理
|
### 4.5 配置管理
|
||||||
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
|
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
|
||||||
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
|
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
|
||||||
@@ -113,6 +115,7 @@ Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI
|
|||||||
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
|
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
|
||||||
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
|
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
|
||||||
- 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`。
|
- 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`。
|
||||||
|
- **成败取决于外部服务当下状态的测试一律标 `slow`**(`tests/e2e/` 四个文件与 Redis 时间语义变体):它们默认不进日常套件,由发布清单第 4 步统一跑。判据是"重跑一次可能就绿了"——这种测试留在提交关卡里会污染信号。同理,给它们的超时不得紧于 `.env` 的生产配置,否则是设计上就会间歇红。
|
||||||
|
|
||||||
## 5. 项目结构
|
## 5. 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
.PHONY: install test lint format check ci wiki wiki-check
|
.PHONY: install test lint format check ci wiki wiki-check shared-table-gate
|
||||||
|
|
||||||
ENV := PolyGateway
|
ENV := PolyGateway
|
||||||
|
|
||||||
|
# 集成测试触碰共享表 llm_calls 的字面量门(issue #18)。
|
||||||
|
# 这道门是**烟雾报警器,不是隔离证明**: 它拦不住 f"{schema}.{table}" 拼接、
|
||||||
|
# 参数化查询,或不带限定名的 DELETE 配上 admin 的默认 search_path。真正的隔离
|
||||||
|
# 来自两处——沙箱工厂不把管理连接交给用例,以及清理脚本以无权角色运行。
|
||||||
|
# 留着它是因为字面量回归最常见、也最便宜拦。
|
||||||
|
shared-table-gate:
|
||||||
|
@if grep -rn --include='*.py' 'public\.llm_calls' tests/; then \
|
||||||
|
echo ""; \
|
||||||
|
echo "错误: 集成测试不得触碰共享表(见上面的命中行)。"; \
|
||||||
|
echo "改用 tests/integration/conftest.py 的 pg_sandbox 工厂;注释里提到它请写「共享表 llm_calls」。"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
install:
|
install:
|
||||||
conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]"
|
conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing
|
conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing
|
||||||
|
|
||||||
lint:
|
lint: shared-table-gate
|
||||||
conda run -n $(ENV) ruff check src/ tests/ --fix
|
conda run -n $(ENV) ruff check src/ tests/ --fix
|
||||||
conda run -n $(ENV) lint-imports
|
conda run -n $(ENV) lint-imports
|
||||||
|
|
||||||
format:
|
format:
|
||||||
conda run -n $(ENV) ruff format src/ tests/
|
conda run -n $(ENV) ruff format src/ tests/
|
||||||
|
|
||||||
check:
|
check: shared-table-gate
|
||||||
conda run -n $(ENV) ruff format --check src/ tests/
|
conda run -n $(ENV) ruff format --check src/ tests/
|
||||||
conda run -n $(ENV) ruff check src/ tests/
|
conda run -n $(ENV) ruff check src/ tests/
|
||||||
conda run -n $(ENV) lint-imports
|
conda run -n $(ENV) lint-imports
|
||||||
|
|||||||
@@ -9,23 +9,105 @@
|
|||||||
每个接入大模型的项目都会重写同一批东西:重试循环、429 处理、熔断器、SSE 解析、遥测埋点——写三遍就有三份 bug。本库把这些收敛为一份经过压测验证的实现:
|
每个接入大模型的项目都会重写同一批东西:重试循环、429 处理、熔断器、SSE 解析、遥测埋点——写三遍就有三份 bug。本库把这些收敛为一份经过压测验证的实现:
|
||||||
|
|
||||||
| 能力 | 说明 |
|
| 能力 | 说明 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 |
|
| 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 |
|
||||||
| 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) |
|
| 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) |
|
||||||
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
|
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
|
||||||
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
|
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
|
||||||
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
||||||
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
||||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||||
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
||||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
|
||||||
|
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
|
||||||
|
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 36 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||||
|
| 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:四种响应(chat / embedding / OCR 两种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断) |
|
||||||
|
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
|
||||||
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
||||||
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
||||||
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
|
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
|
||||||
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
|
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
|
||||||
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
|
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
|
||||||
|
|
||||||
**降级方向是铁律**:缓存/遥测后端掉线 → 静默降级(warning);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放。
|
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。
|
||||||
|
|
||||||
|
## 1.3.4 推理配置迁移
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **1.3.4 虽为 patch,升级仍会拒绝部分旧配置。** 已登记但不含 AUTO 的模型不再接受 `ENABLE_THINKING=true`/`REASONING_EFFORT=auto`;受管推理与 raw 控制并存(即使同值)也会拒绝。请先按下表选择显式档或 raw-only,并在受影响调用首次使用新语义前更换缓存 namespace/salt;**只升级包不会自动隔离旧缓存**。
|
||||||
|
|
||||||
|
**先明确意图,再在首次新语义缓存读写前切换缓存身份。** `auto` 要求开启但不指定强度,不是 `None`(不表态),也不是库代选付费档位。已登记模型只有清单含 AUTO 才接受 True/auto;nearest 不把 AUTO 映射成强度。未知模型仍尽力+warning,空开启片段可能零推理字节,不保证开启。完整型号证据见[批准设计 §4/5](research-wiki/designs/2026-09-09-134-thinking-contracts-design.md)。
|
||||||
|
|
||||||
|
| 项 | 旧配置/受影响模型 | 用户明确选择的新配置(示例,不是成本推荐) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M1 | MiniMax-M3 True/auto | 删除糖,`REASONING_EFFORT=medium` 可恢复旧 medium 字节;也可选表内其他档 |
|
||||||
|
| M2 | deepseek-v4-pro/flash/flash-vision-exp、glm-5.2 True/auto | 删除糖,选 high 或 max;非空开关也不能豁免 AUTO 成员检查 |
|
||||||
|
| M3 | glm-5.3/5.3-flash、kimi-k3/kimi-for-coding True/auto | 删除糖,选 low/high/max;nearest 不能修复 AUTO |
|
||||||
|
| M4 | gpt-5.4/5.5、claude-opus-5/sonnet-5、gemini-3.1-pro True/auto | 删除糖,可选表内 medium;不可达不能补 AUTO,也不等于 live 证明 |
|
||||||
|
| M5 | MiniMax-M2.5/M2.7 True/auto | 仍接受,但 on_base 不再偷带 medium,改为空片段;缓存须迁移。2026-09-09 两型各5轮流式 AUTO 复验通过,不外推到其他模式/渠道 |
|
||||||
|
| M6 | qwen 五型、glm-5/5.1/4.6v True/auto | 保留;glm-5/5.1 历史身份不足仍未覆盖,不推及其他型号 |
|
||||||
|
| M7 | 未登记模型 True/auto | 可保留尽力;确定保证须先独立取证再登记能力 |
|
||||||
|
| M8 | 受管意图+任一层 raw 推理控制,即使同值/被遮蔽 | 保留受管档并删除源 extra_body、请求 overlay 的控制键;或清空源糖/档和请求意图,仅 raw(applied_effort=NULL) |
|
||||||
|
| M9 | 如 glm-5.3,请求 medium,nearest 改 error | 同步更换 namespace/salt;旧身份仍可能回放 nearest 成功,不执行新拒绝 |
|
||||||
|
|
||||||
|
M8 包括 reasoning_effort、enable_thinking、thinking、thinking_budget、reasoning、thinkingConfig、output_config.effort 及当前 wire 声明的整个控制根。浅覆盖次序不改,不深合并;自定义 on_base 不能偷带自己的 effort_key 或标准强度字段,点号键仍是顶层字面键。工厂源级拒绝发生在装配期;请求显式档+已知 raw 可前置拒绝;全量注入默认 transport 在 HTTP 前 RequestRejected,但可能已经准入,沿既有 finally 结算。自定义 transport 由实现方履约。
|
||||||
|
|
||||||
|
### 显式缓存身份切换
|
||||||
|
|
||||||
|
**不增加**自动 revision、fallback/能力表/wire 版本指纹,不强制所有 chat 冷启动。受影响调用须选从未承载旧语义的 namespace 或 salt;同版本 fallback、能力表或 wire 变化亦须再次迁移。未迁移可能命中旧缓存并绕过新拒绝:这是操作前置,不是自动安全机制。
|
||||||
|
|
||||||
|
| 路径 | 切换示例/边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工厂默认 | `PGW_CACHE_NAMESPACE=lab:tenant-a:thinking-134-a`,保留原租户前缀 |
|
||||||
|
| per-call 覆盖 | `chat(..., cache_namespace="tenant-a:thinking-134-a", cache_salt="epoch-7")`;只改工厂默认无效 |
|
||||||
|
| 请求级档 | M3 示例:源不表态,`chat(..., reasoning_effort="medium", cache_salt="epoch-7:thinking-134-a")` |
|
||||||
|
| 全量注入/多源 | 构造参数 cache_namespace 同步切换;共享身份只要一个源受影响,该集合都要隔离或显式拆 scope |
|
||||||
|
| 并行/回滚 | 新旧客户端不共用新身份;回滚旧 namespace 会重见旧值,旧键未清理;未来变更不能复用此标记包办 |
|
||||||
|
|
||||||
|
### 证据与遥测读法
|
||||||
|
|
||||||
|
真实成功尝试记 response.applied_effort;失败尝试记 effective 请求意图(可能零 HTTP);缓存命中和 scope 终态只记**本次请求级**档,不借历史 applied 或源级补值。embedding、OCR text/layout 成败行均 NULL。实际档分析须排除缓存命中与错误行,未知 AUTO 不证明上游能力。
|
||||||
|
|
||||||
|
测试侧默认 FAIL:404 只有请求、唯一尝试、完整无重复键 JSON、error.type=model_not_found 等独立证据全满足才 UNCOVERED;429/5xx/网络/解析错误不整类 skip。成功公共身份缺失无独立证据仍 FAIL;成功 SSE 不新增捕获器。关闭须完整合格轮次全 ABSENT,UNKNOWN 不能靠长度升格成功。不可关闭探测的 OBSERVED 仅支持本条件下未关闭;预期拒绝另按预声明类型、状态、机器字段判定。必需 live 的 SKIP/UNKNOWN/缺轮不因 pytest exit 0 通过发布门。
|
||||||
|
|
||||||
|
**本版验收例外(2026-09-09 用户正式批准)**:不再补全模型矩阵;既有失败、UNKNOWN、不可达、缺轮及下游现行配置缺证据如实保留,不改成 PASS。M2 两型的定向成功不代表全模型通过;三项目实际配置迁移仍未核验,合成兼容测试不能代替,缓存迁移操作前置也未被豁免。逐项实测、网络诊断与证据索引见[1.3.4 验证记录](research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md)。
|
||||||
|
|
||||||
|
## 1.3.5 逻辑调用统计与失败诊断
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **升级后失败行会变多,旧的"失败调用数" SQL 会多数。** 每次领域失败除逐次尝试行外另写一条 `terminal_failure` 行;自建 recorder 若未跟进 10 个新参数,会在**装配期**就报错(不是运行期静默丢行)。
|
||||||
|
|
||||||
|
本版把治理单位从"一次尝试"补齐到"一次逻辑调用"。两件事: 四种响应带上 `call_stats`;遥测表补 10 列并新增一类行。
|
||||||
|
|
||||||
|
### 读 `call_stats`
|
||||||
|
|
||||||
|
```python
|
||||||
|
resp = await client.chat([{"role": "user", "content": "hi"}])
|
||||||
|
stats = resp.call_stats # EmbeddingResponse / OcrTextResult / OcrLayoutResult 同名字段
|
||||||
|
stats.logical_call_id # 一次逻辑调用一个 ID:重试/换源/重问/分批共享
|
||||||
|
stats.attempts # 真实打出去的尝试次数(免预算 429 也计;缓存命中为 0)
|
||||||
|
stats.total_latency_ms # 含缓存 IO、退避、准入等待、重问与内联遥测收尾的墙钟
|
||||||
|
```
|
||||||
|
|
||||||
|
`call_stats` 为 `None` 意为**未知**(如第三方合成的响应、或旧缓存条目回放),不得读成 0。失败时异常对象上**不挂任何统计字段**——要归因请查遥测表。
|
||||||
|
|
||||||
|
### 下游 SQL 迁移五项
|
||||||
|
|
||||||
|
| # | 改什么 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 计失败调用改成 `WHERE event_kind = 'terminal_failure'` | 一次逻辑调用恰好一条终态行 |
|
||||||
|
| 2 | `error IS NOT NULL` 不再是失败调用的判据 | 它同时命中尝试错误行与终态行,升级后计数变大 |
|
||||||
|
| 3 | `AVG(latency_ms)` 须按 `event_kind` 分组 | 终态行的 `latency_ms` 是**整个逻辑调用**的总耗时,与单次尝试不同量纲 |
|
||||||
|
| 4 | 费用口径**不变** | 终态行 `cost IS NULL`、`usage_source='unavailable'`、token 为 0,不参与 `SUM(cost)` |
|
||||||
|
| 5 | 失败行可能带 `http_status_code = 200` | MonkeyOCR 的 `success != true` 就是 200 下的失败,该列不可当成败判据 |
|
||||||
|
|
||||||
|
归因查询的典型形态: 一条 `WHERE logical_call_id = :lcid` 同时拿到整池终态原因(`terminal_failure` 行的 `error` / `error_type`)与逐源现场(`attempt` 行的 `source_name` / `http_status_code` / `cause_type` / `error_body`)。**终态行的 `http_status_code`/`cause_type`/`error_body` 恒为 NULL**: 拿最后一个源的现场冒充整池归因是错的。
|
||||||
|
|
||||||
|
### 存储侧升级
|
||||||
|
|
||||||
|
新增 10 列全部可空、无默认值、追加在现有末列之后(`scope`、`operation`、`logical_call_id`、`event_kind`、`http_status_code`、`error_type`、`cause_type`、`error_body`、`attempts`、`total_latency_ms`),INSERT 字段 26 → 36、物理列 27 → 37。auto 档自动追加;manual 档一条 DDL 不发、按现有列裁剪写入并点名缺列(补列 SQL 由 `telemetry_schema_sql("postgres")` 自取)。**旧行的新列一律 NULL 且不回填**——NULL 表达的是"补列之前根本没记过这件事";滚动升级期新旧进程可写同一张表。
|
||||||
|
|
||||||
|
自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@@ -33,19 +115,19 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||||
"polygateway[redis,postgres,structured]>=1.2.4,<2"
|
"polygateway[redis,postgres,structured]>=1.3.5,<2"
|
||||||
```
|
```
|
||||||
|
|
||||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||||
|
|
||||||
| extra | 内容 | 何时需要 |
|
| extra | 内容 | 何时需要 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| `redis` | redis-py | Redis 限流/熔断/缓存后端 |
|
| `redis` | redis-py | Redis 限流/熔断/缓存后端 |
|
||||||
| `postgres` | asyncpg | Postgres 遥测后端 |
|
| `postgres` | asyncpg | Postgres 遥测后端 |
|
||||||
| `structured` | json-repair | 结构化输出的修复策略 |
|
| `structured` | json-repair | 结构化输出的修复策略 |
|
||||||
| `sdk` | openai | 可选的 SDK transport(默认手写 httpx,不需要) |
|
| `sdk` | openai | 可选的 SDK transport(默认手写 httpx,不需要) |
|
||||||
|
|
||||||
要求 Python ≥ 3.11。
|
要求 Python ≥ 3.12。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -135,7 +217,7 @@ resp = await client.chat(
|
|||||||
`llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发:
|
`llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发:
|
||||||
|
|
||||||
| 库会发 | 库不发 |
|
| 库会发 | 库不发 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 |
|
| 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 |
|
||||||
| `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 |
|
| `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 |
|
||||||
| 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 |
|
| 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 |
|
||||||
@@ -143,7 +225,7 @@ resp = await client.chat(
|
|||||||
### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE`
|
### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE`
|
||||||
|
|
||||||
| 取值 | 含义 |
|
| 取值 | 含义 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres` → **manual** |
|
| 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres` → **manual** |
|
||||||
| `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 |
|
| `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 |
|
||||||
| `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 |
|
| `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 |
|
||||||
@@ -151,7 +233,7 @@ resp = await client.chat(
|
|||||||
**缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。
|
**缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。
|
||||||
|
|
||||||
| 表状态 | `auto` | `manual` |
|
| 表状态 | `auto` | `manual` |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) |
|
| 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) |
|
||||||
| 存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
| 存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
||||||
| 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 |
|
| 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 |
|
||||||
@@ -181,7 +263,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**
|
|||||||
这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束:
|
这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束:
|
||||||
|
|
||||||
| 承诺 | 你可以据此做什么 |
|
| 承诺 | 你可以据此做什么 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 |
|
| 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 |
|
||||||
| 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 |
|
| 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 |
|
||||||
| `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 |
|
| `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 |
|
||||||
@@ -197,7 +279,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**
|
|||||||
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
|
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
|
||||||
|
|
||||||
| # | 锚点 | 做什么 |
|
| # | 锚点 | 做什么 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| 1 | `roles` | 建三角色并授 schema 级权限 |
|
| 1 | `roles` | 建三角色并授 schema 级权限 |
|
||||||
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
|
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
|
||||||
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
|
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
|
||||||
@@ -209,7 +291,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**
|
|||||||
### 1. 三角色
|
### 1. 三角色
|
||||||
|
|
||||||
| 角色 | 拿到什么 | 谁在用 |
|
| 角色 | 拿到什么 | 谁在用 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
|
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
|
||||||
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
|
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
|
||||||
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
|
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
|
||||||
@@ -316,7 +398,7 @@ CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
|
|||||||
四个陷阱,每一个的失败形态都是**静默的**:
|
四个陷阱,每一个的失败形态都是**静默的**:
|
||||||
|
|
||||||
| 陷阱 | 后果 |
|
| 陷阱 | 后果 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
|
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
|
||||||
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
|
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
|
||||||
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
|
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
|
||||||
@@ -327,7 +409,7 @@ CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
|
|||||||
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
|
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
|
||||||
|
|
||||||
| 库会发的语句 | 需要什么 |
|
| 库会发的语句 | 需要什么 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
|
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
|
||||||
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
|
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
|
||||||
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
|
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
|
||||||
@@ -346,20 +428,24 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全
|
|||||||
```
|
```
|
||||||
|
|
||||||
| 层 | 配置 |
|
| 层 | 配置 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
|
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
|
||||||
| 保留期 | 上面的分区模板 + `pg_partman` 的 `retention`,过期分区整块 `DROP` |
|
| 保留期 | 上面的分区模板 + `pg_partman` 的 `retention`,过期分区整块 `DROP` |
|
||||||
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
|
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
|
||||||
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`) |
|
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`;**`--table <schema>.llm_calls` 把目标钉死**,不给则由连接的 `search_path` 推断) |
|
||||||
|
|
||||||
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments`、`name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
|
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments`、`name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
|
||||||
|
|
||||||
|
**1.3.5 新增的两处诊断文本同样不在 cap 覆盖内**:`error_body` 记网关响应正文,沿用库内既有的 `summarize_body` 上限(与 cap 无关,也不随它变化);`error` 在结构化耗尽的终态行上带一段有界说明(修复原因至多 200 字符 + 至多 3 条校验错误 × 200 字符,**不含模型原始正文**),自带独立限长。判断留存面时这两列按各自上限估算,不能算进 cap 的四处。
|
||||||
|
|
||||||
### 7. SQLite 侧的保留期
|
### 7. SQLite 侧的保留期
|
||||||
|
|
||||||
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db`、`runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
|
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db`、`runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
|
||||||
|
|
||||||
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
|
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
|
||||||
|
|
||||||
|
**`--apply` 之前先把目标钉死。** 不给 `--table` 时,脚本删哪张表取决于连接的 `search_path`——它的首项是 `"$user"`,故换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。`--table <schema>.llm_calls` 让目标由参数精确解析、不再经 `search_path` 推断;表名段固定为 `llm_calls`(本脚本只清理遥测表,不是通用清理器),写别的名字会以退出码 1 被拒。cron 里跑 `--apply` 尤其该给它:那一行配置从此自己说明删的是哪张表。
|
||||||
|
|
||||||
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
|
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
|
||||||
|
|
||||||
## 错误模型(四分类)
|
## 错误模型(四分类)
|
||||||
@@ -367,7 +453,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
|||||||
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
|
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
|
||||||
|
|
||||||
| 分类 | 含义 | 库内行为 |
|
| 分类 | 含义 | 库内行为 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| `TransientError` | 超时/5xx/网络抖动/截断流 | 换源重试 + 退避 |
|
| `TransientError` | 超时/5xx/网络抖动/截断流 | 换源重试 + 退避 |
|
||||||
| `SourceDeadError` | 401/403/欠费(429+insufficient_quota) | 立即熔断该源 + 换源 |
|
| `SourceDeadError` | 401/403/欠费(429+insufficient_quota) | 立即熔断该源 + 换源 |
|
||||||
| `RequestRejectedError` | 400/内容拒绝/本地格式拒绝 | 不重试不换源,快速失败 |
|
| `RequestRejectedError` | 400/内容拒绝/本地格式拒绝 | 不重试不换源,快速失败 |
|
||||||
@@ -382,7 +468,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
|||||||
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
||||||
|
|
||||||
| 会到达调用方 | 库内吸收(不必 catch) |
|
| 会到达调用方 | 库内吸收(不必 catch) |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `GatewayUnavailableError` 族——`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` | `TransientError`(退避后换源重试,耗尽即转为 `AllSourcesExhausted`) |
|
| `GatewayUnavailableError` 族——`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` | `TransientError`(退避后换源重试,耗尽即转为 `AllSourcesExhausted`) |
|
||||||
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
|
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
|
||||||
| `ResultInvalidError` | |
|
| `ResultInvalidError` | |
|
||||||
@@ -397,8 +483,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
|||||||
配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.env.example),约定速览:
|
配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.env.example),约定速览:
|
||||||
|
|
||||||
| 键形态 | 作用 |
|
| 键形态 | 作用 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `{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}__{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/REASONING_EFFORT/EFFORT_FALLBACK/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
|
||||||
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
|
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
|
||||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||||
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
|
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
|
||||||
@@ -407,6 +493,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
|||||||
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
|
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
|
||||||
| `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) |
|
| `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) |
|
||||||
| `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text`、`response`、`thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) |
|
| `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text`、`response`、`thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) |
|
||||||
|
| `PGW_TELEMETRY_PG_POOL_MAX` | 可选正整数(缺省 4):Postgres 遥测池的连接**上限**。池按需建连,闲时占 0 条,这一格是忙时天花板而非常驻量。调参按实测折算而非按 `pool_max / RTT` 估算——跨内网 RTT ≈ 123ms 上 `pool_max=4` 实测约 15.6 行/秒(一次 `INSERT` 的往返比一次 `SELECT 1` 重一倍);多个 client 共享同一 recorder 时并发在此汇聚,应相应放大 |
|
||||||
|
| `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | 可选正数(缺省 5.0):**一次遥测写入的硬预算**,同时用作建连、`acquire` 与「准备 + 取连接 + 执行」整段的上界;超时即丢该行,绝不让遥测无界地挂在业务路径上 |
|
||||||
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
|
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
|
||||||
|
|
||||||
**`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 `fail_fast`)——单源 scope 请配 `wait`**
|
**`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 `fail_fast`)——单源 scope 请配 `wait`**
|
||||||
@@ -436,7 +524,7 @@ graph LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
| 模块 | 职责 |
|
| 模块 | 职责 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `types.py` / `errors.py` / `ports.py` | 内核:冻结类型、四分类异常、全部 Protocol(最内层,不依赖任何实现) |
|
| `types.py` / `errors.py` / `ports.py` | 内核:冻结类型、四分类异常、全部 Protocol(最内层,不依赖任何实现) |
|
||||||
| `middleware/` | 治理算法(重试/限流/熔断/缓存/遥测),只面向端口 |
|
| `middleware/` | 治理算法(重试/限流/熔断/缓存/遥测),只面向端口 |
|
||||||
| `transports/` | 协议细节:OpenAI 兼容 SSE、MonkeyOCR 双端点;错误翻译在此层 |
|
| `transports/` | 协议细节:OpenAI 兼容 SSE、MonkeyOCR 双端点;错误翻译在此层 |
|
||||||
@@ -451,7 +539,7 @@ graph LR
|
|||||||
行为不是宣称出来的,是压测出来的(数字见 `research-wiki/findings/`):
|
行为不是宣称出来的,是压测出来的(数字见 `research-wiki/findings/`):
|
||||||
|
|
||||||
| 场景 | 结果 |
|
| 场景 | 结果 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 故障混编 soak(坏 key/黑洞/慢源/限流源混合,8000 调用) | 成功率 98.96%,坏源吸流被压制,真实源零误熔 |
|
| 故障混编 soak(坏 key/黑洞/慢源/限流源混合,8000 调用) | 成功率 98.96%,坏源吸流被压制,真实源零误熔 |
|
||||||
| OCR 故障池 soak(1500 调用,redis 双后端跨进程) | 成功率 99.73%,13 项不变量全过(租约归零/探针不悬挂/零取消泄漏等) |
|
| OCR 故障池 soak(1500 调用,redis 双后端跨进程) | 成功率 99.73%,13 项不变量全过(租约归零/探针不悬挂/零取消泄漏等) |
|
||||||
| 两项目全量迁移回归 | 原测试全绿 + 真实链路冒烟 + 50 样本批跑 100% 解析 |
|
| 两项目全量迁移回归 | 原测试全绿 + 真实链路冒烟 + 50 样本批跑 100% 解析 |
|
||||||
@@ -461,7 +549,7 @@ graph LR
|
|||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
conda create -n PolyGateway python=3.11 && conda activate PolyGateway
|
conda create -n PolyGateway python=3.12 && conda activate PolyGateway
|
||||||
make install # editable 安装(dev + 全部 extras)
|
make install # editable 安装(dev + 全部 extras)
|
||||||
make test # pytest + 覆盖率(目标 ≥80%)
|
make test # pytest + 覆盖率(目标 ≥80%)
|
||||||
make lint # ruff + import-linter
|
make lint # ruff + import-linter
|
||||||
@@ -473,7 +561,7 @@ make ci # 只读全量验证
|
|||||||
## 文档导航
|
## 文档导航
|
||||||
|
|
||||||
| 想了解 | 看 |
|
| 想了解 | 看 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 全部架构决策及理由(单一事实源) | `research-wiki/ARCHITECTURE.md` |
|
| 全部架构决策及理由(单一事实源) | `research-wiki/ARCHITECTURE.md` |
|
||||||
| 里程碑与状态 | `research-wiki/ROADMAP.md` |
|
| 里程碑与状态 | `research-wiki/ROADMAP.md` |
|
||||||
| 项目迁移指南(删除清单/组件映射/行为审计) | `research-wiki/migrations/` |
|
| 项目迁移指南(删除清单/组件映射/行为审计) | `research-wiki/migrations/` |
|
||||||
|
|||||||
+4
-3
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "polygateway"
|
name = "polygateway"
|
||||||
version = "1.2.4"
|
version = "1.3.5"
|
||||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"pydantic>=2.8",
|
"pydantic>=2.8",
|
||||||
@@ -56,7 +56,7 @@ markers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py312"
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
@@ -81,6 +81,7 @@ layers = [
|
|||||||
"polygateway.config",
|
"polygateway.config",
|
||||||
"polygateway.middleware",
|
"polygateway.middleware",
|
||||||
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
||||||
|
"polygateway.thinking",
|
||||||
"polygateway.providers : polygateway.sources",
|
"polygateway.providers : polygateway.sources",
|
||||||
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
||||||
]
|
]
|
||||||
|
|||||||
+146
-27
@@ -38,7 +38,7 @@ resp = await client.chat(messages) # resp: LLMResponse
|
|||||||
### 1.2 能力对比矩阵
|
### 1.2 能力对比矩阵
|
||||||
|
|
||||||
| 能力 | Video-Tree-TRM5 | GovDoc-SaaS | CHSAnalyzer |
|
| 能力 | Video-Tree-TRM5 | GovDoc-SaaS | CHSAnalyzer |
|
||||||
|---|---|---|---|
|
| --- | --- | --- | --- |
|
||||||
| 治理网关(重试/退避/超时) | ✅ `GovernedLLMClient` | ✅ 同款移植 | ✅ Invoker/Governance 分层(结构最好) |
|
| 治理网关(重试/退避/超时) | ✅ `GovernedLLMClient` | ✅ 同款移植 | ✅ Invoker/Governance 分层(结构最好) |
|
||||||
| 错误分类 | ⚠️ 二分类(瞬时/致命) | ⚠️ 同款 | ✅ 三分类 + Retry-After 解析 + 工件级失败 |
|
| 错误分类 | ⚠️ 二分类(瞬时/致命) | ⚠️ 同款 | ✅ 三分类 + Retry-After 解析 + 工件级失败 |
|
||||||
| 限流 | ❌ 仅 `asyncio.Semaphore` | ❌ 完全没有 | ✅ Redis+Lua 六道闸(并发/RPM/TPM × 全局/单源) |
|
| 限流 | ❌ 仅 `asyncio.Semaphore` | ❌ 完全没有 | ✅ Redis+Lua 六道闸(并发/RPM/TPM × 全局/单源) |
|
||||||
@@ -65,7 +65,7 @@ resp = await client.chat(messages) # resp: LLMResponse
|
|||||||
### 1.4 各项目关键资产索引(移植蓝本)
|
### 1.4 各项目关键资产索引(移植蓝本)
|
||||||
|
|
||||||
| 资产 | 来源 | 移植去向(§7) |
|
| 资产 | 来源 | 移植去向(§7) |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| 治理网关主循环(参考结构,需重构掉遥测复制) | `Video-Tree/adapters/llm.py`、`GovDoc/packages/docagent-core/src/docagent_core/llm/client.py` | client + middleware |
|
| 治理网关主循环(参考结构,需重构掉遥测复制) | `Video-Tree/adapters/llm.py`、`GovDoc/packages/docagent-core/src/docagent_core/llm/client.py` | client + middleware |
|
||||||
| 三层流式活性看门狗(纯函数,近乎原样复用) | 三项目同款 `streaming.py` | `streaming.py` |
|
| 三层流式活性看门狗(纯函数,近乎原样复用) | 三项目同款 `streaming.py` | `streaming.py` |
|
||||||
| 进程内熔断器(时钟注入、单探针) | `Video-Tree/adapters/breaker.py` | `backends/memory/` |
|
| 进程内熔断器(时钟注入、单探针) | `Video-Tree/adapters/breaker.py` | `backends/memory/` |
|
||||||
@@ -108,7 +108,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
|||||||
### 2.3 非目标(已确认,含理由)
|
### 2.3 非目标(已确认,含理由)
|
||||||
|
|
||||||
| 不做 | 理由(讨论结论) | 归属 |
|
| 不做 | 理由(讨论结论) | 归属 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| 任务队列(arq)/任务编排 | 队列单位是业务任务,库单位是单次调用,高度不同;强行进库会迫使批处理项目部署队列、并把"任务"业务概念污染进零业务假设的库。Video-Tree 声明了 arq 依赖却从未使用(死依赖)是现实佐证 | 业务侧 |
|
| 任务队列(arq)/任务编排 | 队列单位是业务任务,库单位是单次调用,高度不同;强行进库会迫使批处理项目部署队列、并把"任务"业务概念污染进零业务假设的库。Video-Tree 声明了 arq 依赖却从未使用(死依赖)是现实佐证 | 业务侧 |
|
||||||
| 视频抽帧(ffmpeg)、图像裁剪/拼接/增强等预处理 | 纯业务先验(超声图表格在左上角、每 5 帧一批等),且会拖入 ffmpeg/PIL/numpy 重依赖;库只收就绪的 content 数组/图像字节 | 业务侧 |
|
| 视频抽帧(ffmpeg)、图像裁剪/拼接/增强等预处理 | 纯业务先验(超声图表格在左上角、每 5 帧一批等),且会拖入 ffmpeg/PIL/numpy 重依赖;库只收就绪的 content 数组/图像字节 | 业务侧 |
|
||||||
| OCR 结果的几何映射(坐标换算/归一化/marker 推算) | 同上,业务先验;库只返回 OCR 服务的原生 bbox + page_size | 业务侧 |
|
| OCR 结果的几何映射(坐标换算/归一化/marker 推算) | 同上,业务先验;库只返回 OCR 服务的原生 bbox + page_size | 业务侧 |
|
||||||
@@ -128,6 +128,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
|||||||
**决策**: 借鉴 Clean Architecture 的三条原则——依赖规则(核心不依赖具体技术)、端口与适配器(Protocol 定义接缝)、组装点(所有构造集中注入);**不照搬**其面向应用的四层分层(Entities/Use Cases/Interface Adapters/Frameworks)。库内部的组织模式采用**中间件洋葱**(同 ASGI middleware / gRPC interceptor / Rust tower):重试、限流、熔断、缓存、遥测各为一层,层与层正交,顺序与取舍是配置。
|
**决策**: 借鉴 Clean Architecture 的三条原则——依赖规则(核心不依赖具体技术)、端口与适配器(Protocol 定义接缝)、组装点(所有构造集中注入);**不照搬**其面向应用的四层分层(Entities/Use Cases/Interface Adapters/Frameworks)。库内部的组织模式采用**中间件洋葱**(同 ASGI middleware / gRPC interceptor / Rust tower):重试、限流、熔断、缓存、遥测各为一层,层与层正交,顺序与取舍是配置。
|
||||||
|
|
||||||
**背景与讨论**: 人类提问"是否借鉴《Clean Architecture》,是否有更好的指导思想"。结论:那本书为应用程序而写,库没有"用例层",硬套四层会造出空转抽象。对库更适配的思想来源:
|
**背景与讨论**: 人类提问"是否借鉴《Clean Architecture》,是否有更好的指导思想"。结论:那本书为应用程序而写,库没有"用例层",硬套四层会造出空转抽象。对库更适配的思想来源:
|
||||||
|
|
||||||
- **Hexagonal / Ports & Adapters**(Cockburn):三项目已在实践的本质。
|
- **Hexagonal / Ports & Adapters**(Cockburn):三项目已在实践的本质。
|
||||||
- **《A Philosophy of Software Design》(Ousterhout)的"深模块、窄接口"**:接口复杂度是用户付的成本。落地为——90% 用户三行起步(`from_env()` → `chat()`),全部可配置性经构造函数暴露给需要的人,但绝不强迫简单用户理解。
|
- **《A Philosophy of Software Design》(Ousterhout)的"深模块、窄接口"**:接口复杂度是用户付的成本。落地为——90% 用户三行起步(`from_env()` → `chat()`),全部可配置性经构造函数暴露给需要的人,但绝不强迫简单用户理解。
|
||||||
- **中间件洋葱**:与治理栈天然同构。反面证据:三项目的 `GovernedLLMClient.chat()` 是约 500 行的方法,五层治理手工内联在一个重试循环里,横切关注点没有被切开,遥测调用因此被迫复制 4 次。洋葱模型下遥测就是一层,只写一次。
|
- **中间件洋葱**:与治理栈天然同构。反面证据:三项目的 `GovernedLLMClient.chat()` 是约 500 行的方法,五层治理手工内联在一个重试循环里,横切关注点没有被切开,遥测调用因此被迫复制 4 次。洋葱模型下遥测就是一层,只写一次。
|
||||||
@@ -143,7 +144,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
|||||||
**背景与讨论**: 人类要求完整阐述官方 SDK 与手写的差异优劣。核心对比:
|
**背景与讨论**: 人类要求完整阐述官方 SDK 与手写的差异优劣。核心对比:
|
||||||
|
|
||||||
| 维度 | 手写 httpx | 官方 SDK(openai) |
|
| 维度 | 手写 httpx | 官方 SDK(openai) |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| SSE 协议解析(帧格式、畸形帧、usage 帧、[DONE]) | 自己写自己修(约 200 行),但全可控 | SDK 维护,跟随协议演进 |
|
| SSE 协议解析(帧格式、畸形帧、usage 帧、[DONE]) | 自己写自己修(约 200 行),但全可控 | SDK 维护,跟随协议演进 |
|
||||||
| 错误分类 | 状态码 + body 字符串匹配,自己写 | 类型化异常层级(RateLimitError 等),映射干净 |
|
| 错误分类 | 状态码 + body 字符串匹配,自己写 | 类型化异常层级(RateLimitError 等),映射干净 |
|
||||||
| 非标字段(qwen `enable_thinking`、deepseek `reasoning_content`) | 天然支持 | `extra_body` 写入 + `model_extra` 读出,**够用** |
|
| 非标字段(qwen `enable_thinking`、deepseek `reasoning_content`) | 天然支持 | `extra_body` 写入 + `model_extra` 读出,**够用** |
|
||||||
@@ -219,6 +220,12 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
|||||||
|
|
||||||
**决策**: 消灭 `"qwen" in provider`、`model.split("-")[0]` 式字符串猜测。显式 provider 注册表,每个 provider 声明:thinking 参数注入方式(deepseek `{"thinking":{"type":"enabled"}}` / qwen `{"enable_thinking": True}`)、思考流字段(`reasoning_content` / `<think>` 标签剥离)、原生 schema 能力(供 D7 策略选择)、默认错误翻译细则。新 provider = 注册一个条目,不改核心类。
|
**决策**: 消灭 `"qwen" in provider`、`model.split("-")[0]` 式字符串猜测。显式 provider 注册表,每个 provider 声明:thinking 参数注入方式(deepseek `{"thinking":{"type":"enabled"}}` / qwen `{"enable_thinking": True}`)、思考流字段(`reasoning_content` / `<think>` 标签剥离)、原生 schema 能力(供 D7 策略选择)、默认错误翻译细则。新 provider = 注册一个条目,不改核心类。
|
||||||
|
|
||||||
|
**职责拆分(2026-08-25,issue #16/#17)**: 上面这条决策里的**推理**部分已从 `providers.py` 移出,落进新模块 `thinking.py`。起因是推理这件事从「请求侧注入什么参数」长成了「请求侧注入 + 响应侧裁定 + 两者对账」三件事,留在注册表里会让 `providers.py` 变成「推理的一切」,一句话说不清职责(P3)。拆后 `providers.py` 只回答**provider 是什么**(`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`/`register_provider`),`thinking.py` 承载**推理这件事的全部决策**(`ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`/`register_capability`、`resolve_thinking`、`observe_thinking`、`reconcile_thinking`、`ThinkingUnsupportedError`);纯值类型 `ThinkingObservation` 归最内层 `types.py`(§5.1)。六个公共符号同批提升到包根导出——此前只能深路径 import,而深路径引用正是模块重组会打断下游的原因。
|
||||||
|
|
||||||
|
**1.3.4 受管推理契约(2026-09-09 已批准)**:AUTO=要求开启、不指定强度;空 on_base 仅是协议无需开启字节,不是任意模型默认推理。已登记模型必须含 AUTO 才接受 True/AUTO,nearest 不将 AUTO 代选强度;未知模型按已知 wire 尽力+warning,不保证开启。MiniMax on_base 改空,M3 仍不含 AUTO;M2.5/M2.7 空 wire 真实复验待完成,不新增能力条目。
|
||||||
|
|
||||||
|
有受管意图(含 NONE/糖/未知模型)时,source.extra_body 或 request.overlay 任一层出现标准控制根或当前 wire 两向控制根/effort_key 均拒绝,同值和后层遮蔽也不豁免;无意图保留 raw-only,不推断 applied。on_base 不得含自己的 effort_key 或标准 reasoning_effort/output_config.effort,点号仍是字面顶层键,不新增私有方言解释器。工厂源级校验在装配期;chat 仅前置校验显式请求档与已知 raw;默认 transport 对选中源完整校验并在 HTTP 前 RequestRejected,可能已经准入,finally 结算不变。自定义 transport 由端口实现方履约,不新增 preflight。细则与 M1–M9 见[批准设计 §4–5](designs/2026-09-09-134-thinking-contracts-design.md)。
|
||||||
|
|
||||||
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
|
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
|
||||||
|
|
||||||
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
|
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
|
||||||
@@ -305,7 +312,7 @@ flowchart TB
|
|||||||
> 2026-07-20 修订(CHS 迁移文档缺口 G3): 初版把熔断/限流画在重试循环外,与"每次重试重新过限流闸"的理由自相矛盾,且熔断/限流是 **per-source** 的——源在循环内才被选出,准入只能发生在循环内。修订后与 CHSAnalyzer 实践(`governance.py:120-167` 逐次尝试执行选源→熔断→permit)一致。
|
> 2026-07-20 修订(CHS 迁移文档缺口 G3): 初版把熔断/限流画在重试循环外,与"每次重试重新过限流闸"的理由自相矛盾,且熔断/限流是 **per-source** 的——源在循环内才被选出,准入只能发生在循环内。修订后与 CHSAnalyzer 实践(`governance.py:120-167` 逐次尝试执行选源→熔断→permit)一致。
|
||||||
|
|
||||||
| 相对顺序 | 理由 |
|
| 相对顺序 | 理由 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 遥测最外 | 观测一切,包括缓存命中与各类失败;任何路径都留痕 |
|
| 遥测最外 | 观测一切,包括缓存命中与各类失败;任何路径都留痕 |
|
||||||
| 缓存在重试循环外 | 缓存命中不打网关:不消耗限流配额、不受熔断状态影响 |
|
| 缓存在重试循环外 | 缓存命中不打网关:不消耗限流配额、不受熔断状态影响 |
|
||||||
| 结构化在缓存内、重试外(2026-07-20 M1 设计) | 带反馈重问 = 再次调用内层,天然照过限流/熔断门、逐次遥测;缓存只固化阶梯通过的最终结果 |
|
| 结构化在缓存内、重试外(2026-07-20 M1 设计) | 带反馈重问 = 再次调用内层,天然照过限流/熔断门、逐次遥测;缓存只固化阶梯通过的最终结果 |
|
||||||
@@ -325,6 +332,32 @@ flowchart TB
|
|||||||
6. **开路/全源耗尽**: `CircuitOpenError` / `AllSourcesExhausted` → 按配置 wait(等待恢复,含 stall 判定)或 fail-fast 上抛。
|
6. **开路/全源耗尽**: `CircuitOpenError` / `AllSourcesExhausted` → 按配置 wait(等待恢复,含 stall 判定)或 fail-fast 上抛。
|
||||||
7. **任意时刻取消**: `CancelledError` 穿透所有层;in-flight permit 与连接在 finally 释放。
|
7. **任意时刻取消**: `CancelledError` 穿透所有层;in-flight permit 与连接在 finally 释放。
|
||||||
|
|
||||||
|
### 4.5 资源所有权纪律: 谁建的谁关,注入的一律不碰(2026-08-24,issue #15)
|
||||||
|
|
||||||
|
这是**跨子系统的通用纪律**,不是遥测的局部约定。它被写下来的直接原因是: 库对"谁建的、谁负责关"从来没有统一说法,于是同一个根因在三个地方长出三种形态——
|
||||||
|
|
||||||
|
| 形态 | 位置(修复前) | 性质 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry,共享 recorder 被第一个关闭的 client 弄死(`embedding.py`/`ocr.py` 各有一份逐字复制) | `client.py:271-273` | 越权 |
|
||||||
|
| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 |
|
||||||
|
| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不持有 limiter/breaker 的引用) | `client.py:263-280` | 泄漏 |
|
||||||
|
| 对照组: `RedisLimiter._owns_client` 的纪律**一直是对的** | `limiter.py:185-191, 318-322` | 正确先例 |
|
||||||
|
|
||||||
|
纪律把已有的那个正确先例推广为全库唯一说法,分两层落地:
|
||||||
|
|
||||||
|
| 层 | 所有权归属 | 落法 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 |
|
||||||
|
| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性,`aclose` 只关自建的;三处复制的 `getattr(..., "aclose")` 鸭子探测收敛为一个内部 helper(同时探测 `aclose`/`close`,SQLite recorder 只有同步 `close()`) |
|
||||||
|
|
||||||
|
三条实现细则各自都是"少写一条就等于纪律不成立":
|
||||||
|
|
||||||
|
1. **默认必须是"不拥有"**。`__init__` 是全量注入路径,经它传入的一切组件一律 `_owns_* = False`,只有三个工厂在真正自建时置 True。默认若反过来,直接构造路径下共享 transport 仍会被第一个 client 关掉。
|
||||||
|
2. **判定一律用 `is None` / `is not None`,不用 `or`**。工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好。
|
||||||
|
3. **三个 client(chat/embedding/ocr)必须逐一持有 limiter/breaker 引用并各自被测试钉一次**。收敛成 helper 之后仍要三处各钉一次,否则下次有人把逻辑复制回去无人发现;`GatewayClient` 此前把 limiter/breaker 交给 `RetryMW` 后自己不留引用,`aclose` 因此触达不到自建的 redis 客户端,泄漏就是这么来的。
|
||||||
|
|
||||||
|
公共 API 面零变化(`_owns_*` 是私有属性)。**对下游的可见后果**只有一条,且必须显式声明: `aclose()` 不再关闭注入进来的组件,若有下游依赖了"注入后由 client 代关",升级后需自己关。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 核心类型
|
## 5. 核心类型
|
||||||
@@ -334,7 +367,7 @@ flowchart TB
|
|||||||
**兼容约束(硬)**: 以下字段为三项目现有消费面,只增不删不改名:
|
**兼容约束(硬)**: 以下字段为三项目现有消费面,只增不删不改名:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| `content` | str | 正式输出文本 |
|
| `content` | str | 正式输出文本 |
|
||||||
| `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
|
| `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
|
||||||
| `model` / `provider` | str | 溯源 |
|
| `model` / `provider` | str | 溯源 |
|
||||||
@@ -344,23 +377,41 @@ flowchart TB
|
|||||||
| `cache_hit` | bool | 是否缓存命中 |
|
| `cache_hit` | bool | 是否缓存命中 |
|
||||||
| `call_id` | str | UUID,每次**尝试**独立 |
|
| `call_id` | str | UUID,每次**尝试**独立 |
|
||||||
|
|
||||||
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)。
|
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)、`thinking_observation`(2026-08-25,issue #16/#17,见下)、`applied_effort`(2026-09-05,issue #20,见 §7.5/§7.8 与下文推理档位段:本次**实际**跑在哪一档,`nearest` 映射后与请求档分叉,`None` = 调用方不表态或该路径无推理语义)。
|
||||||
|
|
||||||
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
|
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
|
||||||
|
|
||||||
| 字段 | 含义 | 生产者 |
|
| 字段 | 含义 | 生产者 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| `cached_prompt_tokens` | **供应商侧** prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`)。`None` = 该源未上报;`0` = 上报了一次真实零命中——两者对下游处置不同(前者不可做缓存成本校正),故不可混同 | `openai_compat` 两条路径解析后经 `TransportResult` 上浮 |
|
| `cached_prompt_tokens` | **供应商侧** prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`)。`None` = 该源未上报;`0` = 上报了一次真实零命中——两者对下游处置不同(前者不可做缓存成本校正),故不可混同 | `openai_compat` 两条路径解析后经 `TransportResult` 上浮 |
|
||||||
| `model_reported` | API 响应体里的 `model` 字段;`None` = 未上报。与 `model`(`.env` 配置别名)可能分叉——供应商把别名指向新权重时,实验复现必须认这个串 | 流式取首个含 `model` 的 chunk(首次写入即固定),非流式取 body 顶层 |
|
| `model_reported` | API 响应体里的 `model` 字段;`None` = 未上报。与 `model`(`.env` 配置别名)可能分叉——供应商把别名指向新权重时,实验复现必须认这个串 | 流式取首个含 `model` 的 chunk(首次写入即固定),非流式取 body 顶层 |
|
||||||
|
|
||||||
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。
|
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。
|
||||||
|
|
||||||
|
**推理观测三态 `thinking_observation`(2026-08-25,issue #16/#17)**: 类型 `ThinkingObservation`(`StrEnum`),缺省 `UNKNOWN`。回答的问题是「这次调用到底推理没推理」,由多信号裁定:
|
||||||
|
|
||||||
|
| 值 | 含义 | 判据(按证据硬度排序) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `observed` | 确证本次推理发生 | 推理正文 `thinking.strip()` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
|
||||||
|
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
|
||||||
|
| `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 |
|
||||||
|
|
||||||
|
**测试证据边界(1.3.4)**:运行时 UNKNOWN 不告警不等于关闭测试成功。关闭须完整合格轮次全 ABSENT;不可关闭命题在完整合格轮次有 OBSERVED 可支持本条件下未关闭,全 ABSENT 证伪,无 OBSERVED 但 UNKNOWN 仅未覆盖。开启保留完整计划分母与多数 OBSERVED,不丢失败轮。身份缺失只有独立原始 JSON 证据才可归上游;公共身份丢失且无取证 FAIL,成功 SSE 不新增捕获器。默认 FAIL,仅完整请求/唯一尝试/完整无重复键 JSON/404 精确 error.type=model_not_found 可自动 UNCOVERED;一般400、429、5xx、解析与治理异常不整类 skip,预期拒绝另按预声明机器字段断言。逐轮安全报告在 tests/outputs,写失败 FAIL,不增加生产数据面。
|
||||||
|
|
||||||
|
三态**不可折叠为布尔**: `unknown`(判不出)与 `absent`(确证没有)语义不同,把前者读作后者正是 `reasoning_tokens=None` 制造的那个歧义——MiniMax-M3 非流式开启推理时,推理内容已计费却不回传正文(2026-08-25 实测 completion 53 vs 关闭档 3),该档只能判 `unknown`,宣称「没推理」即撒谎。缺省取 `UNKNOWN` 使任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
|
||||||
|
|
||||||
|
判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。
|
||||||
|
|
||||||
|
**声明 × 观测对账(同批;判据 2026-09-05 由布尔改档位,issue #20)**: `reconcile_thinking` 把本次**实际发出去的档位**(`effort: Effort | None`,即 `TransportResult.applied_effort`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。判据必须写成 `effort is Effort.NONE` 的**身份比较**才落「要求关闭」一支,其余任何档(含 `auto`)落「要求开启」一支——`Effort.NONE` 的取值是非空串 `"none"`,任何靠真值性的写法(`if not effort`)恒为假,会把每个强度档送进关闭分支、告警方向整个颠倒。`none × unknown` 与 `None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, 生效档位)` 集合(第三段 2026-09-05 由 `enable_thinking` 改为**实际发出的档**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音;而档位根本不经过 `enable_thinking` 那个字段,不改就是同一模型的所有档共用一个键),与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。
|
||||||
|
|
||||||
|
这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),成本是一次枚举比较。但**保障只覆盖可观测路径**: M3 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。
|
||||||
|
|
||||||
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
|
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
|
||||||
|
|
||||||
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
||||||
|
|
||||||
| 值 | 含义 | 生产者 | cost |
|
| 值 | 含义 | 生产者 | cost |
|
||||||
|---|---|---|---|
|
| --- | --- | --- | --- |
|
||||||
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
|
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
|
||||||
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
|
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
|
||||||
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
|
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
|
||||||
@@ -396,7 +447,7 @@ flowchart TB
|
|||||||
### 6.1 统一四分类 + 熔断信号(融合 CHSAnalyzer 三分类与 GovDoc 二分类)
|
### 6.1 统一四分类 + 熔断信号(融合 CHSAnalyzer 三分类与 GovDoc 二分类)
|
||||||
|
|
||||||
| 错误类 | 触发 | 重试 | 换源 | 熔断计数 |
|
| 错误类 | 触发 | 重试 | 换源 | 熔断计数 |
|
||||||
|---|---|---|---|---|
|
| --- | --- | --- | --- | --- |
|
||||||
| `TransientError` | 超时/5xx/429/网络抖动/SSE 异常(畸形帧、断流无 [DONE])/看门狗超时 | ✅ 退避后 | ✅ | ✅ |
|
| `TransientError` | 超时/5xx/429/网络抖动/SSE 异常(畸形帧、断流无 [DONE])/看门狗超时 | ✅ 退避后 | ✅ | ✅ |
|
||||||
| `SourceDeadError` | 401/403/欠费/insufficient_quota(429 body 细分) | ❌ | ✅ 立即 | ✅ force_open |
|
| `SourceDeadError` | 401/403/欠费/insufficient_quota(429 body 细分) | ❌ | ✅ 立即 | ✅ force_open |
|
||||||
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
|
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
|
||||||
@@ -414,7 +465,7 @@ flowchart TB
|
|||||||
### 6.2 翻译规则(transport 层职责)
|
### 6.2 翻译规则(transport 层职责)
|
||||||
|
|
||||||
| 输入 | 翻译为 |
|
| 输入 | 翻译为 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| httpx Timeout/Transport 错误、`StreamLivenessTimeout`、SSE 异常 | `TransientError` |
|
| httpx Timeout/Transport 错误、`StreamLivenessTimeout`、SSE 异常 | `TransientError` |
|
||||||
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
|
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
|
||||||
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
|
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
|
||||||
@@ -480,12 +531,16 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.5 响应缓存
|
### 7.5 响应缓存
|
||||||
|
|
||||||
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`。
|
**1.3.4 显式迁移前置(D3)**:key 与源指纹不新增包版本、语义 revision、fallback、能力表或 wire 版本。AUTO/raw/MiniMax 语义变更及同版本 fallback/能力表/自定义 wire 改变时,受影响调用集合必须在首次读写前切到从未承载旧语义的 namespace 或 salt。保留租户前缀与 epoch;覆盖工厂默认、全量注入和 per-call(只改默认对覆盖路径无效)。同一共享缓存身份只要一源受影响,整个调用集合须隔离或由下游显式拆分;不强制未受影响 chat 冷启动。新旧版本不共享新身份,回滚旧身份会重见旧值。**未迁移仍可能回放旧响应、绕过新拒绝**,库不会自动检查新可满足性;操作说明不能当自动防护。
|
||||||
|
|
||||||
|
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`。
|
||||||
|
|
||||||
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
|
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
|
||||||
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
|
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
|
||||||
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。
|
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。
|
||||||
- `sampling`(2026-07-31,issue #4): 调用级采样参数,**仅非空时参与**(注意与 `salt` 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无差别),故空 overlay 时旧键逐字不变、存量缓存不冷启动。读 `request.sampling` 而非 `request.overlay`,不依赖"CacheMW 恰在 StructuredMW 外侧"的层序巧合。**不进 key 的后果**: 同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错——受控实验静默作废。源级 `extra_body` 同理并入 `model_fingerprint`(全源皆空时字面量不变,否则追加 `|sha256(...)`,摘要对象是各源 `(model, extra_body)` 的 canonical JSON 排序去重——按模型而非源名,改源名不误触冷启动)。
|
- `sampling`(2026-07-31,issue #4): 调用级采样参数,**仅非空时参与**(注意与 `salt` 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无差别),故空 overlay 时旧键逐字不变、存量缓存不冷启动。读 `request.sampling` 而非 `request.overlay`,不依赖"CacheMW 恰在 StructuredMW 外侧"的层序巧合。**不进 key 的后果**: 同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错——受控实验静默作废。源级 `extra_body` 同理并入 `model_fingerprint`(全源皆空时字面量不变,否则追加 `|sha256(...)`,摘要对象是各源 `(model, extra_body)` 的 canonical JSON 排序去重——按模型而非源名,改源名不误触冷启动)。
|
||||||
|
- `reasoning_effort`(2026-09-05,issue #20): **请求级**档位,仅 `is not None` 时参与(判据不能用真值性——`Effort.NONE` 是「明确要求不推理」,与 `None`「不表态」拿到的是两种响应,合并即毒化)。它不能靠 `model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,同一个 client 上跑 low 与 max 在它眼里毫无分别,不进 key 就是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。**源级** `reasoning_effort` 则与 `enable_thinking` 同规则并入 `_fingerprint_mark`(仅表态时追加,故全源不表态时字面量逐字不变、存量缓存不冷启动;两者取值域不相交,`"none"`/`"low"`… vs `true`/`false`,追进同一个列表不会摘要成同一身份)。
|
||||||
|
- **key 记的是请求档,不是 `nearest` 映射后的生效档**: `CacheMW` 在洋葱里比 transport 更外一层,查缓存时 `resolve_thinking` 尚未执行,生效档根本拿不到。副作用是被映射到同一档的两个请求各占一个缓存槽(存两份相同响应,浪费但不毒化)。**由此的已知边界**: 能力表更新导致映射结果变化时(如某模型新增 `minimal` 档),请求档算出的 key 不变而实际发出的字节变了,会命中按旧映射存下的响应——能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace` 或 `cache_salt`。
|
||||||
- **两条已知副作用**: ① 逐 rollout 变化的 `seed` 进 key 后该路径天然全部 miss(正确语义,但缓存对它不再省钱);② `model_fingerprint` 是**集合级**指纹而非本次选中源的指纹,同 scope 各源 `extra_body` 不同时仍可能返回另一源的响应(既有取舍的延续,与 `model` 同),要求逐源可复现应让每源独享 scope 或 namespace。
|
- **两条已知副作用**: ① 逐 rollout 变化的 `seed` 进 key 后该路径天然全部 miss(正确语义,但缓存对它不再省钱);② `model_fingerprint` 是**集合级**指纹而非本次选中源的指纹,同 scope 各源 `extra_body` 不同时仍可能返回另一源的响应(既有取舍的延续,与 `model` 同),要求逐源可复现应让每源独享 scope 或 namespace。
|
||||||
- value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
|
- value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
|
||||||
|
|
||||||
@@ -495,7 +550,7 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.7 多源与选源
|
### 7.7 多源与选源
|
||||||
|
|
||||||
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
|
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`reasoning_effort` 与 `effort_fallback`(2026-09-05 issue #20: 前者是本源默认推理档位,`None` = 不表态、`Effort.NONE` = 要求不推理,构造期与 `enable_thinking` 语义矛盾即 `ValueError`;后者取 `error`(缺省)或 `nearest`,决定请求档打空时报错还是映射到最近档)/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
|
||||||
|
|
||||||
**TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。
|
**TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。
|
||||||
|
|
||||||
@@ -507,28 +562,90 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.8 遥测与成本
|
### 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**、**reasoning_tokens**、**tenant_id**、**meta**。
|
**必录字段**(继承三项目 15 字段规范;当前 36 个 INSERT 字段,物理表列 37 = 36 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): 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**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**。
|
||||||
|
|
||||||
**`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。
|
**`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 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 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'` 同一纪律)。
|
**`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` 路径,失败仍只逐行降级、不判死。
|
**`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` 路径,失败仍只逐行降级、不判死。
|
||||||
|
|
||||||
|
**`reasoning_effort` 列(1.3.4 四种行来源澄清,不改 schema)**:TEXT 可空,NULL 与明确要求不推理的 `'none'` 不同。只有真实成功尝试读 transport 的 response.applied_effort(nearest 后,不重算);AUTO 是编码选择,不是服务端内部强度,未知 AUTO 不构成能力验证,raw-only 为 NULL。
|
||||||
|
|
||||||
|
| 行类型 | 来源/限制 |
|
||||||
|
| --- | --- |
|
||||||
|
| 真实成功尝试 | response.applied_effort;未知/raw-only 限制如上 |
|
||||||
|
| 失败尝试 | effective_effort(请求>源>糖)的意图,可能零 HTTP,不能称实际发出 |
|
||||||
|
| cache_hit | 本次请求级 reasoning_effort,不读历史 applied、不推源级;观测回放历史,不是本次实测 |
|
||||||
|
| scope 终态失败 | 本次请求级 reasoning_effort,可能尚未选源,不补逐次根因 |
|
||||||
|
|
||||||
|
实际档分析须 `cache_hit=false AND error IS NULL`。embedding/OCR text/layout 的成功与失败尝试由 reasoning_applies=False 保证 NULL,真实 client→emitter→临时 SQLite 与 chat 阳性共同守卫,不能用空行集合证明。生产 emitter 单一出口、端口字段数和 DDL 不变。
|
||||||
|
|
||||||
|
**逻辑调用十列(2026-09-09,issue #19/#23,端口 26 → 36)**: 本版把治理单位从"一次尝试"补齐到"一次逻辑调用"。十列按同一顺序追加在 `reasoning_effort` 之后,全部可空、无默认值、不回填旧行(NULL 表达的是"补列之前根本没记过这件事",与任何哨兵值不同;这一点与 `tenant_id` 故意相反——后者是 RLS 可见性需要哨兵,前者是归因需要区分真实缺口)。
|
||||||
|
|
||||||
|
| 列 | 语义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `scope` | 池名,`TelemetryEmitter` 构造期注入(三个 client 各一行),三类行都带;**不拿 `source_name` 顶替**(终态失败可能根本没选出源) |
|
||||||
|
| `operation` | `chat` / `embed` / `recognize_text` / `parse_layout`,**由调用点给定**。它与 `PolyGatewayError.operation` 是两个语义: 后者是异常自报的出错环节(可为 `download_result` 这类子步骤),链路上任何位置不得读它来填本列 |
|
||||||
|
| `logical_call_id` | 一次逻辑调用一个 ID;上下文缺席(库内现场构造的请求)落 NULL,**不造 ID** |
|
||||||
|
| `event_kind` | 三态: `attempt` / `cache_hit` / `terminal_failure`,三类行的唯一机械判据 |
|
||||||
|
| `http_status_code` / `cause_type` / `error_body` | 只在失败的 `attempt` 行上非空(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了");终态行三列恒 NULL(**C1 红线**) |
|
||||||
|
| `error_type` | 该行自身错误的类名;取消路径传字符串,故为 NULL |
|
||||||
|
| `attempts` / `total_latency_ms` | 只属终态行(同一份冻结快照,与该行 `latency_ms` 同值),其余两类行 NULL |
|
||||||
|
|
||||||
|
两条不变量: **I3** —— 每次领域失败至多一条终态行(`_CallContext.claim_terminal()` 去重;recorder 写失败仍只 warning,故 SQL 可见 ≤ 1);**I4** —— 非领域异常(编程错)**零条**终态行、原样传播、分类不被改写。终态行仍 `cost=NULL`、`usage_source='unavailable'`、token 0,费用聚合口径不变。诊断值的提取收敛在 Emitter 内一个纯 helper(`_error_fields`),只读领域异常的既有属性,**不遍历任意对象、不解析字符串猜诊断**。另立**装配期形状闸**: 旧签名 recorder 在构造 `TelemetryEmitter` 时当场 `ValueError`——降级方向的铁律管的是**运行期写失败**,装配错误放行的后果是下游 100% 丢遥测且调用照常成功。
|
||||||
|
|
||||||
|
**`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。
|
||||||
|
|
||||||
|
**recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter` 的 `_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value`。`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 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)。
|
(`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)。
|
||||||
|
|
||||||
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
|
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
|
||||||
|
|
||||||
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"` 的 `text`、`response`、`thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。
|
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"` 的 `text`、`response`、`thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。**1.3.5 新增的两处诊断文本同样不在 cap 覆盖内**: `error_body` 沿用 `summarize_body` 的既有上限,结构化耗尽终态行的 `error` 说明自带有界限长(`repair=` 至多 200 字符 + `validation=` 至多 3 条 × 200 字符,**不含 `raw_text`**——模型正文的预算已由 attempt 行的 `response` 列承担)。两段限长常量与 `StructuredMW` 重问反馈共用同一份(`format_bounded_errors`),数值只有一份定义。
|
||||||
|
|
||||||
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
||||||
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
|
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
|
||||||
- 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
- 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
||||||
|
|
||||||
|
**遥测池的资源语义(2026-08-24,issue #15)**: `PostgresRecorder` 此前 `create_pool(dsn, timeout=10)` 继承 asyncpg 默认的 `min_size=max_size=10`,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限"(`pool.py:457` 的 `if self._minsize:`)——建池是一次全有全无的重资源动作: 拿不到 10 条就抛异常。这让遥测成为全库唯一预占资源的组件(httpx transport 与三个 redis 后端全是按需建连),也就成了共享实例余量紧张时**必然第一个倒下**的一环,而它承担的恰恰是最不该悄悄失败的职责。改为 `create_pool(dsn, min_size=0, max_size=<PGW_TELEMETRY_PG_POOL_MAX>, timeout=<预算>, command_timeout=<预算>)`,三条随之确立:
|
||||||
|
|
||||||
|
| 语义 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 建池零成本 | `min_size=0` 时 `_initialize` 只造 holder 对象、**一条连接都不连**(实测 0.000s,指向不可达端口也照样成功)。稳态占用由"每 client 常驻 10 条"变为"实际并发,闲时 0";真实 PG 实测: 建 recorder 后 0 → 一次写入后 1 → 20 行并发后 4(= `pool_max`)→ `aclose` 后 0 |
|
||||||
|
| 只暴露 `max_size` | `min_size` **有意不给配置项**: 它唯一的作用是把上面那个脆点装回来,换取的只是首次写入省下 ≈390ms 建连。库没有理由提供一个只会伤人的旋钮(P1+P5)。`max_size` 则必须暴露——继承第三方默认值等于库对自己的资源占用不表态(P4) |
|
||||||
|
| 写入有硬预算 | 整次写入(准备 + acquire + execute)由 `asyncio.timeout(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S)` 包一层,超时按行级丢弃。把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证 |
|
||||||
|
|
||||||
|
两处实现纪律,都是"看起来完成了、其实资源还挂着"的形态,必须写下来否则会被改回去: ① **不得用 `async with pool.acquire(...)`**——`Pool.release()` 是 `await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`),外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行的那个 shielded release **会正常等到完成**,业务路径真实上界变成 ≈ 2 × 预算;故改为显式 `acquire(timeout=<完整写入预算>)` + `finally: release(con, timeout=1s)`(内层传完整预算而非剩余量: 真正的上界是外层那一层 `asyncio.timeout`),释放超时即 `con.terminate()`,承诺精确化为"主写入尝试 ≤ 预算,释放路径独立有界"。② **`aclose()` 必须有界且终局**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时无限等、60 秒只发一条 warning(`pool.py:939-948, 961-972`),故走 `asyncio.wait_for` + 超时 `terminate()`;同时置 `_closed`,此后写入短路且**不复活**——原实现关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入方以为自己管着全部连接、实际早已不是(issue #15 实施期发现,是下面所有权根因的又一处表现)。
|
||||||
|
|
||||||
|
**遥测失败的三分判据(2026-08-24,issue #15)**: 判死判据此前挂在"**哪一步**失败"(`_open_pool` 失败即永久判死),而那一步里同时藏着两类性质完全不同的失败——DSN 非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。判据改挂"失败是**什么性质**",两句话说完:
|
||||||
|
|
||||||
|
1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。
|
||||||
|
2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。
|
||||||
|
|
||||||
|
| 档 | 覆盖(按 SQLSTATE 分类而非异常类白名单——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移) | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 配置级致命 | `ClientConfigurationError`(DSN 不可解析);`create_pool` 抛的 `ValueError`/`TypeError` | 永久 no-op + 一条 **error**(人配错了,不是 warning) |
|
||||||
|
| 环境级不可用 | SQLSTATE 类 `08`/`53`(含 53300 too many connections)/`57`/`28`/`3D`,具体码 `42501`(无权限)/`42P01`(表不存在);`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅在准备期路径可达**: 它是 `OSError` 子类,但写入期的超时先被 `record_llm_call` 的 `except TimeoutError` 接住并按行级丢弃,压根到不了本分类函数——见下方第 ④ 点);表确定不存在且建不出来 | **冷却降级**(内部常量 60s,不给配置项——无部署差异理由),到期放行**一次**重新准备,成功即恢复 |
|
||||||
|
| 行级拒绝 | 其余 `PostgresError`(`22`/`23` 等数据与约束类),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级,接入节流复述 |
|
||||||
|
|
||||||
|
四点必须一起记住,否则后来人会把判据改回去: ① **致命档窄到只剩 DSN 一类是有意的**——认证失败、库不存在、表建不出来一律归环境级,因为 DBA 改完密码/建完表就该自动恢复,而永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形;②**`42703` 是唯一具名例外**,按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了优先级更高的承诺——manual 档缺列时按现有列裁剪 `INSERT` 继续写、缺列以逐行 warning 暴露,即"部分列写进去了"这件事本身有价值,不该被冷却掉;新增例外必须同款论证。③ **认不出的失败一律归最轻档(行级)**,这个保守缺省在建池路径上是安全的,理由是 `min_size=0` 让建池不触库(实测 0.000s),"下次调用重试建池"本身**零成本**——原实现注释担心的"每次重试内联吞一次 connect 超时"在新语义下不再成立;④ **表里那条 `TimeoutError` 规则只在准备期路径可达,写入期不可达**(2026-08-24 合并前审查发现,**本轮只记录不改行为**): `record_llm_call` 的 `except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的任何超时都在那里被按行级丢弃,不会走到分类函数。真实后果是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用内联付满一个写入预算(缺省 5s)、丢一行、`degraded` 保持 False、**不进 60s 冷却**——即"冷却把最坏成本压成每 60s 一次、上界一个预算"这句承诺只在准备期路径上成立。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行走行级、不置 degraded"本就是明确记下的有意取舍(见下一段中"`degraded` 与 `dropped_rows` 覆盖的不是同一件事"那一条)。是否给"连续超预算丢行"升档,留作后续议题。
|
||||||
|
|
||||||
|
**降级的可见性与可编程性(2026-08-24,issue #15)**: 铁律里"遥测后端挂 → 静默降级"的"静默"指的是**不向调用方冒泡**,不是"没有日志、没有状态"。此前它被实现成了后者——全程只有一条 warning,长跑进程里等同于消失(issue 是人工比对"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的,期间 19 次调用一行未落);SQLite 侧更糟,初始化失败后写入直接 `return`,连 warning 都没有。"遥测必录"铁律的实质要求是: **库做不到必录时,必须持续、可编程地让下游知道**。落法是 `telemetry/status.py` 的 `TelemetryStatusTracker`——两个 recorder 共用、不含任何后端知识(只接受"降级了/恢复了/丢了一行"三个事实),进入与恢复各一条日志(**进入那条的级别由 `fatal` 决定,且只在 tracker 这一处决定**: 致命档 error——人配错了、本进程内不会自愈,其余 warning——外部状态、会自愈;recorder 侧不得再复制一条,否则同一事实两条日志、级别两个源头),降级期间按行数(100 行)与时间(300s)双阈值节流复述,`snapshot()` 给只读 `TelemetryStatus`(`degraded`/`fatal`/`reason`/`degraded_for_s`/`dropped_rows`/`retry_after_s`),经三个 client 的 `telemetry_status` 属性出口。三条设计约束:
|
||||||
|
|
||||||
|
- **不叫 `health`**: 该词在 `ports.py` 已被 `OcrTransport.check_health`(源探活)与 `SourceSelector.health(source_name) -> float`(成功率 EWMA)占用两次,库内 `health` 一律指"源的健康度";这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分(P2)。
|
||||||
|
- **不并入 `TelemetryRecorder` 主 Protocol**,新起**独立**端口 `TelemetryStatusProvider`: 前者是 `@runtime_checkable`,而 runtime 检查按属性存在性做——加一个成员会让所有只实现 `record_llm_call` 的对象**当场不再是** `TelemetryRecorder`,库内与下游的同款 `isinstance` 断言升级即断。client 侧取值经**一处** `isinstance` 判定,不重演 `aclose` 那种三处复制的鸭子类型。
|
||||||
|
- **`TelemetryStatus` 进顶层 `__all__`**(与 `SourceStats` 不同): 后者是端口内部快照、下游不消费,而本类型是 `client.telemetry_status` 的返回类型,下游要拿它做类型标注与对账——"顶层导出即公共 API 面"的约定要求它出现在那里。端口 `TelemetryStatusProvider` 则不导出(库外无实现者,导出即多一份永久承诺)。
|
||||||
|
- **`degraded` 与 `dropped_rows` 覆盖的不是同一件事,下游对账必须两个都看**: `degraded` 只在**环境级/致命级**失败(服务端真的说了"不可用",如 53300)时置位;而写入因**本地池饱和**超出写入预算被丢时走的是行级丢弃——`degraded` 保持 False,只有 `dropped_rows` 增长。这是有意的(池满是本进程并发过高,不是后端挂了,冷却 60s 只会白丢更多行),但只按 `degraded` 配告警的下游会**完全看不见**这一类丢行,而它恰恰是 `pool_max` 配小了的唯一信号。
|
||||||
|
- **SQLite 侧只做可见性**,不做 lazy 化与冷却重连: 它的失败模式(本地目录不可写、文件损坏)在装配期就暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确。这个不对称是已知且有理由的;tracker 与快照两侧共用,将来要对称时接口已就位。
|
||||||
|
|
||||||
|
**资源所有权在遥测侧的落点**: 通用纪律见 §4.5。对遥测的直接后果是 §7.7 R5 那条"共享必须显式注入"第一次真正可用——`PostgresRecorder(dsn, pool=<外部池>)` 与"多个 client 注入同一个 recorder"都不再被第一个 `aclose()` 弄死,issue #15 提的"共享池"方向由此以显式注入形态自然成立,不需要任何隐式全局注册表(那会违反"纯 asyncio 中立: 无全局状态、无模块级单例")。
|
||||||
|
|
||||||
### 7.9 结构化输出阶梯(D14)
|
### 7.9 结构化输出阶梯(D14)
|
||||||
|
|
||||||
| 级 | 内容 | 成本 |
|
| 级 | 内容 | 成本 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| ① 预防 | provider 注册表声明支持时,用 response_format / function calling 直接约束(`NativeSchemaStrategy`) | 无额外 |
|
| ① 预防 | provider 注册表声明支持时,用 response_format / function calling 直接约束(`NativeSchemaStrategy`) | 无额外 |
|
||||||
| ② 修复 | 围栏剥离 → json_repair → provider 变体归一化(DeepSeek 参数平铺等)(`JsonRepairStrategy`) | 零网络 |
|
| ② 修复 | 围栏剥离 → json_repair → provider 变体归一化(DeepSeek 参数平铺等)(`JsonRepairStrategy`) | 零网络 |
|
||||||
| ③ 校验 | 调用方传 pydantic 模型时库内做**形态**校验;语义校验留业务层 | 零网络 |
|
| ③ 校验 | 调用方传 pydantic 模型时库内做**形态**校验;语义校验留业务层 | 零网络 |
|
||||||
@@ -540,7 +657,7 @@ flowchart TB
|
|||||||
### 7.10 OCR 端口族
|
### 7.10 OCR 端口族
|
||||||
|
|
||||||
| 端口 | 对应 MonkeyOCR 端点 | 协议 | 输出 |
|
| 端口 | 对应 MonkeyOCR 端点 | 协议 | 输出 |
|
||||||
|---|---|---|---|
|
| --- | --- | --- | --- |
|
||||||
| `OcrTextPort.recognize_text(image: bytes)` | `POST /ocr/text` | multipart 上传 → JSON `{content}` | `OcrTextResult`(多行纯文本) |
|
| `OcrTextPort.recognize_text(image: bytes)` | `POST /ocr/text` | multipart 上传 → JSON `{content}` | `OcrTextResult`(多行纯文本) |
|
||||||
| `OcrLayoutPort.parse_layout(image: bytes)` | `POST /parse` | multipart → JSON(download_url) → GET ZIP → 解包 `*_middle.json` | `OcrLayoutResult`(elements 含 bbox + page_size) |
|
| `OcrLayoutPort.parse_layout(image: bytes)` | `POST /parse` | multipart → JSON(download_url) → GET ZIP → 解包 `*_middle.json` | `OcrLayoutResult`(elements 含 bbox + page_size) |
|
||||||
|
|
||||||
@@ -563,7 +680,8 @@ src/polygateway/
|
|||||||
├── config.py # GatewaySettings: 多源/韧性/装配键族聚合与装配守卫(M1 增补)
|
├── config.py # GatewaySettings: 多源/韧性/装配键族聚合与装配守卫(M1 增补)
|
||||||
├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.py / structured.py
|
├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.py / structured.py
|
||||||
├── transports/ # openai_compat.py / openai_sdk.py / monkey_ocr.py
|
├── transports/ # openai_compat.py / openai_sdk.py / monkey_ocr.py
|
||||||
├── providers.py # D11 provider 注册表
|
├── providers.py # D11 provider 注册表(只回答 provider 是什么)
|
||||||
|
├── thinking.py # 推理这件事的全部决策: 能力表 + 请求侧注入 + 响应侧裁定 + 对账
|
||||||
├── sources.py # SourceConfig + 选源策略
|
├── sources.py # SourceConfig + 选源策略
|
||||||
├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现)
|
├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现)
|
||||||
├── telemetry/ # sqlite.py / postgres.py / pricing.py
|
├── telemetry/ # sqlite.py / postgres.py / pricing.py
|
||||||
@@ -571,7 +689,7 @@ src/polygateway/
|
|||||||
└── streaming.py # 三层活性看门狗(纯函数)
|
└── streaming.py # 三层活性看门狗(纯函数)
|
||||||
```
|
```
|
||||||
|
|
||||||
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/`、`backends/`、`telemetry/`、`structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
|
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/`、`backends/`、`telemetry/`、`structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。`thinking.py`(2026-08-25)夹在**实现层与 `providers` 之间**: 它 import `providers.py` 的 `ProviderProfile`(故在其上),被 `transports/` 与 `client.py` import(故在其下);契约里写作独立一层 `polygateway.thinking`,插在 `transports | backends | telemetry | structured` 与 `providers : sources` 中间。**枚举 `ThinkingObservation` 因此必须留在 `types.py`**——它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让最内层反向依赖决策层,契约当场判红。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -587,13 +705,14 @@ src/polygateway/
|
|||||||
- **`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(2026-08-19,issue #14)**: 熔断全拒时的处置,与 `{SCOPE}__QUOTA_FULL` 同形同族(上一条"后端选择即配置"里记的 `PGW_QUOTA_FULL` 是 M1 定稿前的暂拟名,实际落地为 scope 键 `{SCOPE}__QUOTA_FULL`)。缺省 **fail_fast** = 存量下游的控制流逐字不变;**单源 scope 应显式配 `wait`**。两键值域相同但语义不同故分列: 配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),调用方可能想要"配额满就等、源坏了就立刻失败"。落到 `GatewaySettings.circuit_open`(无默认值,与既有全部字段一致),校验收敛在唯一消费者 `SourceAdmission` 一处——三个客户端构造函数此前各带一份 `quota_full` 校验,再加一键就是八处复制。
|
- **`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(2026-08-19,issue #14)**: 熔断全拒时的处置,与 `{SCOPE}__QUOTA_FULL` 同形同族(上一条"后端选择即配置"里记的 `PGW_QUOTA_FULL` 是 M1 定稿前的暂拟名,实际落地为 scope 键 `{SCOPE}__QUOTA_FULL`)。缺省 **fail_fast** = 存量下游的控制流逐字不变;**单源 scope 应显式配 `wait`**。两键值域相同但语义不同故分列: 配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),调用方可能想要"配额满就等、源坏了就立刻失败"。落到 `GatewaySettings.circuit_open`(无默认值,与既有全部字段一致),校验收敛在唯一消费者 `SourceAdmission` 一处——三个客户端构造函数此前各带一份 `quota_full` 校验,再加一键就是八处复制。
|
||||||
- **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。
|
- **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。
|
||||||
- **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
|
- **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
|
||||||
|
- **`PGW_TELEMETRY_PG_POOL_MAX` / `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(2026-08-24,issue #15)**: 两个可选键,**env 装配路缺省 4 与 5.0**。库必须对"自己该占多少资源"有一个可陈述的表态(不表态就等于继承第三方默认值,那正是 issue 的病根,见 §7.8),但**表态的落点是 `_load_pool_max`/`_load_write_timeout` 这条 env 装配路,不是字段默认值**: `GatewaySettings.telemetry_pg_pool_max` / `telemetry_pg_write_timeout_s` 与相邻三个遥测键**一样是无默认值的必填字段**,直接构造 `GatewaySettings` 的调用点需补两个参数(dataclass 语义上也只能如此——这两个字段后面跟着四个无默认值字段,就地加默认值即 `TypeError: non-default argument follows default argument`)。缺省写在 config 一处,`PostgresRecorder` 的 `pool_max`/`write_timeout_s` 是 keyword-only **必填**参数(与 `auto_migrate` 同一纪律: 缺省规则不与类签名漂移)。值域校验(`pool_max >= 1`、`write_timeout_s > 0`)落 `GatewaySettings._validate_telemetry`,与 `telemetry_text_cap` 同一先例覆盖**三条装配路**(直接构造 / `dataclasses.replace` / env),报错文本同时点字段名与 env 键名。两键都带 `PG` 前缀与 `PGW_TELEMETRY_PG_DSN` 对齐: SQLite 侧的等价物(`busy_timeout=5000`)本次不动,这个不对称是已知且有理由的(§7.8 末)。**冷却期 60s 有意不给键**——无部署差异理由(P1 YAGNI)。`pool_max` 的调参口径必须按实测折算而非按 `pool_max / RTT` 估算: 跨内网 RTT ≈ 123ms 的实验室 PG 上 `pool_max=4` 实测约 **15.6 行/秒**(50 行并发批 3.2s),一次 `INSERT` 的实际往返比一次 `SELECT 1` 重一倍。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 非功能性需求(强制覆盖,继承 Video-Tree CLAUDE.md §4.2.1 条款)
|
## 10. 非功能性需求(强制覆盖,继承 Video-Tree CLAUDE.md §4.2.1 条款)
|
||||||
|
|
||||||
| 维度 | 回答 |
|
| 维度 | 回答 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 持久化策略 | 遥测逐调用追加写(WAL);缓存写在响应成功后;崩溃最多丢当次调用的遥测记录 |
|
| 持久化策略 | 遥测逐调用追加写(WAL);缓存写在响应成功后;崩溃最多丢当次调用的遥测记录 |
|
||||||
| 幂等性 | 遥测 `INSERT OR IGNORE`(call_id 主键);缓存写幂等(同 key 同值);限流 permit 带 TTL 租约,进程死亡后自动过期回收 |
|
| 幂等性 | 遥测 `INSERT OR IGNORE`(call_id 主键);缓存写幂等(同 key 同值);限流 permit 带 TTL 租约,进程死亡后自动过期回收 |
|
||||||
| 断点续跑 | 库无长任务状态,天然无断点问题;响应缓存本身即业务侧重跑的加速器 |
|
| 断点续跑 | 库无长任务状态,天然无断点问题;响应缓存本身即业务侧重跑的加速器 |
|
||||||
@@ -612,7 +731,7 @@ src/polygateway/
|
|||||||
### 11.1 GovDoc-SaaS(难度低,首个迁移)
|
### 11.1 GovDoc-SaaS(难度低,首个迁移)
|
||||||
|
|
||||||
| 项目侧 | 处置 |
|
| 项目侧 | 处置 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `docagent-core/llm/client.py`、`breaker.py`、`redis_cache.py`、`streaming.py`、`telemetry_sqlite.py` | 删除,由库继任 |
|
| `docagent-core/llm/client.py`、`breaker.py`、`redis_cache.py`、`streaming.py`、`telemetry_sqlite.py` | 删除,由库继任 |
|
||||||
| `protocols.py` 的 `LLMProvider.chat(messages, *, session_id, parent_call_id)` 签名 | 库保持兼容(或一行 shim) |
|
| `protocols.py` 的 `LLMProvider.chat(messages, *, session_id, parent_call_id)` 签名 | 库保持兼容(或一行 shim) |
|
||||||
| 倒推的库需求 | `from_env` 工厂(GovDoc 装配层本就缺失,库直接补上)、Postgres 遥测、缓存 key namespace 含租户 |
|
| 倒推的库需求 | `from_env` 工厂(GovDoc 装配层本就缺失,库直接补上)、Postgres 遥测、缓存 key namespace 含租户 |
|
||||||
@@ -622,7 +741,7 @@ src/polygateway/
|
|||||||
> 下表保留作历史记录与能力倒推依据(VT 倒推的库能力——多逻辑角色、cache salt、多模态摘要进 hash、OcrTextPort 等——均已交付且被其他消费方使用,不回收);v1.0 验收标准相应改为 §11.1 + §11.3 两项目。
|
> 下表保留作历史记录与能力倒推依据(VT 倒推的库能力——多逻辑角色、cache salt、多模态摘要进 hash、OcrTextPort 等——均已交付且被其他消费方使用,不回收);v1.0 验收标准相应改为 §11.1 + §11.3 两项目。
|
||||||
|
|
||||||
| 项目侧 | 处置 |
|
| 项目侧 | 处置 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `adapters/llm.py`、`breaker.py`、`streaming.py`、`redis_cache.py`、`telemetry.py` | 删除,由库继任 |
|
| `adapters/llm.py`、`breaker.py`、`streaming.py`、`redis_cache.py`、`telemetry.py` | 删除,由库继任 |
|
||||||
| `main.py:_build_adapters()` | 改为按角色调用 `from_env`(SEARCH/JUDGE/VL/EVOLVE;共享实例显式声明) |
|
| `main.py:_build_adapters()` | 改为按角色调用 `from_env`(SEARCH/JUDGE/VL/EVOLVE;共享实例显式声明) |
|
||||||
| `adapters/vlm.py`(base64 编码与注入)、抽帧、OCR 文本拼接与注入前缀 | 留在项目(业务侧),组装好 content 数组后调库 |
|
| `adapters/vlm.py`(base64 编码与注入)、抽帧、OCR 文本拼接与注入前缀 | 留在项目(业务侧),组装好 content 数组后调库 |
|
||||||
@@ -632,7 +751,7 @@ src/polygateway/
|
|||||||
### 11.3 CHSAnalyzer(难度高,能力对标项)
|
### 11.3 CHSAnalyzer(难度高,能力对标项)
|
||||||
|
|
||||||
| 项目侧 | 处置 |
|
| 项目侧 | 处置 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `app/providers/governance.py`、`app/coordination/limiter.py` + `scripts.py`、`provider_gate.py` | 删除,由库继任(库必须先达到能力对等,这是 M2 的验收内容) |
|
| `app/providers/governance.py`、`app/coordination/limiter.py` + `scripts.py`、`provider_gate.py` | 删除,由库继任(库必须先达到能力对等,这是 M2 的验收内容) |
|
||||||
| `app/providers/invokers.py` 的 VLM invoker / `MonkeyOcrParseInvoker` | 由库 transport / `OcrLayoutPort` 继任 |
|
| `app/providers/invokers.py` 的 VLM invoker / `MonkeyOcrParseInvoker` | 由库 transport / `OcrLayoutPort` 继任 |
|
||||||
| `app/providers/table_locator.py`(几何映射)、`marker_imaging.py`(拼图/增强)、`position_scheduler.py`(公平调度) | 留在项目(业务侧) |
|
| `app/providers/table_locator.py`(几何映射)、`marker_imaging.py`(拼图/增强)、`position_scheduler.py`(公平调度) | 留在项目(业务侧) |
|
||||||
@@ -644,7 +763,7 @@ src/polygateway/
|
|||||||
## 12. 里程碑
|
## 12. 里程碑
|
||||||
|
|
||||||
| 阶段 | 交付 | 可接入 |
|
| 阶段 | 交付 | 可接入 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| M1 核心 | types/errors/ports、OpenAICompat transport(含非流式)、看门狗、RetryMW、**多源多账号+选源+源冷却备忘(2026-07-20 人类拍板,自 M2 提前——理由: RetryMW 循环与端口签名 M1 冻结,多源行为一并钉死避免 M2 返工)**、内存版限流/熔断、缓存(Redis+内存)、SQLite 遥测、结构化输出双策略、provider 注册表、from_env | GovDoc、Video-Tree |
|
| M1 核心 | types/errors/ports、OpenAICompat transport(含非流式)、看门狗、RetryMW、**多源多账号+选源+源冷却备忘(2026-07-20 人类拍板,自 M2 提前——理由: RetryMW 循环与端口签名 M1 冻结,多源行为一并钉死避免 M2 返工)**、内存版限流/熔断、缓存(Redis+内存)、SQLite 遥测、结构化输出双策略、provider 注册表、from_env | GovDoc、Video-Tree |
|
||||||
| M2 分布式 | Redis 限流(六道闸+契约测试)/熔断后端、多源 × Redis 后端联合验证(全局限额跨 worker)、背压 stall、Postgres 遥测、pricing 成本 | CHSAnalyzer(治理部分) |
|
| M2 分布式 | Redis 限流(六道闸+契约测试)/熔断后端、多源 × Redis 后端联合验证(全局限额跨 worker)、背压 stall、Postgres 遥测、pricing 成本 | CHSAnalyzer(治理部分) |
|
||||||
| M3 OCR | OcrText/OcrLayout 端口 + MonkeyOCR transport,走同一治理栈 | CHSAnalyzer(全量)、Video-Tree(OCR 升级) |
|
| M3 OCR | OcrText/OcrLayout 端口 + MonkeyOCR transport,走同一治理栈 | CHSAnalyzer(全量)、Video-Tree(OCR 升级) |
|
||||||
@@ -657,9 +776,9 @@ src/polygateway/
|
|||||||
## 13. 开放问题(待人类拍板)
|
## 13. 开放问题(待人类拍板)
|
||||||
|
|
||||||
| # | 问题 | 建议 |
|
| # | 问题 | 建议 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| Q1 | 打包与分发 | **已拍板(2026-07-22 用户)**: Gitea PyPI 包注册(gitea.iomgaa.online,内置 registry;twine 上传、项目侧 `pip install --index-url .../api/packages/iomgaa/pypi/simple/`);git+https 留作退路 |
|
| Q1 | 打包与分发 | **已拍板(2026-07-22 用户)**: Gitea PyPI 包注册(gitea.iomgaa.online,内置 registry;twine 上传、项目侧 `pip install --index-url .../api/packages/iomgaa/pypi/simple/`);git+https 留作退路 |
|
||||||
| Q2 | Python 最低版本 | 3.11(覆盖三项目: 3.11×2 + 3.13×1) |
|
| Q2 | Python 最低版本 | **3.12(已拍板,2026-08-24 人类确认)**: "我们现在的项目至少都是 3.12 的了,3.11 都有点老"——原记载的依据"覆盖三项目: 3.11×2 + 3.13×1"**已过时**,三个迁移目标均已 ≥3.12,故抬版本不再让任何迁移目标装不上。落点: `requires-python = ">=3.12"`、ruff `target-version = "py312"`、CLAUDE.md 与 README 同步。收益是 `asyncio.timeout` 可直接用于遥测写入预算(3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,省掉一整块 `wait_for` 绕行补丁)与 PEP 695 泛型语法;代价是仍在 3.11 的部署 `pip install` 会被 pip 直接拒绝(issue #15,见 CHANGELOG"请先读这一条(一)") |
|
||||||
| Q3 | Embedding 客户端是否纳入。**勘误(2026-07-20,VT 迁移文档 R11)**: 初版称"各有一套独立重试实现"不实——GovDoc 的 `OpenAICompatEmbedding` 有自研退避,但 Video-Tree 的 `RemoteEmbeddingProvider` 是**同步 SDK 裸调、无任何重试**;纳入库还需异步化其端口 | **已拍板(2026-07-20 人类)**: 纳入 M2(消灭无治理的裸调 + 统一重试),含端口异步化;Embedding 端口为公共 API,随 M2 设计文档过人类门 |
|
| Q3 | Embedding 客户端是否纳入。**勘误(2026-07-20,VT 迁移文档 R11)**: 初版称"各有一套独立重试实现"不实——GovDoc 的 `OpenAICompatEmbedding` 有自研退避,但 Video-Tree 的 `RemoteEmbeddingProvider` 是**同步 SDK 裸调、无任何重试**;纳入库还需异步化其端口 | **已拍板(2026-07-20 人类)**: 纳入 M2(消灭无治理的裸调 + 统一重试),含端口异步化;Embedding 端口为公共 API,随 M2 设计文档过人类门 |
|
||||||
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
|
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
|
||||||
| Q4 | conda 环境名 | `PolyGateway` |
|
| Q4 | conda 环境名 | `PolyGateway` |
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
|
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
|
||||||
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 |
|
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 |
|
||||||
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(thinking 注入/思考流字段声明) |
|
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) |
|
||||||
| 3 | `transports/openai_compat.py`(SSE 解析、非流式快路径、错误翻译 §6.2) | 依赖 1/2a/2b;错误翻译是中间件的语义地基 |
|
| 3 | `transports/openai_compat.py`(SSE 解析、非流式快路径、错误翻译 §6.2) | 依赖 1/2a/2b;错误翻译是中间件的语义地基 |
|
||||||
| 4a | `middleware/retry.py`(D13 自研,单层原则)+ `sources.py`(SourceConfig、round_robin/least_inflight 选源、源冷却备忘) | 依赖错误分类;先于限流接入便于独立测试。**多源完整行为(换源/冷却/多源行为测试)2026-07-20 人类拍板自 M2 提前进 M1**——重试循环每次尝试都要选源,签名与行为一并钉死 |
|
| 4a | `middleware/retry.py`(D13 自研,单层原则)+ `sources.py`(SourceConfig、round_robin/least_inflight 选源、源冷却备忘) | 依赖错误分类;先于限流接入便于独立测试。**多源完整行为(换源/冷却/多源行为测试)2026-07-20 人类拍板自 M2 提前进 M1**——重试循环每次尝试都要选源,签名与行为一并钉死 |
|
||||||
| 4b | `backends/memory/`(limiter + breaker)+ 对应中间件 | 语义契约(permit/settle、状态机)在内存版上钉死,契约测试同步交付 |
|
| 4b | `backends/memory/`(limiter + breaker)+ 对应中间件 | 语义契约(permit/settle、状态机)在内存版上钉死,契约测试同步交付 |
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# 遥测连接池的资源语义与生命周期: 从"预占 10 条"到"按需 0 条"
|
||||||
|
|
||||||
|
- **issue**: #15(共享 PostgreSQL 实例,`max_connections=100`,多 worker × 多 scope 部署)
|
||||||
|
- **核查基准**: HEAD 1.2.4。issue 按 1.1.2 运行环境提交并已自行复核 1.2.4,本文逐条重核**全部成立**: `postgres.py:100`(建池不传 min/max)、`postgres.py:106`(建池失败即永久判死)、`postgres.py:246-251`(`aclose` 不清 `_failed`)、`client.py:405-420`(每个 client 各 new 一个 recorder)。`min_size`/`max_size` 在整个包内**一次都没出现过**。
|
||||||
|
- **状态**: **已实施**(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`,T0–T7 见实现计划末尾的提交表)。人类已确认方案与全部四组改动 + 缺省值;**Codex 已审,7 条全部处置完毕(§9)**;实施期的三处修订以 §10 标注
|
||||||
|
- **实测环境**: asyncpg 0.31.0;真实实验室 PG(`polygateway` 专用库,跨内网 RTT ≈ 123ms)
|
||||||
|
|
||||||
|
## 1. 问题的真实形状
|
||||||
|
|
||||||
|
issue 把问题命名为"asyncpg 默认 `min_size=10` 太大"。这个命名会把方案引向"改个默认值"。实际是**四层缺陷叠加**,只改默认值会留下三层,且下一次换个瞬时错误(PG 重启、DNS 抖动)照样全量失遥测。必须分开命名。
|
||||||
|
|
||||||
|
### 1.1 前提实测: `min_size` 的语义是"预连接",不是"下限"
|
||||||
|
|
||||||
|
asyncpg `pool.py:457` 是 `if self._minsize:` ——为 0 时 `_initialize` 只创建 holder 对象,**一条连接都不连**。由此实测得到本设计的全部地基:
|
||||||
|
|
||||||
|
| 实测项 | `min_size=0, max_size=2` | 默认 `10/10`(现状) |
|
||||||
|
|---|---|---|
|
||||||
|
| 建池指向**不可达**端口 | **立即成功**,0.000s,`size=0` | 立即抛 `ConnectionRefusedError` ← **issue 的失败点** |
|
||||||
|
| 建池连真实库 | 0.000s,`size=0` | 0.72s,**10 条常驻** |
|
||||||
|
| 首次写入 / 稳态写入 | 513ms(含建连 ≈390ms)/ **123ms**(一次 RTT) | 同(稳态无差异) |
|
||||||
|
| `acquire` 失败后再 `acquire` | 照常重试,池不进坏状态 | — |
|
||||||
|
| 空闲超 `max_inactive_connection_lifetime` | 连接归 0,下次写入重连 | 同 |
|
||||||
|
|
||||||
|
**关键推论**: `min_size=0` 不只是"调小",它把建池从一次全有全无的重资源动作变成**零成本、不触库**的动作。这一步走出去,后面三层的性质全变。
|
||||||
|
|
||||||
|
**实施后在同一台真实实验室 PG 上的复测(T6,按唯一 `application_name` 过滤 `pg_stat_activity`)**,是全套证据里最直观的一条: 修复前建完 recorder 即 **10** 条连接;修复后 **0**(建 recorder)→ **1**(一次写入)→ **4**(20 行并发,恰为 `pool_max`)→ **0**(`aclose` 后)。四个数字逐一对应上表的四行推论。
|
||||||
|
|
||||||
|
### 1.2 缺陷一: 库对自己的资源占用从未表态——而这是全库唯一一处
|
||||||
|
|
||||||
|
`create_pool(self._dsn, timeout=10)` 继承第三方默认值(P4/P5: 默认参数掩盖关键逻辑)。横向扫过库内每一处外部资源:
|
||||||
|
|
||||||
|
| 组件 | 建连方式 | 上限 | 预占? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| httpx transport(`openai_compat.py:301`) | 按需 | 100(httpx 缺省) | 否 |
|
||||||
|
| RedisLimiter / RedisGate / RedisCache | 按需 | 无上限(redis-py 缺省) | 否 |
|
||||||
|
| **PostgresRecorder** | **预占 10 条,否则建池失败** | 10 | **是** |
|
||||||
|
|
||||||
|
**库内每一处外部资源都是按需建立,唯独遥测池预占**。issue 那句"业务侧一条一条按需要,这个池要么一次拿到 10 条、要么建池失败,所以余量紧张时先倒下的必然是它"完全正确——它是链路上最脆的一环,承担的却是最不该悄悄失败的职责。issue 现场规模: 4 client × 10 = **40 条常驻专用于写遥测**,而实际写入并发是个位数。
|
||||||
|
|
||||||
|
### 1.3 缺陷二: 判死判据挂在"哪一步失败",而非"失败是什么性质"
|
||||||
|
|
||||||
|
issue #9 已把判死收窄为"确定写不进去",但漏了一格: `_open_pool` 这一步里**同时藏着两类失败**——DSN 本身非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。因为 `min_size=10` 让瞬时错误**发生在建池这一步**,它就被 `postgres.py:106` 一刀切成了永久判死。
|
||||||
|
|
||||||
|
判据错位的证据: `postgres.py:104-105` 的注释"池建不出来 = 确定写不进去"——这句话在 `min_size=10` 下是**假的**(连接耗尽不是确定写不进去,是这一秒写不进去);在 `min_size=0` 下才为真。**注释描述的是设计意图,代码实现的是另一件事**,中间的差额就是这次事故。
|
||||||
|
|
||||||
|
### 1.4 缺陷三: 降级不可恢复,且不可见
|
||||||
|
|
||||||
|
| 性质 | 现状 | 后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 不可恢复 | `_failed` 置位后无任何恢复路径;`aclose()`(`postgres.py:246-251`)只清 `_schema_ready` **不清 `_failed`** | 只有进程重启能恢复 |
|
||||||
|
| 不可见 | 全程只有**一条** warning(`postgres.py:107`) | 长跑进程里等同于静默 |
|
||||||
|
|
||||||
|
issue 是**手工对账**(日志里的完成里程碑条数 vs `llm_calls` 行数)才发现的,期间 19 次调用一行未落、成本少记约 $5。这就是"遥测必录"铁律的实质破口: 库做不到必录时,必须**持续、可编程地**让下游知道。SQLite 侧更糟——`sqlite.py:138-139` 初始化失败后写入直接 `return`,**连 warning 都没有**。
|
||||||
|
|
||||||
|
### 1.5 缺陷四: 共享路径是坏的,所以每个 client 只能各占一份
|
||||||
|
|
||||||
|
issue 建议"让指向同一 DSN 的多个 recorder 共享一个池"。这条路今天走不通,而且不通的原因是一个**跨组件的所有权纪律缺口**:
|
||||||
|
|
||||||
|
| 现象 | 位置 | 性质 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry → 共享 recorder 被第一个关闭的 client 弄死 | `client.py:271-273`(`embedding.py:455-461`、`ocr.py:465-467` 各有一份复制) | 越权 |
|
||||||
|
| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 |
|
||||||
|
| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不碰 limiter/breaker) | `client.py:263-280` | **泄漏** |
|
||||||
|
| 对照组: `RedisLimiter._owns_client` 纪律**是对的** | `limiter.py:185-191, 318-322` | 正确先例 |
|
||||||
|
|
||||||
|
**实施期挖出的第四个现象(T4,本设计原稿未预见)**: 注入外部池时,`aclose()` 之后的下一次写入会拿 DSN **偷偷自建一个池**——注入方以为自己管着全部连接,实际早已不是。它与上表三条同一根因(库不区分"这个资源是谁的"),只是表现在**关闭之后**而非关闭当时,故原稿按"谁关谁的"扫一遍时没看见。修法归入 §3.2 第 4 点的"关了就是关了": 置 `_closed` 后写入短路且不复活。
|
||||||
|
|
||||||
|
三个现象一个根因: **库对"谁建的、谁负责关"没有统一纪律**。ARCH §7.7 R5 规定"共享必须显式注入",但显式注入这条正道今天是坏的,下游只能退回"每 client 各占一份"——缺陷一的放大器由此长在架构里,而不是长在某个默认值里。
|
||||||
|
|
||||||
|
## 2. 备选方案与否决理由
|
||||||
|
|
||||||
|
| 备选 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| 只把默认值调小(issue 方向 1 单独做) | 脆点消失,但 §1.3 的判据错位仍在: 下次 PG 重启/DNS 抖动落在准备期,照样永久失能。治标 |
|
||||||
|
| 只加建池退避重试(issue 方向 3 单独做) | 在错的地方加复杂度。`min_size=0` 之后建池已不触库,**没有可重试的失败**;真正需要重试的是 acquire,而那里本来就有正确行为 |
|
||||||
|
| 隐式全局池注册表(DSN → 共享池) | 违反"纯 asyncio 中立: 无全局状态、无模块级单例"铁律,且解决的是 `min_size=0` 之后已不存在的问题(闲时占 0) |
|
||||||
|
| 暴露 `min_size` 配置项 | 它唯一的作用是把脆点装回来,换取首次 390ms。库没有理由提供一个只会伤人的旋钮(P1+P5) |
|
||||||
|
| 遥测改异步队列 + 后台 flush | 真正彻底消除"遥测拖慢业务",但引入进程崩溃时的丢数据窗口——与遥测被下游当**审计证据**用(§7.8/issue #12 决策 E-a)正面冲突;还要背负后台任务生命周期与背压策略。重大架构变更,不在本 issue 换取的收益内 |
|
||||||
|
| 把遥测失败塞进 `errors.py` 四分类 | 四分类的语义是"决定重试/换源/熔断"(ARCH §5.1)。遥测失败既不冒泡也不参与那套决策,塞进去会污染分类语义。改为在遥测子系统内定义自己的三分,收敛在一处(§3.2) |
|
||||||
|
|
||||||
|
## 3. 设计
|
||||||
|
|
||||||
|
### 3.1 A 组 · 池语义: 显式声明,按需建连
|
||||||
|
|
||||||
|
`create_pool(dsn, min_size=0, max_size=<配置>, timeout=<写入预算>, command_timeout=<写入预算>)`。
|
||||||
|
|
||||||
|
- **只暴露 `max_size`**(理由见 §2)。稳态占用从"40 条常驻"变成"实际并发,闲时 0"。
|
||||||
|
- 整次写入(`_ensure_ready` + `acquire` + `execute`)由 `asyncio.timeout` 包一层**硬预算**,超时按行级丢弃。这把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证。
|
||||||
|
- `acquire` 必须显式传 timeout。今天 `postgres.py:238` 的 `pool.acquire()` **无超时**(asyncpg 缺省 `timeout=None` = 无限等待),池满时会无限期挂在业务路径上——现状因 `max_size=10` 而未暴露,`max_size=4` 后必须补齐。
|
||||||
|
- **不得用 `async with pool.acquire(...)`(Codex 审查,2026-08-24,已核实)**。`Pool.release()` 是 `await asyncio.shield(ch.release(timeout))`,且该 timeout **默认取 acquire 时记录的 `ch._timeout`**(asyncpg `pool.py:886-889, 930-937`)。外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行 `async with` 的 `__aexit__`,此时**没有新的 cancel 投递**,那个 shielded release 会正常等到完成——于是业务路径的真实上界是 **≈ 2 × 预算**,而不是文档原先承诺的一个预算。故改为显式 `con = await pool.acquire(timeout=self._write_timeout_s)` + `finally: await pool.release(con, timeout=<小的独立上限>)`,释放超时则 `con.terminate()`。**acquire 传的是完整预算而非剩余预算**(实施期核定,T3): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍,徒增出错面;实测总耗时正好等于预算。承诺相应精确化为: **主写入尝试 ≤ 预算,释放路径独立有界**。
|
||||||
|
- `CancelledError` 穿透由测试钉死: `asyncio.timeout` 只把自己触发的 cancel 转成 `TimeoutError`,外部取消照常以 `CancelledError` 冒出(实测确认,Codex 独立复现)。**实现纪律**: 降级路径(节流日志、tracker 更新、release 收尾)一律不得 `except CancelledError` 而不 re-raise;`except TimeoutError` 必须排在 `except Exception` 之前;严禁裸 `except BaseException`(铁律"取消可穿透")。
|
||||||
|
|
||||||
|
### 3.2 B 组 · 失败三分与冷却降级
|
||||||
|
|
||||||
|
**判据(两句,写进 ARCH)**:
|
||||||
|
|
||||||
|
1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。
|
||||||
|
2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。
|
||||||
|
|
||||||
|
第 2 句是 Codex 审查(2026-08-24)后补的,**原稿只有第 1 句,而分类表把 SQLSTATE `42` 整类归了行级——这与第 1 句自相矛盾**: 42501(账号被收走 INSERT 权限)、42P01(表被迁走/删掉)都是"能被外部修好"的持续性状态,却要在每次 LLM 调用上内联付一次 ≈123ms 往返并刷一条 warning,永远不会自愈也永远不停。按 SQLSTATE 前两位切太粗,必须切到具体码。
|
||||||
|
|
||||||
|
归档(asyncpg 0.31 异常层次 + PG SQLSTATE,**按 SQLSTATE 分类而非异常类白名单**——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移):
|
||||||
|
|
||||||
|
| 档 | 判据 | 处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| **配置级致命** | `ClientConfigurationError`(DSN 本身不可解析,`InterfaceError`/`ValueError` 子类);`create_pool` 抛的 `ValueError`/`TypeError`(参数非法) | 永久 no-op + 一条 **error**(人配错了,不是 warning) |
|
||||||
|
| **环境级不可用** | SQLSTATE `08`(连接)/`53`(资源不足,含 **53300 too many connections**)/`57`(管理干预)/`28`(认证)/`3D`(库不存在)/**`42501`(无权限)**/**`42P01`(表不存在)**;`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅准备期路径可达**——写入期的超时被 `record_llm_call` 的 `except TimeoutError` 先接住并按行级丢弃,见第 3 点);**表确定不存在且建不出来** | **冷却降级**(内部常量 60s),到期允许**一次**重新准备 |
|
||||||
|
| **行级拒绝** | 其余 `PostgresError`: 数据与约束类(`22`/`23` 等),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级(`postgres.py:242-244`),但**接入节流复述** |
|
||||||
|
|
||||||
|
四点必须说清:
|
||||||
|
|
||||||
|
1. **致命档收到极窄是有意的**。认证失败、库不存在、表建不出来一律归环境级——它们都是外部状态,DBA 改完密码/建完表就该自动恢复。永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形,而 DSN 是构造期固定的字符串,是唯一满足这条的东西。
|
||||||
|
2. **`42703` 是判据的唯一具名例外,且必须写明理由**。按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了一条更高优先级的承诺: manual 档缺列时**按现有列裁剪 INSERT 继续写**,缺列以逐行 warning 暴露,好让下游发现 schema 漂移——即"部分列写进去了"这件事本身有价值,不该被冷却掉。代价(无限逐行 warning)由接入节流复述抵消。**例外只此一条,新增例外必须同款论证**。
|
||||||
|
3. **带冷却正面回答了 `postgres.py:104-105` 的顾虑**。那条注释担心的是"每次调用都内联吞一次 connect 超时";冷却 + §3.1 的硬预算把最坏成本变成"每 60s 一次、上界一个预算",有界且可解释。**进程不再需要重启**。
|
||||||
|
**这句承诺的适用范围是准备期路径**(2026-08-24 合并前审查校正,**只改文档不改行为**): `TimeoutError` 是 `OSError` 子类、本表据此归环境级,但 `record_llm_call` 的 `except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的超时一律在那里按行级丢弃,`_handle_failure` 根本不会被调用——写入路径上这条分类规则是死代码。于是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用仍内联付满一个预算(缺省 5s)、丢一行、`degraded` 保持 False、不进冷却。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行不置 degraded"是 §6"突发排队"与 ARCH §7.8"`degraded` 与 `dropped_rows` 覆盖的不是同一件事"那一条明确记下的有意取舍;升档议题见 §6 的"连续超预算丢行是否该升档"一格。
|
||||||
|
4. **`aclose()` 的语义钉死为"关了就是关了"**: 置 `_closed`,此后写入短路且**不复活**。今天"关完还能自己重建池"的灰色状态取消。issue 提的"`aclose` 不清 `_failed`"由冷却机制解决,不由 `aclose` 解决——恢复是运行时行为,不是关闭动作的副作用。
|
||||||
|
**关闭动作本身也必须有界(Codex 审查,已核实)**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时**无限等**,60 秒只发一条 warning(`pool.py:939-948, 961-972`);asyncpg 自己的 docstring 就写着"advisable to use `asyncio.wait_for` to set a timeout"。故 `aclose()` 走 `asyncio.wait_for(pool.close(), ...)`,超时后 `pool.terminate()`,外部取消照常穿透——否则"遥测不得拖垮业务"在收尾路径上开了个口子。
|
||||||
|
|
||||||
|
分类函数是全库唯一一处 PG 失败分类,作 `postgres.py` 模块级私有函数(与 recorder 同文件、只服务 PG;不新起文件避免碎片化)。**认不出的失败归最轻档(行级)**是它的保守缺省,而这个缺省在**建池路径**上安全的理由比"最轻档代价最小"更强(实施期核实,T5): `min_size=0` 让建池不触库(实测 0.000s),所以"归行级 = 下次调用再重试一次建池"本身**零成本**——`postgres.py:104-105` 那条注释担心的"每次重试内联吞一次 connect 超时"是 `min_size=10` 语义下的顾虑,在新语义下**不成立**。这是 §1.1 那个关键推论的又一处红利: 地基一换,原本需要小心处理的保守缺省变成了白拿。
|
||||||
|
|
||||||
|
### 3.3 C 组 · 降级可见 + 可编程
|
||||||
|
|
||||||
|
新增 `telemetry/status.py` 的 `TelemetryStatusTracker`(两个 recorder **共用**,消除两侧不对称):
|
||||||
|
|
||||||
|
| 能力 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 进入降级 | 一条日志,含原因分档与恢复条件(冷却剩余 / "需重启");**级别由 `fatal` 决定且只在这一处决定**——致命档 error(人配错了,不会自愈)、其余 warning。recorder 侧不得再复制一条(实施期更正 #4) |
|
||||||
|
| 降级期间 | 按丢弃行数与时间**节流复述**(不刷屏,也不静默)——这一条是 §1.4 的直接钉子 |
|
||||||
|
| 恢复 | info 一条,报告"期间丢弃 N 行" |
|
||||||
|
| 快照 | `TelemetryStatus` frozen dataclass(放 `types.py`,与 `SourceStats` 同一先例): `degraded` / `fatal` / `reason` / `degraded_for_s` / `dropped_rows` / `retry_after_s` |
|
||||||
|
|
||||||
|
**不叫 `health`,是因为这个词在 `ports.py` 里已经被占用两次**(本轮自查发现,Codex 未提): `OcrTransport.check_health`(`ports.py:90`,源探活)与 `SourceSelector` 侧的 `health(source_name) -> float`(`ports.py:234`,成功率 EWMA)。库内 `health` 一律指**源的健康度**,而这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分。同一文件里一词两义会直接违反 P2(领域术语命名)。
|
||||||
|
|
||||||
|
**不并入 `TelemetryRecorder` 主 Protocol(Codex 审查,已核实)**: 该 Protocol 是 `@runtime_checkable`(`ports.py:246`),而 runtime 检查按属性存在性做——加一个 `status` 属性,会让所有只实现 `record_llm_call` 的实现**当场不再是** `TelemetryRecorder`。库内 `tests/unit/test_ports.py:137,141` 就有 `isinstance(_DummyRecorder(), TelemetryRecorder)` 断言,下游若用同款断言,升级即断。原稿"库外无第三方实现者故加属性零成本"的判断**只覆盖了静态类型,漏了运行时结构契约**。改为:
|
||||||
|
|
||||||
|
- 独立可选端口 `TelemetryStatusProvider`(单方法/单属性,`@runtime_checkable`),两个内置 recorder 实现它;`TelemetryRecorder` 逐字不动。
|
||||||
|
- 出口 `GatewayClient.telemetry_status -> TelemetryStatus | None`(None = 未启用遥测,或注入的 recorder 不提供)。取值经**一处** `isinstance(..., TelemetryStatusProvider)` 判定,不重演 `aclose` 那种三处复制的鸭子类型。
|
||||||
|
- `types.py` 与 `ports.py` 同层且允许互 import(import-linter `ports : types : errors` 契约),分层不破。
|
||||||
|
- 时钟经构造参数注入(`now: Callable[[], float] = time.monotonic`,与 `GatewayClient(now=...)` 同款),冷却与节流均可测。快照对外给 `degraded_for_s` **相对时长**而非绝对时间戳,避免 monotonic 与 wall clock 两个时钟并存的二义。
|
||||||
|
- **SQLite 侧本次只做可见性**(补上缺失的 warning + 接入 tracker + 快照),**不做** lazy 化与冷却重连。理由: SQLite 的失败模式(本地目录不可写、文件损坏)在装配期就会暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确;lazy 化是独立重构。tracker 与快照两侧共用,将来若要对称,接口已就位。
|
||||||
|
|
||||||
|
### 3.4 D 组 · 资源所有权纪律统一
|
||||||
|
|
||||||
|
把 `RedisLimiter._owns_client` 这个**库内已有的正确先例**推广为全库唯一纪律: **谁建的谁关,注入的一律不碰**。区分两类:
|
||||||
|
|
||||||
|
| 类 | 所有权归属 | 落法 |
|
||||||
|
|---|---|---|
|
||||||
|
| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 → **`RedisCache` 补齐这条纪律** |
|
||||||
|
| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性(与 `RedisLimiter.from_url:190` 逐字同款模式),`aclose` 只关自建的 |
|
||||||
|
|
||||||
|
- **默认必须是"不拥有"**: `__init__` 是全量注入路径(`client.py:126-149`),经它传入的一切组件一律视为**外部所有**(`_owns_* = False`),只有三个工厂在 `or _build_*` / `if telemetry is not None else _build_telemetry` 真正自建时才置 True。原稿只写了"工厂置位"没写死这条默认,Codex 据此指出直接构造路径下共享 transport 仍会被第一个 client 关掉——那是实现走偏的后果,但默认值本就该在设计里定死,故补。
|
||||||
|
- 三处复制的 `getattr(..., "aclose")` 收敛为一个内部 helper;所有权修正必须三处一致,复制就是下一个 bug 的种子。
|
||||||
|
- `aclose` 补关 limiter/breaker——修掉现存泄漏。这需要**三个 client 都新持引用**: 今天 `GatewayClient.__init__` 把 limiter/breaker 交给 `RetryMW` 后自己不留引用(`client.py:133-134`),embedding/ocr 同样(`embedding.py:497-498`、`ocr.py:510-511` 自建、`embedding.py:452-461`、`ocr.py:462-467` 的 `aclose` 触达不到)。内存后端无 `aclose`,helper 探测后跳过。
|
||||||
|
- **判定一律用 `is None` / `is not None`,不得用 `or`**(实施期补,T1): 工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好,故写进设计而非留在代码里。
|
||||||
|
- **零公共 API 面变化**: `_owns_*` 是私有属性,由工厂置位。
|
||||||
|
- 有了 D 组,issue 的"共享池"方向以**显式注入**形态自然成立(`PostgresRecorder(dsn, pool=...)` 已支持且不关外部池),无需任何隐式全局。
|
||||||
|
|
||||||
|
### 3.5 新配置键与缺省值(人类已定)
|
||||||
|
|
||||||
|
| 键 | 字段 | 缺省 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PGW_TELEMETRY_PG_POOL_MAX` | `telemetry_pg_pool_max: int` | **4** | 稳态吞吐**实测约 15.6 行/秒**(见 §6 的口径更正;原稿按 `max_size / RTT` 估的 32 行/秒偏乐观一倍),覆盖单 client 十余并发;闲时占 0,不构成常驻负担。issue 现场 4 client × 4 = 峰值 16、稳态趋近 0(今天是 40 条常驻) |
|
||||||
|
| `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | `telemetry_pg_write_timeout_s: float` | **5.0** | 实测稳态 123ms、首次含建连 513ms;5s 宽松且**有界**。同时用作 connect / acquire / 整次写入硬上界 |
|
||||||
|
| (无键) | 冷却期 | 60s,**内部常量** | 无部署差异理由(P1 YAGNI) |
|
||||||
|
|
||||||
|
- 两键都带 `PG` 前缀,与 `PGW_TELEMETRY_PG_DSN` 一致,语义无歧义: SQLite 侧的等价物(`busy_timeout=5000`,`sqlite.py:58`)本次不动,这个不对称是**已知且有理由**的(见 §3.3 末)。
|
||||||
|
- 校验落 `GatewaySettings._validate_telemetry`(与 `telemetry_text_cap` 同一先例,覆盖直接构造 / `dataclasses.replace` / env 三条路): `pool_max >= 1`、`write_timeout_s > 0`,报错文本同时点字段名与 env 键名。
|
||||||
|
- 加字段的代价可控: `GatewaySettings(` 全库**只有 1 处**构造(`config.py` 的 `_load_pgw`),测试全走 `from_env`(74 处)+ `replace`(53 处),不重演 issue #13 那 35 处直接构造点的代价。三条链路(chat/embedding/ocr)因共用 `GatewaySettings` + `_build_telemetry` 自动覆盖。
|
||||||
|
|
||||||
|
## 4. 行为矩阵
|
||||||
|
|
||||||
|
| 场景 | 现状(1.2.4) | 本设计 |
|
||||||
|
|---|---|---|
|
||||||
|
| 建 client,共享实例余量 3 条 | 建池失败 → **整进程永久失遥测** | 建池成功(不触库),写入按需拿 1 条 → **正常落库** |
|
||||||
|
| 稳态写入 | 10 条常驻 | 闲时 0 条,忙时 ≤ `pool_max` |
|
||||||
|
| `too many clients` 落在首次准备期 | 永久判死 | 冷却降级 60s → 到期重试 → **自动恢复** |
|
||||||
|
| `too many clients` 落在稳态写入 | 丢一行,池自恢复(已正确) | 同,且进入降级态使其**可见** |
|
||||||
|
| PG 重启 / 网络抖动 | 视落点: 准备期 → 永久判死 | 一律冷却降级 → 自动恢复 |
|
||||||
|
| DSN 写错 | 永久 no-op + warning | 永久 no-op + **error**(措辞点明"配置错,需改 DSN 并重启") |
|
||||||
|
| 旧表缺列(42703) | 逐行 warning 丢弃 | 逐字不变(行级档) |
|
||||||
|
| 表不存在且建不出来 | 永久判死 | 冷却降级,DBA 建表后**自动恢复** |
|
||||||
|
| 遥测后端慢/挂 | `acquire` 无超时,可无限期挂在业务路径 | 硬预算封顶(5s),超时丢一行 |
|
||||||
|
| 降级期间下游想知道 | 只能人肉对账 | `client.telemetry_status` + 节流复述日志 |
|
||||||
|
| 多 client 注入同一 recorder | 第一个 `aclose` 把它弄死 | 各关自己的,共享 recorder 存活 |
|
||||||
|
| 自建 redis limiter | `aclose` 后**泄漏** | 被关 |
|
||||||
|
| 注入 redis 客户端给 RedisCache | 被 `aclose` 误关 | 不动 |
|
||||||
|
|
||||||
|
## 5. 测试策略
|
||||||
|
|
||||||
|
行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。
|
||||||
|
|
||||||
|
**单元层**(`tests/unit/test_telemetry.py` 邻域,沿用既有假 asyncpg 模块):
|
||||||
|
建池参数断言 `min_size == 0` 且 `max_size == 配置值`(钉住"库对资源占用的表态",防回归到继承第三方默认值,这是本 issue 的**主回归钉子**);`too many clients`(53300)落在准备期 → 进冷却降级、**不** fatal → 假时钟推进 60s → 自动恢复;`ClientConfigurationError` → fatal + 一条 error + 此后零成本短路(断言不再调 `acquire`);**`42501`/`42P01` → 进冷却降级**、`42703` → 行级丢弃且**不**进降级(§3.2 的分档边界,两侧各钉一次);假 pool 的 acquire 挂住 → 硬预算生效、丢一行、耗时 ≤ 预算;外部 `CancelledError` 在 `asyncio.timeout` 内**不**被吞成 `TimeoutError`;状态快照六字段的状态机;节流复述(N 条丢弃只出 M 条 warning,loguru sink 断言);`aclose` 后写入不复活。
|
||||||
|
|
||||||
|
**收尾路径层**(Codex 审查新增,两条都是"看起来完成了、其实资源还在"的形态):
|
||||||
|
① `execute` 被硬预算取消后**连接不泄漏**——假 pool 记录 acquire/release 配对次数,断言超时路径上 release 照样发生且总耗时 ≤ 预算 + release 上限(钉 §3.1 那条 shielded release 的坑);② 持有连接不释放时 `aclose()` **不无限挂**——假 holder 永不 release,断言 `aclose` 在超时后走 `terminate()` 返回。
|
||||||
|
|
||||||
|
**所有权层**(`tests/unit/test_client.py` 邻域,假 recorder/transport 记 close 次数):
|
||||||
|
注入的 recorder/transport/limiter/breaker/cache 不被 `aclose` 关;自建的被关;自建 redis limiter/breaker 被关(泄漏钉子);注入给 `RedisCache` 的客户端不被关;三个 client(chat/embedding/ocr)**逐一**覆盖——收敛成 helper 后仍须三处各钉一次,否则下次复制回来无人发现。
|
||||||
|
|
||||||
|
**契约层**: `isinstance(只实现 record_llm_call 的对象, TelemetryRecorder)` 必须**仍为 True**(`tests/unit/test_ports.py:137,141` 现有断言保持绿即可,不需新增)——它是"没把 `status` 并进主 Protocol"这条决策的机械化执法点。
|
||||||
|
|
||||||
|
**集成层**(`tests/integration/test_postgres_telemetry.py`,真实 PG,沿用 run 级前缀隔离与"严禁 DROP/TRUNCATE"纪律,缺 DSN 则 skip、不标 slow):
|
||||||
|
`pg_stat_activity` 计数——建 recorder 后本池连接 **0** 条,一次写入后 **≤1** 条(issue 的直接回归钉子);稳态连接数 ≤ `pool_max`。**计数必须按唯一 `application_name` 过滤**(经 `server_settings` 设一个 run 级值): 该实例被多项目共用,按库名或用户名计数会被别人的连接污染,那样的用例是设计上就会间歇红的信号污染源(CLAUDE.md §4.6)。降级与恢复走**不可达 DSN** 的 recorder 验证,不去动共享实例的 `max_connections`。
|
||||||
|
|
||||||
|
## 6. 非功能与已知取舍
|
||||||
|
|
||||||
|
| 维度 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 首次写入延迟 | `min_size=0` 把 ≈390ms 建连从"装配期"挪到"首次写入"。稳态无差异(实测 123ms);空闲超 `max_inactive_connection_lifetime`(asyncpg 缺省 300s,不暴露)后再付一次。相对一次秒级 LLM 调用可忽略 |
|
||||||
|
| 突发排队(**热池稳态**) | 业务并发 > `pool_max` 时遥测写入排队。按下一格更正后的实测口径(15.6 行/秒): 50 行同时到达 → 实测 3.2s,在 5s 预算内但**余量只剩约 1.5 倍**(原稿按 32 行/秒估算时以为余量有 3 倍);超出即丢行(铁律"丢一条 < 拖垮调用") |
|
||||||
|
| 突发排队(**冷启动/空闲后**) | 上一格的算术只在"schema 已就绪且连接已热"时成立。空闲超回收期后连接归 0,第一波要重新建连(实测 ≈390ms),且首次准备被 `_init_lock`(`postgres.py:75, 83-91`)串行保护——冷启动的最坏延迟不是 `64 / 32 ≈ 2s`。Codex 审查指出原稿这段易被读成两种情形通用,故拆开写。冷启动上界仍由硬预算封顶,超出即丢行 |
|
||||||
|
| Python 版本 | **本条取舍已消解**(人类决策,2026-08-24): 最低版本提到 **3.12**(`requires-python = ">=3.12"`、ruff `target-version = "py312"`),3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,`asyncio.timeout` 可直接用,不必退回 `wait_for`。代价见 §7 |
|
||||||
|
| `pool_max` 的调参口径(**实施期更正,T3 实测**) | 原稿的 `期望吞吐 ≈ pool_max / RTT`(4/0.123 ≈ 32 行/秒)**偏乐观一倍**: T3 实测 50 行并发批耗时 **3.2s**,即约 **15.6 行/秒**、每条连接约 4 行/秒——一次 `INSERT` 的实际往返比一次 `SELECT 1`(RTT 的测法)重。取舍方向不变(超预算丢行 < 拖垮业务),但 `.env.example` 与 README 的调参口径**必须写实测数字**,否则下游按错公式放大,以为 `pool_max=8` 能到 64 行/秒(实为约 31)。共享一个 recorder 给多 client 时并发在此汇聚,应按 client 数相应放大 |
|
||||||
|
| 冷却期的丢数 | 降级 60s 期间的行**确实丢了**,只是可见、可计数、且到期自动恢复。这是"遥测降级不得拖垮业务"的既有方向(ARCH 降级方向铁律),本设计不改方向,只改**可恢复性与可见性** |
|
||||||
|
| `42703` 缺列的持续逐行重试 | 缺列时每次调用付一次 acquire+execute(≈123ms 内联)且逐行 warning,不进冷却。**这是判据的唯一具名例外**(§3.2 第 2 点),由 issue #13 的"缺列须逐行暴露"承诺定死;代价由节流复述抵消。`42501`/`42P01` 原稿同归此格,经 Codex 审查已改判环境级 |
|
||||||
|
| 快照计数的线程安全 | `dropped_rows` 是单事件循环内的 int 自增。库不承诺跨线程共享同一 recorder("纯 asyncio 中立"),最坏是计数不准,不会崩 |
|
||||||
|
| SQLite 侧不对称 | 只做可见性,不做 lazy 化/冷却(理由见 §3.3)。tracker 与快照两侧共用,不产生第二套概念 |
|
||||||
|
| redis / httpx 的资源上限 | 两者均无上限或偏大(§1.2),但**按需建连、无预占脆点**,不是本 issue 的病灶。列为观察项,**本次不动**(反 gold-plating) |
|
||||||
|
| 连续超预算丢行是否该升档(**留作后续议题**) | 后端假死(TCP 通但不回应)且 schema 已就绪时,每次业务调用都内联付满一个预算并丢一行,`degraded` 恒 False、永不进冷却(成因见 §3.2 第 3 点)。本次不改行为——相对改前的"无限期挂"已是净改善,而升档需要新判据("连续 N 次超预算 = 后端不可用"),那是个有代价的猜测: 判错会把本地并发过高误判成后端挂了,冷却 60s 只会白丢更多行。要动就得先有实测依据,不在本 issue 范围内 |
|
||||||
|
| 端口签名 | `TelemetryRecorder` **逐字不变**(24 字段签名与 Protocol 成员集合都不动),不触碰迁移兼容约束(ARCH §5.1)、也不破坏 `runtime_checkable` 的既有 `isinstance` 语义;新增的是**独立**端口 `TelemetryStatusProvider` |
|
||||||
|
|
||||||
|
## 7. 文档与发布
|
||||||
|
|
||||||
|
ARCH §7.8 增补三条: 遥测池的资源语义(为何 `min_size=0`、为何不暴露 `min_size`)、失败三分判据(§3.2 那句判据是主要交付物之一)、**资源所有权纪律**(§3.4,应作为跨子系统的通用纪律成文,而非遥测局部约定)。§9 配置面登记两个新键。`.env.example`、README 能力表与配置表、Gitea wiki 按 `docs-convention.md` §2 同步。
|
||||||
|
|
||||||
|
**版号由人类在发布时定**,本文不预设: 按 semver 应是 **1.3.0**(端口新增只读属性 + 两处对下游可见的行为变更),但项目既有口径明显偏 patch——issue #11 扩遥测列(端口 22→24)落 1.2.1、issue #14 新增配置键 + `retry_after_s` 语义变更**设计文档写的是 1.3.0、实际发成了 1.2.4**。不核对这一条就照抄"1.3.0"会重演同一次不一致。
|
||||||
|
|
||||||
|
CHANGELOG 有四处需"请先读这一条"待遇(第 4 条是合并前审查补的):
|
||||||
|
|
||||||
|
1. **最低 Python 提到 3.12**(人类决策,2026-08-24;`requires-python`、ruff `target-version`、README、CLAUDE.md 四处已同步)。这是四处里**唯一会让下游装不上**的变更: 仍在 3.11 的部署 `pip install` 直接被 pip 拒绝。这一条本身就足以把版号推到 **1.3.0**——它不是"新增能力",是缩小了支持面。
|
||||||
|
2. 遥测常驻连接从 `10 × client 数` 变为按需(纯改善,但监控上会看到连接数曲线突变)。
|
||||||
|
3. `aclose` 不再关闭注入的组件。这是修正越权,但若有下游**依赖**了"注入后由 client 代关",升级后会漏关——必须显式声明。
|
||||||
|
4. **直接构造 `GatewaySettings` 需补两个参数**(合并前审查补,2026-08-24)。原稿漏了这一条,还把两个新字段写成"带缺省"——它们与相邻三个遥测键一样**无默认值**,缺省只在 env 装配路;直接构造的调用点升级即 `TypeError`,是货真价实的破坏性变更。
|
||||||
|
|
||||||
|
**版本提升的两项前置——已于 2026-08-24 执行完毕**(顺序不可颠倒,先改语法会当场把 import 全炸掉):
|
||||||
|
|
||||||
|
| # | 前置 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 重建 conda 环境(原 3.11.15 不满足新的 `requires-python`,`make install` 会被 pip 拒绝) | `PolyGateway` 重建为 **3.12.13**;`make install` 通过。比对新旧 `pip freeze` 发现重建**只**缺发布工具链(`build`/`twine` 及依赖,不在 `make install` 的 extras 里),已补装(twine 7.0.0) |
|
||||||
|
| 2 | `target-version = "py312"` 启用 UP047,3 处须改 PEP 695 语法(该语法在 3.11 是 **SyntaxError**) | `gather_bounded`(`client.py:443`)、`_anext_within`(`streaming.py:39`)、`stream_with_liveness_timeouts`(`streaming.py:60`)改为 `def f[T](...)`;两文件的模块级 `_T = TypeVar("_T")` 与 `TypeVar` import 随之删除 |
|
||||||
|
|
||||||
|
验证: `make check` 全绿(ruff format + lint + import-linter 契约 KEPT),全套件 **973 passed / 23 skipped / 45 deselected(slow),覆盖率 94%**。这三处改动**不是本 issue 的重构**,是版本提升的直接后果,归入版本提升那个前置提交。
|
||||||
|
|
||||||
|
## 8. 已定决策(人类,2026-08-24)
|
||||||
|
|
||||||
|
| # | 决策 | 随之固定的实施边界 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | C 组只读状态快照**要做** | 新增**独立**端口 `TelemetryStatusProvider`(`TelemetryRecorder` 不动,理由见 §3.3);`types.py` 加 `TelemetryStatus`;client 侧一处 `isinstance` 判定。命名避开 `health`(该词在 `ports.py` 已两处占用) |
|
||||||
|
| 2 | D 组(所有权纪律)**一并做** | 改动面从 telemetry 扩到 client/embedding/ocr/backends。拆为**独立前置提交**(纪律统一 + 泄漏修复),验收标准"全套件绿 + 新增用例只在所有权层",该提交即回滚点 |
|
||||||
|
| 3 | 缺省 `POOL_MAX=4` / `WRITE_TIMEOUT=5.0` | 按 §3.5 落 config 校验;README 须给出调参口径,否则这两个旋钮等于不存在。**人类当时定的公式 `pool_max ≈ 期望吞吐 × RTT` 已被 §10 修订 #1 作废**(偏乐观一倍),文档一律写实测值 15.6 行/秒 |
|
||||||
|
| 4 | **最低 Python 提到 3.12**,版号定 **1.3.0** | 消解 §6 的 `asyncio.timeout` 版本取舍(可直接用,不退回 `wait_for`)。两项前置(重建环境、UP047 三处改 PEP 695)**已执行完毕并验证**,详见 §7。版号 1.3.0 的依据是缩小支持面,不是新增能力 |
|
||||||
|
|
||||||
|
## 9. 审查留痕(Codex,2026-08-24)
|
||||||
|
|
||||||
|
报 3 阻断 + 3 应改 + 1 可选,**逐条独立核实后 6 条采纳、1 条改判**。采纳的都不是措辞问题,而是"承诺比实现能给的更强"这同一类错误的不同实例。
|
||||||
|
|
||||||
|
| # | 档 | 结论 | 落点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 阻断 | **采纳**。`async with pool.acquire()` 的释放路径是 shielded 且复用 acquire 的 timeout,业务路径真实上界 ≈ 2 × 预算。核实于 `pool.py:886-889, 930-937` | §3.1 第 3 条;§5 收尾路径层① |
|
||||||
|
| 2 | 阻断 | **采纳,并回头改了判据本身**。SQLSTATE `42` 整类归行级与"能被外部修好的一律冷却重试"自相矛盾。补出第 2 句判据(行级 vs 环境级看"与本行数据有没有关系"),`42501`/`42P01` 改判环境级,`42703` 降为唯一具名例外 | §3.2 判据 2 与第 2 点;§5 单元层;§6 |
|
||||||
|
| 3 | 阻断 | **改判为实现约束**(非设计缺陷)。原稿"工厂置 `_owns_*`"已隐含"注入即不拥有",但确实没写死默认值。补为显式条款 | §3.4 第 1 条 |
|
||||||
|
| 4 | 应改 | **采纳,且原稿的理由本身是错的**。原稿称"库外无第三方实现者故加属性零成本"——这只覆盖静态类型,漏了 `TelemetryRecorder` 是 `@runtime_checkable`(`ports.py:246`),加属性会让 `tests/unit/test_ports.py:137,141` 的 `isinstance` 当场变 False。改为独立端口 | §3.3;§5 契约层;§6 |
|
||||||
|
| 5 | 应改 | **采纳**。`Pool.close()` 等 in-flight 释放会无限挂,60s 只 warning(`pool.py:939-948, 961-972`) | §3.2 第 4 点;§5 收尾路径层② |
|
||||||
|
| 6 | 应改 | **采纳**。limiter/breaker 引用要传穿三个 client,原稿只写了 chat | §3.4 第 3 条 |
|
||||||
|
| 7 | 可选 | **采纳**。吞吐算术只对热池稳态成立,冷启动另有口径 | §6 |
|
||||||
|
|
||||||
|
**本轮自查另补两条 Codex 未发现的**: ① `health` 一词在 `ports.py` 已被 `check_health`(`:90`)与 `health(source_name) -> float`(`:234`)占用两次,故快照改名 `TelemetryStatus`(§3.3);② `asyncio.timeout` 是 3.11 新增而 `requires-python = ">=3.11"`,3.11.0/3.11.1 的 `uncancel` 有已知缺陷,实施时须在"抬最低版本"与"改用 `wait_for`"之间选一(§6)。
|
||||||
|
|
||||||
|
Codex 的取消穿透实测与本会话结论一致(外部 `task.cancel()` 在 `asyncio.timeout` 内冒出的是 `CancelledError` 而非 `TimeoutError`),两处独立验证互为佐证。
|
||||||
|
|
||||||
|
## 10. 实施期修订(2026-08-24,T0–T7 执行中发现)
|
||||||
|
|
||||||
|
设计经人类审后实施,过程中三处需要回改设计本身——都不是措辞问题,而是"原稿的事实基础不够"。逐条落回正文而非只记在这里,以免后来人读正文时踩同一个坑。
|
||||||
|
|
||||||
|
| # | 修订 | 落点 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **吞吐算术偏乐观一倍**。原稿按 `pool_max / RTT` 估 32 行/秒,T3 实测 50 行并发批 3.2s(≈15.6 行/秒)——`INSERT` 的实际往返比测 RTT 用的 `SELECT 1` 重。方向不变,但下游调参必须拿实测数字 | §3.5 表、§6 两格 |
|
||||||
|
| 2 | **原稿未预见的一处真 bug**: 注入外部池时 `aclose()` 之后的下一次写入会拿 DSN 偷偷自建一个池。与 §1.5 三条同根因,只是表现在关闭之后,T4 修掉 | §1.5 |
|
||||||
|
| 3 | **两条论证被补强**: ①"认不出的失败归行级"这个保守缺省在建池路径上安全,理由是 `min_size=0` 让重试建池零成本(T5);②所有权判定必须用 `is not None` 而非 `or`,否则注入 falsy 后端时自建分支与所有权标志漂移(T1) | §3.2 末、§3.4 |
|
||||||
|
| 4 | **日志级别的决策点收敛到 tracker**(独立验证发现)。原实现在 recorder 的 fatal 分支另发一条 `logger.error`,而 tracker 同时发一条语义重复的 warning——同一个事实两条日志,"级别"这个决策两个源头。改为 `enter_degraded` 按 `fatal` 选级别(error / warning),recorder 不再另发;SQLite 侧的致命档同步升为 error。**这条决策此前没有执法点**: 测试 fixture 挂 `level="WARNING"`,ERROR 与 WARNING 同池,删掉那条 error 用例照样绿。补 `captured_logs` fixture(连级别一起捕获)后三处补上级别断言 | §3.2 表、§3.3 表、§5 单元层 |
|
||||||
|
| 5 | **`acquire` 传的是完整预算,不是剩余预算**(独立验证发现,改文档不改代码): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍。行为无害,实测总耗时正好等于预算 | §3.1 |
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-08-25-thinking-observability-design
|
||||||
|
title: "推理可观测性一等化(issue #16 + #17)"
|
||||||
|
date: 2026-08-25
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理可观测性一等化(issue #16 + #17)
|
||||||
|
|
||||||
|
> 类型:design|日期:2026-08-25|状态:待人类确认
|
||||||
|
> 事实基础见 `findings/2026-08-25-thinking-observability-regression.md`(本文所有实测引用均出自该文)。
|
||||||
|
> 沿用 `2026-08-02-thinking-capability-design.md` 的先例:经充分实测后直接给出单一方案,不列备选;被否决的路见 §9。
|
||||||
|
|
||||||
|
## 1. 问题不是 issue 说的那个
|
||||||
|
|
||||||
|
issue #16/#17(Gitea `iomgaa/PolyGateway`,原文经 `tea issues 16` / `17` 读取;本仓库 remote 非 GitHub,`gh` 读不到)判定"MiniMax-M3 开启推理静默失效,模型不推理"。**实测推翻了这个诊断**:M3 的推理完全正常——流式路径下 `reasoning_content` 有 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
|
||||||
|
|
||||||
|
真正发生的是:**MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 deepseek 在同一网关同一 key 上照常返回),于是 `reasoning_tokens` 恒为 NULL;而 e2e 的四条用例把 `reasoning_tokens` 当作唯一判据,于是集体判红。
|
||||||
|
|
||||||
|
**库自己握着决定性证据却没用它**:`LLMResponse.thinking` 在同一次调用里是 185 字符的实打实推理正文,从未参与任何"推理是否发生"的判定。
|
||||||
|
|
||||||
|
所以这是一次**可观测性缺口**,不是功能故障。而缺口的形态——库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`——正是 P5 要消灭的静默掩盖。
|
||||||
|
|
||||||
|
## 2. 根因三层
|
||||||
|
|
||||||
|
| # | 缺陷 | 只修外层会留下什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① | `reasoning_tokens=None` 同时承载"没推理"与"没上报"两个语义,不可区分。`types.py` 的 docstring **已经写明这个歧义,但只是描述它,没有解决它** | 换个供应商停报 ctd,同样的红再来一次 |
|
||||||
|
| ② | 解析出的 `thinking` 文本从未接入任何判定:e2e、遥测、下游看的都只有 `reasoning_tokens` | 库继续把手里的硬证据丢在地上 |
|
||||||
|
| ③ | 能力表是**静态单向**声明(只有 `can_disable`),且没有任何机制把声明与运行时观测对账 | **下一个同构故障已在等着** |
|
||||||
|
|
||||||
|
第 ③ 层最要紧。设想某天 M3 变成不能关推理:库照常注入 `reasoning_effort=none`,模型照常推理,下游拿到推理内容却以为关了,而库全程不吭声——与本次同构,且更隐蔽(本次至少有测试变红,那次连测试都是绿的,因为 L1 的判据同样只看 `reasoning_tokens`)。能力表过期是**必然事件**(M3 的 evidence 停在 8-02 整整 23 天),设计必须把它当常态处理,而不是靠人记得去复测。
|
||||||
|
|
||||||
|
## 3. 设计主张
|
||||||
|
|
||||||
|
一句话:**把"这次推理到底发生没发生"从下游的猜测变成库的一等返回值,由多信号裁定;单次响应判不出来时如实说"未知",绝不伪装成"没有";并用它与能力表持续对账,让声明过期成为可报警事件。**
|
||||||
|
|
||||||
|
三条纪律贯穿全文:
|
||||||
|
|
||||||
|
- **能从数据可靠推断的,绝不进静态表。** 静态表必然过期,这次就是。
|
||||||
|
- **判不出来就叫"未知",不许折叠进"没有"。** 折叠是 ① 的病根。
|
||||||
|
- **最硬的证据优先。** 推理正文是事实本身,token 计数是对事实的转述;转述缺失时事实仍然作数。
|
||||||
|
|
||||||
|
## 4. 数据模型
|
||||||
|
|
||||||
|
### 4.1 `ThinkingObservation` 三态(新增,响应侧)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ThinkingObservation(StrEnum):
|
||||||
|
OBSERVED = "observed" # 确证推理发生
|
||||||
|
ABSENT = "absent" # 确证未推理(正面证据)
|
||||||
|
UNKNOWN = "unknown" # 无任何信号,判不出来
|
||||||
|
```
|
||||||
|
|
||||||
|
**枚举定义在 `types.py`,裁定逻辑在 `thinking.py`——两者必须分开。** 它是 `LLMResponse`/`TransportResult` 的字段类型,而 `types.py` 是最内层、不得 import 任何具体实现(P7,import-linter 契约执法)。把枚举放进 `thinking.py` 会让最内层反向依赖决策模块,契约当场判红。纯值类型归最内层、决策逻辑归上层,是本设计的分层落法。
|
||||||
|
|
||||||
|
取 `StrEnum` 而非裸 `str` 常量:取值域显式、可类型检查,且它是 `str` 子类,`dataclasses.asdict` + `json.dumps` 天然可序列化(缓存回放路径见 §6)。
|
||||||
|
|
||||||
|
裁定纯函数 `observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation`,四条分支按顺序:
|
||||||
|
|
||||||
|
| 条件 | 结果 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| `thinking.strip()` 非空 | OBSERVED | 推理正文是事实本身,压倒一切 |
|
||||||
|
| `reasoning_tokens > 0` | OBSERVED | 上游明确上报了推理用量 |
|
||||||
|
| `reasoning_tokens == 0` | ABSENT | 上报了且为零 = "未推理"的正面证据 |
|
||||||
|
| 其余(`None`) | UNKNOWN | 无信号,不猜 |
|
||||||
|
|
||||||
|
**判据取 `bool(thinking.strip())` 而非 `bool(thinking)`**:transport 收集 `reasoning_content` 时只判 truthy(`openai_compat.py`),上游返回纯空白串就会被计成"观测到推理"。网关响应是外部输入,校验后使用(P5)。
|
||||||
|
|
||||||
|
映射到实测:
|
||||||
|
|
||||||
|
| 场景 | observation | 是否诚实 |
|
||||||
|
|---|---|---|
|
||||||
|
| M3 开启,流式 | OBSERVED | ✅ 有 185 字符正文 |
|
||||||
|
| M3 开启,非流式 | UNKNOWN | ✅ 确实观测不到(正文与 ctd 双缺) |
|
||||||
|
| M3 关闭 | UNKNOWN | ✅ 判不出——**且必须承认判不出**,见下 |
|
||||||
|
| qwen 开启 | OBSERVED | ✅ 两个信号都在 |
|
||||||
|
|
||||||
|
**`UNKNOWN` 不具证伪力,不得声称它能保障关闭方向。** M3 关闭档落在 `UNKNOWN`,这意味着库无法证明推理真的关掉了。对账(§5)能提供的保障只有一个方向:**若模型真的推理了,可观测路径会把结果翻成 `OBSERVED`,告警随之触发**——M3 流式正属此列(关闭档若失效,正文会冒出来)。而不可观测路径(M3 非流式)没有任何保障,这一点必须写在文档里而不是假装有。**告警覆盖的是可观测路径,不是全部路径**。
|
||||||
|
|
||||||
|
`ABSENT` 这一支在当前三家供应商上**实测永不触发**(未推理时都是整个容器缺失,无人报 `0`)。仍然保留:协议允许上报 `0`,而一旦有供应商这么做,它就是唯一能把"没推理"与"没上报"分开的信号——为一个已知会出现的未来留一个空槽,不是 YAGNI 违例。
|
||||||
|
|
||||||
|
### 4.2 明确不做:不把"可观测性"写进能力表
|
||||||
|
|
||||||
|
诱惑很大:给 `ThinkingCapability` 加一个 `reports_reasoning_usage: bool` 或 `observable_in_non_stream: bool`。**否决**。理由是本次故障的教训本身——静态声明会过期,而过期表现为静默错觉。可观测性每次响应都能直接看出来,把它冻进静态表等于再造一个 8-02 版本的定时炸弹。
|
||||||
|
|
||||||
|
同理否决"看 `completion_tokens_details` 容器在不在"这一判据:实测三家在未推理时都是容器整体缺失,该信号与真实信号高度混淆,用它裁定等于把噪声当信号。
|
||||||
|
|
||||||
|
## 5. 对账:声明 × 观测
|
||||||
|
|
||||||
|
在 transport 拿到结果处做一次比较,矛盾即 warning:
|
||||||
|
|
||||||
|
| 请求方向 | 观测 | 能力表 | 处置 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `enable_thinking=False` | OBSERVED | 已登记 `can_disable=True` | **warning**:能力表漂移——声明说可关闭,实测推理了。附 model 与 `evidence` 日期,指路 `register_capability` |
|
||||||
|
| `enable_thinking=False` | OBSERVED | 未登记 | **warning**:关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
|
||||||
|
| `enable_thinking=True` | ABSENT | 任意 | **warning**:注入了开启参数,上游明确上报未推理 |
|
||||||
|
| `enable_thinking=True` | UNKNOWN | 任意 | **warning 一次**:推理参数已注入但本路径观测不到,无法确认是否生效;**若为非流式路径,推理内容可能已计费却不回传**(M3 实测 completion 53 vs 关闭档 3) |
|
||||||
|
| `False` | UNKNOWN | 任意 | 不表态——不能证伪(§4.1) |
|
||||||
|
| `None`(不干预) | 任意 | 任意 | 不表态——调用方没提要求,无从谈"违背" |
|
||||||
|
|
||||||
|
前两行必须分开:`resolve_thinking` 的 Phase 3 允许未登记模型按 provider 形态尽力注入并预先 warning,那是**事前猜测**;这里的对账是**事后实证**,两者文案不能混。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。
|
||||||
|
|
||||||
|
第四行是 issue #17 关切的"静默失效"的诚实版本:库不再默不作声,而是明说"我注入了,但我看不见结果"。M3 非流式每次都落这一档,故节流不可少。
|
||||||
|
|
||||||
|
**不抛错**,三条理由:一次观测不足以否决一次成功的调用;P5 的降级方向铁律只对限流/熔断要求"报错而非放行",可观测性属遥测方向,降级即 warning;矛盾结果已随 `LLMResponse` 与遥测落地,处置权归下游。
|
||||||
|
|
||||||
|
**节流**:per transport 实例的 `set[(source, model, direction)]`,同一组合只喊一次,与既有 `_warned_models` 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。键含**源名**是因为多源多账号是本库的核心场景:同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警文案定位不到该查哪个网关(源名在调用点拼进文案,不进 `reconcile_thinking` 的签名——那是纯判定函数,源名是定位信息而非判据)。两个 set 分开维护的理由是**语义不同**(一个记"未登记能力已告警过",一个记"某源某方向的矛盾已告警过"),共用会让两种告警的生命周期纠缠在一起;不是键会碰撞——两者键空间本就不相交。
|
||||||
|
|
||||||
|
这一条是本设计的灵魂:它把"能力表过期"从**静默错觉**变成**日志里的显式告警**,成本是一次枚举比较。
|
||||||
|
|
||||||
|
## 6. 落点清单
|
||||||
|
|
||||||
|
**源码**
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|---|---|
|
||||||
|
| `types.py` | 新增 `ThinkingObservation`(枚举归最内层,§4.1);`LLMResponse` 增 `thinking_observation: ThinkingObservation = UNKNOWN`(只增不删,迁移兼容);`TransportResult` 同增 |
|
||||||
|
| `thinking.py`(**新建**) | 推理这件事的全部**决策**,见 §7 |
|
||||||
|
| `providers.py` | 收缩为纯注册表:`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`/`register_provider` |
|
||||||
|
| **`ports.py`** | `TelemetryRecorder.record_llm_call` 24 参 → 25 参。该 docstring 明定"新增参数不设默认值"(库外无第三方实现者),故两个 recorder 与全部测试替身必须同步。**这是端口 Protocol 签名变更**,属 CLAUDE.md 强制人类确认档 |
|
||||||
|
| `transports/openai_compat.py` | 组装 `TransportResult` 时调 `observe_thinking`;对账告警落此处(唯一同时握有请求方向与响应结果的地方) |
|
||||||
|
| `middleware/retry.py` | 透传新字段 |
|
||||||
|
| `middleware/telemetry.py` | `_AttemptUsage` 增一字段;三个 `emit_*` 各传一行;`_record` 签名增一参——**全部经既有单一出口 `_record` 抵达 recorder**,不新开调用点(§12) |
|
||||||
|
| **`middleware/cache.py`** | `_rehydrate` 走 `LLMResponse(**fields)`,JSON 复活的是**裸字符串**而非枚举实例:须显式转 `ThinkingObservation(...)`。域外取值(多版本共用同一 Redis 时,更新版本写入的新态)降级为 `UNKNOWN` 并单独告警,内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存响应;"整条作废"只留给真正破坏内容完整性的失败(JSON 坏了、结构化重建不过) |
|
||||||
|
| `telemetry/schema.py` | 新列 `thinking_observation TEXT`,两端 DDL + 两份 backfill + `COLUMNS`;INSERT 字段 24→25,物理列 25→26 |
|
||||||
|
| `telemetry/sqlite.py`、`telemetry/postgres.py` | 实现新参 |
|
||||||
|
| `client.py` | import 路径改指 `thinking.py` |
|
||||||
|
| `__init__.py` | 新增包根导出,见 §7 |
|
||||||
|
|
||||||
|
**测试**
|
||||||
|
|
||||||
|
`tests/unit/` 下 `test_types.py`(默认值为 UNKNOWN、位置构造兼容、枚举归属模块)、`test_ports.py`(端口签名冻结测试与 recorder 替身)、`test_openai_compat.py`(裁定四分支、优先级、对账三类告警、节流只喊一次)、`test_retry.py`(透传)、`test_telemetry.py`(列数/列序/组装)、`test_cache.py`(回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中、内容坏了才回源)、`test_package.py`(包根导出面,比照 `TelemetryStatus` 先例)、`test_providers.py`(拆分后的注册表);`tests/integration/test_postgres_telemetry.py`(新列 backfill 与 round-trip);`tests/e2e/test_thinking_live.py`(判据重建,§8)。
|
||||||
|
|
||||||
|
**文档**(发布清单第 1 步要求构建前改完)
|
||||||
|
|
||||||
|
`README.md` 的"必录 24 字段"→ 25,**须用 `inspect.signature` 实测而非凭记忆**;`research-wiki/ARCHITECTURE.md` 的 D11、§5.1 响应字段、§7.8 遥测字段、§8 模块结构(补 `thinking.py`);`research-wiki/schemas/llm-calls.md`(标题仍写"22 字段",已过期两轮,本次一并订正为 25);`research-wiki/index.md`(登记本 design 与 finding);`CHANGELOG.md`(断裂项置顶,§13)。
|
||||||
|
|
||||||
|
`thinking_observation` **不进缓存 key**:它是结果不是请求。缓存回放的历史响应带回历史 observation,与 `reasoning_tokens`/`cached_prompt_tokens` 的既有回放口径一致。
|
||||||
|
|
||||||
|
默认值取 `UNKNOWN` 使得任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
|
||||||
|
|
||||||
|
## 7. 模块边界:为什么新建 `thinking.py`
|
||||||
|
|
||||||
|
现状 `providers.py` 装着两件事:provider 注册表(形态)与推理决策(`resolve_thinking` + 能力表)。加入响应侧裁定与对账后它会变成"推理这件事的一切",一句话说不清职责(P3)。
|
||||||
|
|
||||||
|
| 模块 | 职责 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `providers.py` | **provider 是什么** | `ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`、`register_provider` |
|
||||||
|
| `thinking.py` | **推理这件事的全部决策** | `ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`(请求侧注入)、`ThinkingUnsupportedError`、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义**——纯值类型归 `types.py`(§4.1) |
|
||||||
|
|
||||||
|
符合 P7"决策逻辑与状态存储分离":注册表存声明,`thinking.py` 做决策。未来任何推理相关能力都有唯一归属,不必再挑"放哪个文件"。
|
||||||
|
|
||||||
|
**同时把公共符号提升到包根导出**:`ThinkingCapability`、`ThinkingObservation`、`register_capability`、`get_capability`、`resolve_thinking`、`ThinkingUnsupportedError`。`__init__.py` 的 docstring 早已写明"顶层导出即公共 API 面",而这些符号此前只能深路径 import——**给下游一个稳定引用点,才是模块重组不再破坏下游的前提**。这是本次一并消除的第四项债务。
|
||||||
|
|
||||||
|
破坏面:`from polygateway.providers import ThinkingCapability / resolve_thinking / get_capability / DEFAULT_CAPABILITIES` 会断。这些符号不在包根 `__all__` 内,且三个参考项目尚未迁移接入(M4 未完成),实际下游为零。CHANGELOG 显式列出并给出改法。
|
||||||
|
|
||||||
|
## 8. e2e 判据重建
|
||||||
|
|
||||||
|
四条红用例的病根是判据盲区,不是被测行为。逐条重建:
|
||||||
|
|
||||||
|
| 用例 | 旧判据 | 新判据 |
|
||||||
|
|---|---|---|
|
||||||
|
| L1 关闭 | 每轮 `reasoning_tokens in (None,0)` | 每轮**不是 OBSERVED**。证伪力不减反增:模型若偷偷推理,流式必带出正文 → OBSERVED → 红 |
|
||||||
|
| L2 开启 | 多数轮 `reasoning_tokens>0`,退路 `completion>100` | 多数轮 **OBSERVED**;**删除 `_ON_MIN_COMPLETION` 魔数退路** |
|
||||||
|
| L2b 锚点 | `prompt_tokens` 两档分开 | 不变——它一直是对的,也是本次开启方向唯一没红的证据 |
|
||||||
|
| L3b 非法值反证 | 非法值多数轮推理 | 同 L2 判据;补注 provider 不可移植性(minimax 返 200 照常推理,qwen 返 400) |
|
||||||
|
| L4 extra_body 覆盖 | 多数轮推理 | 同 L2 判据 |
|
||||||
|
| L5 非流式 | 非流式重跑 L1/L2,要求开启档观测到推理 | **重新定义**,见下 |
|
||||||
|
|
||||||
|
删掉 `_ON_MIN_COMPLETION` 是有意的。它是"`reasoning_tokens` 被中转吃掉时的退路",而实测两档的 completion 分布重叠(关闭档最高 46、开启档最低 13),这个退路从一开始就不成立——它让判据看起来有兜底,实则在噪声里画了条线。有了 `thinking` 正文这个真信号,魔数退路失去存在理由。
|
||||||
|
|
||||||
|
**L5 是本次改动里最重要的一条。** M3 非流式下推理正文与 ctd 双双缺失(实测),旧断言"非流式开启档应观测到推理"**永远不可能成立**——它断言的是一件事实上不发生的事。新断言改为两条:其一 `prompt_tokens` 锚点在非流式下仍然分开(证明参数确实到达了模型),其二 observation 为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。
|
||||||
|
|
||||||
|
**从"断言一件不成立的事"变成"断言库对这件事的诚实"**——这正是本设计要立的规矩。
|
||||||
|
|
||||||
|
同时在 e2e 报告与 `DEFAULT_CAPABILITIES` 的 evidence 里登记:M3 非流式路径推理不可观测,下游用非流式开推理会**付费买看不见的推理**(completion 53 vs 关闭档 3)。库修不了上游,但必须让它可见。
|
||||||
|
|
||||||
|
## 9. 被否决的路
|
||||||
|
|
||||||
|
| 备选 | 否决原因 |
|
||||||
|
|---|---|
|
||||||
|
| 只把 e2e 判据从 `reasoning_tokens` 改成"看 `thinking` 非空" | 能让四条转绿,但 ① ③ 两层一个不动:下游拿到的仍是歧义的 `None`,能力表过期仍然静默。修的是测试不是库 |
|
||||||
|
| 给 `ThinkingCapability` 加可观测性字段 | 静态声明必然过期,等于再造一个 8-02 版定时炸弹(§4.2) |
|
||||||
|
| 用"`completion_tokens_details` 容器在不在"区分 ABSENT/UNKNOWN | 实测三家未推理时都是容器整体缺失,该信号与真实信号混淆(§4.2) |
|
||||||
|
| transport 内维护"该源历史上是否上报过推理信号"的学习态 | 行为依赖历史 → 不可复现、难测试;与"纯 asyncio 中立、无隐式状态"相抵 |
|
||||||
|
| 观测与声明矛盾时抛错 | 一次观测不足以否决一次成功调用;且与降级方向铁律的分工不符(§5) |
|
||||||
|
| 顺手把遥测四处复制的参数列表收敛为单一 helper | 见 §12 |
|
||||||
|
|
||||||
|
## 10. 非功能维度
|
||||||
|
|
||||||
|
**并发与取消**:裁定是纯函数,无 I/O、无状态;对账节流集合是 per-transport-instance 的 set,无跨实例共享、无模块级单例。`CancelledError` 路径完全不变(新增代码不在任何 await 之间持有资源)。
|
||||||
|
|
||||||
|
**降级方向**:可观测性属遥测方向 → 静默降级(warning),不报错、不阻断调用。遥测新列走既有 backfill;旧表缺列时既有的"缺列告警 + 降级写入"逻辑原样覆盖。
|
||||||
|
|
||||||
|
**幂等与重复**:纯函数,重复调用同结果。遥测 INSERT 仍走 `ON CONFLICT DO NOTHING` / `INSERT OR IGNORE`。
|
||||||
|
|
||||||
|
**持久化与原子性**:仅增一列,无写入路径变化。新列排在 `created_at` 之后(旧表只能 ALTER 追加到末尾,新建库若插在前面则两条路径的物理列序分叉——既有列序纪律,不可违)。PG 侧 `TEXT` 可空、无默认值,补列只改 catalog 不重写全表。
|
||||||
|
|
||||||
|
**零业务假设**:新增词汇全部是模型调用领域术语(thinking/reasoning/observation),无业务领域词。
|
||||||
|
|
||||||
|
## 11. 错误处理与测试策略
|
||||||
|
|
||||||
|
新增裁定不产生新的失败模式,**不进四分类**。`ThinkingUnsupportedError`(装配期配置错误,`ValueError` 子类)的语义与抛出位置不变,只换模块归属。
|
||||||
|
|
||||||
|
| 层 | 覆盖 |
|
||||||
|
|---|---|
|
||||||
|
| 单元 | `observe_thinking` 四条分支 + 空白串不算 OBSERVED;对账四类告警(False×OBSERVED 已登记 / False×OBSERVED 未登记 / True×ABSENT / True×UNKNOWN)与两类不表态;节流只喊一次;`LLMResponse`/`TransportResult` 默认值为 UNKNOWN 且位置构造不破;端口签名冻结(25 参);缓存回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中;遥测归一化对裸 str 与域外值都不丢整行;schema 列数与列序断言(既有测试自动抓);包根导出面 |
|
||||||
|
| 集成 | SQLite/PG 新列 backfill 与 round-trip(既有测试模式) |
|
||||||
|
| e2e | §8 判据重建,合并前 `pytest -m slow` 真跑并存档报告 |
|
||||||
|
|
||||||
|
**先失败后通过的证据**:`observe_thinking` 与对账的单测在字段落地前必然红;e2e 的 L2/L4 在判据改完、字段落地后应从当前 main 的 FAIL 转绿(库本来就拿到了 `thinking`,只是没人看)。L5 的新断言在旧代码上无法表达(`thinking_observation` 不存在),是纯新增覆盖。
|
||||||
|
|
||||||
|
## 12. 明确不做
|
||||||
|
|
||||||
|
**不重构遥测组装路径。** 铁律"遥测调用点收敛为单一 helper"**当前已经满足**:`TelemetryEmitter._record` 是全库唯一调用 `record_llm_call` 的地方(`middleware/telemetry.py` 文件头即如此声明)。三个 `emit_*` 是三个语义不同的入口(逐次尝试 / 缓存命中 / 终态失败),各自组装参数是职责所在,不是复制粘贴债务——本次新增字段照样只经 `_record` 一个出口下沉。
|
||||||
|
|
||||||
|
**不改 M3 的 `can_disable`**:2026-08-25 复测 `reasoning_effort=none` → prompt 194(= 基线)、completion 3、无正文,声明依然成立。只刷新 evidence 日期并补记两条新限制(非流式不可观测、仅 `reasoning_effort` 有效)。
|
||||||
|
|
||||||
|
**不追 MiniMax 为何停报 ctd**:那是上游的事,库无从干预,也不该把自己的正确性押在它身上——本设计的全部要点正是让库在它停报时依然说得清话。
|
||||||
|
|
||||||
|
## 13. 版本号
|
||||||
|
|
||||||
|
本次含:`LLMResponse` 新增公共字段、新增模块 `thinking.py`、新增包根导出、遥测新增一列、`providers.py` 深路径 import 断裂。按语义化版本这是 **minor**。1.3.0 仅新增一个 `TelemetryStatus` 导出即定为 minor,本次变更面更大。
|
||||||
|
|
||||||
|
曾建议 1.4.0,理由是把"深路径 import 断裂"藏在 patch 版号里等于留债——下游看 1.3.0→1.3.1 不会去读 CHANGELOG。
|
||||||
|
|
||||||
|
**人类 2026-08-25 决定:发 1.3.1。** 决定已记录,实施按此执行。既然版号不再承担预警职责,预警必须由 CHANGELOG 独立扛起:断裂项与改法置于本版条目**最前**,沿用 1.3.0"请先读这一条"的体例,不得只在中段一笔带过。
|
||||||
|
|
||||||
|
## 14. 验收标准
|
||||||
|
|
||||||
|
- `observe_thinking` 四条分支与对账三种组合有单测,节流经测试确认只喊一次
|
||||||
|
- `LLMResponse.thinking_observation` 在 M3 开启流式档实测为 `OBSERVED`、非流式档为 `UNKNOWN`、qwen 开启档为 `OBSERVED`
|
||||||
|
- 遥测 SQLite/PG 两端新列均可写可读,旧表 backfill 通过,列序断言绿
|
||||||
|
- `tests/e2e/test_thinking_live.py` 全类绿(`pytest -m slow` 真跑,报告存档 `tests/outputs/e2e/`)
|
||||||
|
- 端口 `record_llm_call` 25 参,两个 recorder 与全部测试替身同步,签名冻结测试绿
|
||||||
|
- 缓存回放的 `thinking_observation` 是 `ThinkingObservation` 实例而非裸字符串
|
||||||
|
- `make lint`(含 import-linter 契约,须确认 `types.py` 未 import `thinking.py`)与全套件绿
|
||||||
|
- README 的遥测字段数经 `inspect.signature` 实测更新为 25;ARCHITECTURE §8 模块结构含 `thinking.py`;`schemas/llm-calls.md` 由过期的"22 字段"订正为 25;本 design 与 finding 进 `research-wiki/index.md`
|
||||||
|
- CHANGELOG 本版条目**最前**列出深路径 import 断裂与改法、端口签名变更、M3 非流式付费不可见推理这一事实(§13)
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-08-26-issue18-pg-test-isolation
|
||||||
|
title: "issue #18: 隔离靠权限强制,目标靠显式声明"
|
||||||
|
date: 2026-08-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# issue #18:隔离靠**权限强制**,目标靠**显式声明**
|
||||||
|
|
||||||
|
> 类型:design|日期:2026-08-26|状态:待 Codex 审 → 人类审
|
||||||
|
> 事实基础见 `findings/2026-08-26-issue18-shared-pg-test-isolation.md`(本文所有实测引用均出自该文)。
|
||||||
|
> 两处需人类拍板的取舍已于 2026-08-26 会话中确认:`--table` **纳入**;7 条写真表的用例**全迁**;`public.llm_calls` 里那 11 行历史孤儿行**不清理**。
|
||||||
|
|
||||||
|
## 1. issue #18 的诊断只对了一半
|
||||||
|
|
||||||
|
issue 判定"行数断言依赖共享实例的当下状态",方向对;它推荐的首选处置(标 `slow`,交发布清单统一跑)**不解决问题**——标 `slow` 只是把假红挪出日常关卡,而这条断言还有另一半失效:
|
||||||
|
|
||||||
|
| 失效方向 | 表现 | 标 `slow` 之后 |
|
||||||
|
|---|---|---|
|
||||||
|
| 假红 | 外部进程写/删共享表 → 断言红,脚本无辜 | 挪到发布关卡,**照样红**,只是红得更少人看见 |
|
||||||
|
| **假阴** | 外部插入与脚本误删互相抵消 → 行数相等 → 静默放行 | **原样保留** |
|
||||||
|
|
||||||
|
这条断言守的是"脚本静默删了共享的真表"。假阴才是它真正的代价,而 `slow` 对假阴毫无作用。
|
||||||
|
|
||||||
|
## 2. 根因三层
|
||||||
|
|
||||||
|
| 层 | 事实 | 后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| L1 | `_public_count` 是全套件唯一一处**全表口径**断言,而同文件的 `_RUN_PREFIX` 机制从设计上就假定"多个进程并行写同一张表" | 两套前提互斥,偶发红是必然而非意外 |
|
||||||
|
| L2 | 一个**安全属性**(脚本不越界)被编码成对**全局可变量**(真表行数)的观测 | 假红 + 假阴,结论既不可靠也不可否证 |
|
||||||
|
| L3 | 之所以只能这么写:`telemetry_retention.py` 的目标表由连接的 `search_path` 隐式决定(`to_regclass('llm_calls')`),**调用点无法声明"我要删哪张表"** | 测试没有别的手段表达"只许动这张表",只好退回事后观测 |
|
||||||
|
|
||||||
|
L3 不是测试的问题,是脚本契约的问题——它同时是生产风险:`search_path` 默认首项是 `"$user"`,换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。脚本现有的应对是把解析结果打印出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
|
||||||
|
|
||||||
|
## 3. 设计主张
|
||||||
|
|
||||||
|
1. **安全属性由数据库权限强制,不由断言观测**——测试跑脚本用的角色对 `public.llm_calls` 无任何权限,越界不是"会被发现",而是"做不到"。
|
||||||
|
2. **目标表由调用方声明**——`--table SCHEMA.NAME` 给出后,目标不再经 `search_path` 推断。
|
||||||
|
3. **测试与真实共享表完全脱钩**——`public.llm_calls` 从此零测试触碰,隔离手法收敛为"临时 schema"一种,并由 lint 门机械化守住。
|
||||||
|
|
||||||
|
## 4. 变更 A:`telemetry_retention.py` 新增 `--table SCHEMA.NAME`
|
||||||
|
|
||||||
|
### 4.1 语义:声明即目标,不是"声明后比对"
|
||||||
|
|
||||||
|
两种可能的实现要先分清:
|
||||||
|
|
||||||
|
| | 做法 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 否决 | 仍按 `search_path` 解析,再与声明比对,不符则退出 | 目标**仍然**由环境决定,`--table` 只是一道确认;且要为"不符"发明第四个退出码语义 |
|
||||||
|
| **选定** | 给了 `--table` 就用 `to_regclass('"schema"."name"')` **精确解析**,绕开 `search_path` | 目标真正由参数决定;不存在则落入既有的"目标表不可用"语义 |
|
||||||
|
|
||||||
|
选定做法的实现落点只有一处——`_purge_postgres` 里 `to_regclass($1)` 的入参从裸 `TABLE` 换成引号限定名,分区探测、统计、分批 DELETE 全部不变(它们本就用解析结果拼 `qualified`)。
|
||||||
|
|
||||||
|
三条支撑它的 PG 语义已实测(PostgreSQL 16.14,见 finding §7):`to_regclass('"schema"."llm_calls"')` 正常解析;**schema 不存在时返回 NULL 而不抛错**;引号限定名**区分大小写**(`"PGWPROBE_S_X"."llm_calls"` → NULL)。前两条决定了"找不到"能落进既有的退出码 2 而不需要新分支,第三条决定了 §4.2 的"逐字比较"是可实现的。
|
||||||
|
|
||||||
|
### 4.2 参数与校验
|
||||||
|
|
||||||
|
| 规则 | 行为 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 |
|
||||||
|
| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 |
|
||||||
|
| **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**,消息点明本脚本只清理 `llm_calls` | 见 §4.4:不加这条,`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" |
|
||||||
|
| 两段均非空;**schema 段须为普通标识符**(`[A-Za-z_][A-Za-z0-9_$]*`) | 不合法 → 退出 **1** | 复杂标识符(含引号的表名)不支持,此时退回不给 `--table` 的路径;写进 `--help`。**本行原写作"均不含 `.` 与 `\"`",实现阶段核出"段内含 `.`"是不可达分支**——按 `.` 切分后恰好两段是前置条件,`a.b.c` 走的是"不是恰好两段"那条消息,故删去该半句 |
|
||||||
|
| **逐字比较,不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 |
|
||||||
|
| 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 |
|
||||||
|
| 无权限 | 后续 `COUNT` 抛 `PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |
|
||||||
|
|
||||||
|
退出码不新增。`1` 留给"参数写错了,重试也没用",`2` 留给"环境不对,值得告警"——这条分界是脚本已有的对调度器契约(见 `_Parser.error` 的注释),本变更沿用。
|
||||||
|
|
||||||
|
### 4.3 目标白名单:为什么表名段不可变
|
||||||
|
|
||||||
|
`--table` 若只校验"两段、非空、无点无引号",一次手误 `--table audit.events` 就会让脚本对一张**恰好也有 `created_at` 与 `tenant_id` 列**的业务表执行同一套 COUNT + 分批 DELETE。脚本的名字、`--help`、退出码 3 的分区提示、README 的定位全都是围绕遥测表 `llm_calls` 写的,它从未声称自己是通用清理器;让参数悄悄扩大作用域,是在一个**默认 dry-run、拿 DELETE 权限跑**的脚本上开一个静默的口子。
|
||||||
|
|
||||||
|
故 `--table` 的可变部分只有 schema 一段。**为什么不干脆改叫 `--schema`**:cron 配置里的那一行必须自解释——运维读 crontab 时看到 `--table public.llm_calls` 就知道全部目标,看到 `--schema public` 还得回去查脚本常量才知道表名。多出的那条校验不是冗余,它本身就是"本脚本的作用域到此为止"的显式声明,且错误消息可以当场把边界告诉用户。
|
||||||
|
|
||||||
|
### 4.4 未声明时的提示
|
||||||
|
|
||||||
|
`--apply` 且未给 `--table` 时,在"目标表: x.y"之后补一行:
|
||||||
|
|
||||||
|
```
|
||||||
|
注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。
|
||||||
|
```
|
||||||
|
|
||||||
|
只在 `--apply` 时打:dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途,多一行提示是噪音。
|
||||||
|
|
||||||
|
## 5. 变更 B:测试角色化——把安全网换成权限边界
|
||||||
|
|
||||||
|
### 5.1 模型
|
||||||
|
|
||||||
|
**凡是启动 `telemetry_retention.py` 子进程的用例,一律用临时登录角色跑,无一例外**——包括正向的 apply/dry-run/分区让路用例。只给"最坏情况"那一条用低权限角色是自欺:正向用例才是带 `--apply` 真删数据的那些,它们若仍用 `.env` 的 superuser DSN 跑,一旦 `search_path` 或 `--table` 出问题,删的就是真表,而新设计里已经没有行数快照会发现它。
|
||||||
|
|
||||||
|
每个这样的用例临时建一个**登录角色** `tmp`,并 `CREATE SCHEMA s AUTHORIZATION tmp`,表由 `tmp` 自己建。于是:
|
||||||
|
|
||||||
|
- `tmp` 是那张表的**属主**——与脚本文档要求的"用维护角色跑"形态一致,测的不是一个失真的现场
|
||||||
|
- `tmp` 对 `public.llm_calls` 一无所有:实测 ACL 为 `{app=arwdDxt/app, chs3_test=ar/app}`,无 PUBLIC 授权
|
||||||
|
|
||||||
|
**必须换角色的原因**:`.env` 里的 `app` 实测 `rolsuper = true`,superuser 无视一切权限检查,用它跑则这条防线不存在。无 `CREATEROLE` 权限的环境 `skip`(项目既有惯例,见 `least_privilege_dsn`)。
|
||||||
|
|
||||||
|
防线已实测:临时角色裸连(`search_path = "$user", public`)对真表执行 `COUNT` 与 `DELETE`,两者均 `InsufficientPrivilegeError: permission denied for table llm_calls`。
|
||||||
|
|
||||||
|
**约束:角色名与 schema 名必须错开。** 实测 `CREATE SCHEMA X AUTHORIZATION X` 时,`"$user"` 会命中自有 schema 并**遮蔽 public**——今天 `least_privilege_dsn` 正是同名形态。同名虽多一层巧合式防护,却让 §5.3 的最坏情况用例根本走不到 public,等于测了个假现场。故 `pg_sandbox` 一律用 `pgw_s_<uuid>` / `pgw_r_<uuid>` 两套名字。
|
||||||
|
|
||||||
|
### 5.2 最坏情况从"事后观测"变成"确定性红灯"
|
||||||
|
|
||||||
|
| 情形 | 旧 | 新 |
|
||||||
|
|---|---|---|
|
||||||
|
| `search_path` 失效,脚本落到 `public` | 事后数行数,可能被并发抵消 | 数据库拒绝 → 退出 2 → 测试红,**且一行都删不掉** |
|
||||||
|
| 外部进程并发读写 `public` | 直接假红 | 与测试无关(不再读 `public`) |
|
||||||
|
|
||||||
|
`_public_count` / `before_public` / 那条 `assert` 整体删除。
|
||||||
|
|
||||||
|
### 5.3 新增一条"最坏情况"用例,替代被删掉的安全网
|
||||||
|
|
||||||
|
用属主角色的 DSN **不挂 search_path** 跑脚本(于是解析走 `"$user", public`,角色同名 schema 不存在 → 落到 `public.llm_calls`),不给 `--table`:
|
||||||
|
|
||||||
|
- 断言退出码 **2**、stderr 非空且点名 `llm_calls`、临时表内容一行未变
|
||||||
|
- **不断言 PG 的英文错误原文**(服务端 `lc_messages` 不由测试掌握),也**不出现 `public.llm_calls` 字面量**(见 §7 的 lint 门)
|
||||||
|
- 库里没有 `public.llm_calls` 的环境上,脚本报"找不到表"同样退出 2 —— 两条路都绿,用例不因环境而摇摆
|
||||||
|
|
||||||
|
这条用例把"最坏情况"钉成确定性的红/绿,且完全不观测共享状态。
|
||||||
|
|
||||||
|
## 6. 变更 C:7 条用例迁出 `public`
|
||||||
|
|
||||||
|
| 用例 | 迁移后验的东西 |
|
||||||
|
|---|---|
|
||||||
|
| `TestSchema::test_schema_has_frozen_columns_in_order` | **变强**:现在验的是本机那张被历史 `_BACKFILL` 补过列的老表,迁到 fresh schema 后验的是**库当前 DDL 建出来的表** |
|
||||||
|
| `TestObservabilityColumns::test_values_round_trip` | 不变(只要求表存在) |
|
||||||
|
| `TestSchema::test_call_id_idempotent` / `test_concurrent_writes_all_land` | 不变(与表在哪无关) |
|
||||||
|
| `TestDegradation::test_row_failure_does_not_poison_later_rows` / `test_aclose_idempotent` | 不变 |
|
||||||
|
| `TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max` | 不变(验的是连接数),但**必须保留唯一 `application_name`**,见下 |
|
||||||
|
|
||||||
|
### 6.1 `_RUN_PREFIX` 有两个职责,只能删掉其中一个
|
||||||
|
|
||||||
|
| 职责 | 落点 | 处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| call_id **行隔离** | `_cid()` 的 63 处调用、5 处 `LIKE '<前缀>%'` 过滤、`dsn` fixture teardown 的 `DELETE` | 删除——schema 隔离已完全取代它 |
|
||||||
|
| **`application_name` 唯一** | `test_pool_does_not_preconnect_and_stays_within_pool_max` 用它标记本池连接,再查 `pg_stat_activity` 数连接数 | **保留**(就地生成 uuid)——连接是**实例级**共享资源,schema 隔离对它无效;改成固定名字会把并行进程的连接数进来,等于把偶发红从表层搬到连接层 |
|
||||||
|
|
||||||
|
删除行隔离用途时调用点做**机械替换**(`_cid("c1")` → `"c1"`),不改任何断言语义;5 处 `LIKE` 过滤逐条在计划里列出并单独验证。
|
||||||
|
|
||||||
|
### 6.2 顺带封掉一个仓库自己已记载的隐患
|
||||||
|
|
||||||
|
`test_schema_has_frozen_columns_in_order` 今天查的是 `information_schema.columns WHERE table_name='llm_calls'`,**不带 schema 过滤**——库里任何一个残留的临时 schema 里的同名表都会污染结果。这不是推测:`production_template` 的 `except BaseException` 分支注释里已经写明了这个坑("会被残留物在下一次运行里以列数不符的形态误伤"),当时的处置是让另一处 fixture 清理得更干净。迁移时补上 `table_schema = $1`,把它从"靠别人不留残留"改成"自己只看自己"。
|
||||||
|
|
||||||
|
**用函数级而非 module 级 sandbox**:建/删一个 schema 是毫秒级,7 条用例的开销可忽略;module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。
|
||||||
|
|
||||||
|
## 7. 变更 D:`conftest.py` 收敛 + lint 门
|
||||||
|
|
||||||
|
### 7.1 一个沙箱工厂取代七处样板
|
||||||
|
|
||||||
|
`tests/integration/conftest.py` 新增:
|
||||||
|
|
||||||
|
| fixture | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `pg_admin_dsn`(session) | 读 `.env`、缺失 `skip`、库名守卫(只许 `polygateway`)。**命名下划线语义上属内部**,用例不该直接用 |
|
||||||
|
| `pg_sandbox`(function,工厂) | `await pg_sandbox(ddl=..., extra=(), owner_role=False)` → 返回 frozen dataclass(`schema` / `dsn` / `role`);teardown 按 LIFO 统一 `DROP SCHEMA CASCADE` + `DROP OWNED BY` + `DROP ROLE` |
|
||||||
|
|
||||||
|
三条硬约束(缺一条工厂就会自己变成污染源):
|
||||||
|
|
||||||
|
1. **资源逐步登记,`except BaseException` 清理**:建角色成功、建 schema 失败时不会走到 `yield`,普通 teardown 不执行,角色就永久留在实例上(角色是**全局**对象,不随库消失)。`production_template` 已有同款先例,工厂必须继承它而不是简化掉。
|
||||||
|
2. **uuid 后缀取 12 位十六进制**:8 位在并行会话下碰撞概率虽低却非零,而碰撞的后果是 `CREATE ROLE` 失败或误清理别人的残留。加长的成本为零。
|
||||||
|
3. **admin DSN 不做成 fixture**:改为模块私有函数,只被工厂内部调用。做成 fixture 就等于把一个能 `DELETE FROM public.llm_calls` 的连接摆在所有用例面前,"用例不该直接用"只是纪律不是机制。
|
||||||
|
|
||||||
|
今天这套样板在两个文件里重复**七处**(`legacy_schema`、`pre_tenant_schema`、`fresh_schema`、`partitioned_schema`、`least_privilege_dsn`、`least_privilege_pre_tenant_dsn`、`production_template`,加 retention 侧两处)。收敛后清理逻辑只有一份——今天任何一处 teardown 写漏,残留都落在共享库里。
|
||||||
|
|
||||||
|
### 7.2 机械化执法
|
||||||
|
|
||||||
|
`make lint` / `make check` 各加一步:
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
§5.3 的用例已按"不出现该字面量"设计,故门无需豁免名单——**注释与 docstring 同样不例外**,现有多处"共享的 public.llm_calls"措辞改写为"共享表 `llm_calls`"。豁免名单一旦开口,门就退化成建议。
|
||||||
|
|
||||||
|
**这道门是烟雾报警器,不是隔离证明。** 它拦不住 `f"{schema}.{table}"` 拼接、`to_regclass($1)` 参数化、或不带限定名的 `DELETE FROM llm_calls` 配上 admin 的默认 `search_path`。真正的隔离来自两处:工厂 API 不把 admin DSN 交出去(§7.1 约束 3),以及脚本以无权角色运行(§5.1)。文档里必须这样写,否则下一个人会拿这道门当"tests 零触碰 public"的证明。
|
||||||
|
|
||||||
|
## 8. 明确不做
|
||||||
|
|
||||||
|
| 不做 | 理由 |
|
||||||
|
|---|---|
|
||||||
|
| 标 `slow` | §1:对假阴无效;改完之后这条用例的成败不再取决于外部服务状态,它**应该**留在日常关卡里 |
|
||||||
|
| 建临时数据库(而非 schema) | PG 的 schema 对 DML/DDL 已是完备隔离;建库只换来"孤儿库更难清、需 CREATEDB、断连才能 DROP"三项成本 |
|
||||||
|
| 清理 `public.llm_calls` 里那 11 行孤儿行 | 人类决策:那是与迁移项目共用的表,本次不动 |
|
||||||
|
| 给 SQLite 分支加 `--table` | 库文件即目标,无歧义(§4.2) |
|
||||||
|
| 动 Redis 集成测试 | 实测已是每用例 uuid 命名空间/scope,无全表口径断言,不属同类 |
|
||||||
|
| 把 `--table` 做成必填 | 会打断下游既有 cron,属破坏性契约变更 |
|
||||||
|
|
||||||
|
## 9. 残余风险(本设计**不**覆盖,需明写而非默认解决)
|
||||||
|
|
||||||
|
| 风险 | 为什么不在本设计覆盖范围 | 缓解 |
|
||||||
|
|---|---|---|
|
||||||
|
| fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在(建 schema/角色本身就需要它),权限边界对它无效 | 工厂不把 admin DSN 交给用例;§7.2 的门能拦住字面量形态 |
|
||||||
|
| 进程被 `SIGKILL` 时 pytest finalizer 不执行,残留 schema/角色 | 任何进程内机制都做不到 | 命名固定前缀 `pgw_s_` / `pgw_r_`,残留可一条 SQL 查出(`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'`);**不做自动 TTL 清理**——并行会话下"清理别人的残留"会误删正在跑的 schema,比残留本身更危险 |
|
||||||
|
| 共享实例上其他项目往真表写/删 | 不归本库管 | 改完之后本仓库测试对它完全不敏感,这正是本设计的目的 |
|
||||||
|
| `production_template` 仍以管理身份执行不带限定名的 `DELETE` / `DROP TABLE` | 它有意不收敛进工厂(§7.1 末段),三角色与分区语义是它自己的 | 独立验证实测:它的连接 `search_path` **只有**自己那个 schema(`public` 不在路径里),故 `to_regclass('llm_calls')` 返回 `None`——search_path 一旦失手,报的是"关系不存在"而不是静默打到共享表 |
|
||||||
|
| `pg_catalog_probe` 持管理连接 | 工厂自测需要查 catalog 核对残留,这个能力删不掉 | 探针只接受 `SELECT` 开头的语句(有用例钉住);它不交出 DSN,故越界能力止于只读查询 |
|
||||||
|
|
||||||
|
## 10. 版本号与发布
|
||||||
|
|
||||||
|
**1.3.2**(patch)。需在 CHANGELOG 里如实写明:`tools/` 与 `tests/` **都不在 pip 包内**(README 已声明脚本随仓库分发),故 1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同,本版的对外内容是**运维脚本的契约扩展**与测试确定性,不是库能力更新。不得包装成库更新。
|
||||||
|
|
||||||
|
发布按 CLAUDE.md §4.4.1 九步全走,其中与本变更直接相关的:README 需补 `--table` 用法与安装版本约束核对;`make wiki-check` 需在合并前跑过;合并后在 main 上补跑 `pytest -m slow`。
|
||||||
|
|
||||||
|
## 11. 验收标准
|
||||||
|
|
||||||
|
| # | 判据 | 验证方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1a | `--table` 的**参数分类**:sqlite 互斥、非两段、空段、含点/引号、表名段非 `llm_calls` —— 各自退出 1 | 单测(`tests/unit/test_retention_tool.py`,无需 PG) |
|
||||||
|
| 1b | `--table` 的**真实解析行为**:显式指向 sandbox 表成功删除;指向不存在的 schema → 2;指向无权表 → 2;指向分区表 → 仍 3 | **集成用例(必须真连 PG)**——单测只能验参数分类与拼出的目标字符串,验不了 `to_regclass` 的真实语义 |
|
||||||
|
| 2 | 未给 `--table` 且 `--apply` 时打印推断提示 | **集成用例**断言 stdout —— 该提示行只在 PG 分支打印,不连库的单测触发不到它(本行原写作"单测断言 stdout",计划阶段核出该判据不可执行,就地更正) |
|
||||||
|
| 3 | 最坏情况(search_path 落到 public)**删不掉任何行**且退出 2 | §5.3 新用例 |
|
||||||
|
| 4 | 整套 `tests/integration` 连跑三次全绿,其间 `public.llm_calls` 行数由外部任意变动 | 连跑 + 期间手工改动共享表行数 |
|
||||||
|
| 5 | `tests/` 下 `public.llm_calls` 零命中 | `make lint` |
|
||||||
|
| 6 | 迁移未削弱任何用例:7 条用例的断言逐条对照迁移前后 | 计划阶段逐条列表,verifier 复核 |
|
||||||
|
| 6b | `test_pool_does_not_preconnect...` 仍持有唯一 `application_name` | 代码复核 + 两进程并发跑该用例 |
|
||||||
|
| 6c | `test_schema_has_frozen_columns_in_order` 带 `table_schema` 过滤 | 故意在库里留一个残留同名表,用例仍绿 |
|
||||||
|
| 6d | 沙箱工厂 setup 中途失败不留角色/schema | 注入一个会失败的 DDL,跑完查 `pg_namespace` / `pg_roles` 无 `pgw_%` 残留 |
|
||||||
|
| 7 | 全套件 + `-m slow` 全绿 | 合并前 |
|
||||||
|
|
||||||
|
## 12. 审查留痕(Codex,2026-08-26)
|
||||||
|
|
||||||
|
报 6 项实质问题,**全部采纳**,其中两项为阻断级:
|
||||||
|
|
||||||
|
| # | 意见 | 处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **阻断**:`--table` 未限定表名段,会把脚本扩成"任意同形表删除工具"(`--table audit.events` 且该表恰有 `created_at`/`tenant_id` 时真删数据) | 采纳,见 §4.2 新增规则与 §4.3 |
|
||||||
|
| 2 | **阻断**:只给"最坏情况"用例换低权限角色,正向 apply 用例仍用 superuser 跑,则新安全网对最危险的那条路径不生效 | 采纳,§5.1 改为"凡启动脚本的用例一律用临时角色,无一例外" |
|
||||||
|
| 3 | `_RUN_PREFIX` 有第二个职责(`application_name` 唯一),机械删除会让连接池用例失去并发隔离 | 采纳,§6.1;本会话的独立清点也得出同一结论 |
|
||||||
|
| 4 | `test_schema_has_frozen_columns_in_order` 的 `information_schema` 查询不带 schema 过滤 | 采纳,§6.2;核实属实,且仓库注释已记载该坑 |
|
||||||
|
| 5 | 沙箱工厂 setup 中途失败不清理、uuid 后缀偏短、admin DSN 做成 fixture 等于把越界能力摆在所有用例面前 | 采纳,§7.1 三条硬约束 |
|
||||||
|
| 6 | lint 门只防字面量,不能当"零触碰"的证明;`--table` 的验收不能只靠 unit | 采纳,§7.2 定位改写 + §11 拆出 1a/1b |
|
||||||
|
|
||||||
|
**一处处置与建议不同**:Codex 认为 `Public.llm_calls` 这类大小写手误落到退出 2 属"告警误分类",建议归 1。本设计维持 2,理由写在 §4.2——该参数格式合法,能否解析到是环境事实;归 1 会让"schema 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# 推理档位一等化设计(issue #20 及其一般形式)
|
||||||
|
|
||||||
|
> **替代指针(2026-09-09)**:§3–6/8/12 的 AUTO 无条件放行、MiniMax 内置 medium、受管 raw 覆盖与缓存迁移/遥测总括语义,以[1.3.4 已批准设计](2026-09-09-134-thinking-contracts-design.md) §4–8 为准。历史调研与实验事实保留,不倒改为新语义已验证。
|
||||||
|
|
||||||
|
- **日期**: 2026-09-04
|
||||||
|
- **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门)
|
||||||
|
- **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过
|
||||||
|
- **影响面**: `SourceConfig`/`ChatRequest` 公共类型、`ProviderProfile`/`ThinkingCapability` 公共类型、`resolve_thinking`/`reconcile_thinking` 公共函数、缓存 key 公式(ARCH §7.5)、遥测端口(25 → 26 字段)、`.env` 键
|
||||||
|
- **人类拍板(2026-09-04)**: 作用域取「源级默认 + 请求级覆盖」;档位不支持时「默认报错、可显式开映射」;不可关闭时「报错并给可执行替代」
|
||||||
|
- **人类复核(2026-09-04,针对 Codex 异议)**: `effort_fallback` 的最近档映射**要实现**,不因当前无已知消费者而推迟;方案选择标准 = 架构可维护性/清晰度 > 代码简洁 > 鲁棒性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 问题不是 issue #20 说的那个
|
||||||
|
|
||||||
|
issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解决它自己描述的失败,因为二态 bool 在新一代模型上无档可填。
|
||||||
|
|
||||||
|
2026-09-04 调研,四份独立注册表(cherry-studio 客户端注册表、OpenRouter `/models` 的 `reasoning` 字段、LiteLLM 模型元数据、我们自己的网关 new-api `relaykit/relayconvert/reasoning/`)与六家官方文档,三条结论直接推翻 issue #20 的建议:
|
||||||
|
|
||||||
|
| # | 结论 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | **GLM-5.3 官方强制推理**,`thinking.type` 只接受 `enabled`;官方档位 `low/high/max`,`none` **不是**它的档位 | 智谱官方文档;cherry `toggle:false`;OpenRouter `mandatory:true` 三源一致 |
|
||||||
|
| 2 | **`medium` 只在 GPT-5.x / Claude 5 / Gemini 3 三家存在** | 见 §8 档位表 |
|
||||||
|
| 3 | 不可关闭不是孤例: GLM-5.3 系、Gemini 3 Pro / 3.1 Pro 为 mandatory;MiniMax M2.x **接受 `disabled` 但不生效** | 官方文档;与本库 2026-08-02 实测一致 |
|
||||||
|
|
||||||
|
第 1 条意味着 issue #20 建议的 `can_disable=True` 不能登记:我们发出去的 `reasoning_effort:"none"` 是个**未定义值**,智谱按自己的方式处理(多半当最低档)。这正好解释 issue #20 自己观测到的「短提示词 rt≈1.2,5552 token 长上下文跳到 0/54/167」——低档本来就要想,只是短提示词下想得少。
|
||||||
|
|
||||||
|
第 2 条意味着现有 `minimax` profile 那条「`thinking_on` 统一取 medium」的约定,推广到 GLM/kimi/deepseek 上全部是空档。
|
||||||
|
|
||||||
|
**真实缺口**: `enable_thinking: bool | None` 这个类型表达不了现实。补数据不能修复类型。
|
||||||
|
|
||||||
|
## 2. 现状审计(旧行为逐条处置)
|
||||||
|
|
||||||
|
替换 `thinking.py` 的请求侧决策,响应侧与对账基本保留。逐条声明:
|
||||||
|
|
||||||
|
| # | 现有行为 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `enable_thinking` 三态: None 不注入 / True 注入 on / False 注入 off | **保留**语义,降为 `reasoning_effort` 的语法糖(§4.2) |
|
||||||
|
| 2 | `ProviderProfile.thinking_on/off` 两个固定片段,`None`=形态未知 | **替换**为 `ThinkingWire`(§3.3);`None`=未知的语义**保留**。**判据须按请求档位取相关字段**(旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此)——初稿 §4.1 Phase 2 写成「只看 `on_base`」是错的: 那会让「关闭形态已知、开启形态未知」的自定义 provider 在请求 `none` 时被误拒,且指路指向它已经做过的 `register_provider`,比不指更糟(2026-09-05 独立验证查出) |
|
||||||
|
| 3 | `ThinkingCapability.can_disable: bool` | **替换**为 `supported_efforts`;`can_disable` 成为 `'none' in supported_efforts` 的派生(§3.2) |
|
||||||
|
| 4 | `evidence: str` 强制附实测出处 | **保留**,且强化: 初始表全部标注「文档推定,待实测」 |
|
||||||
|
| 5 | `resolve_thinking` 四道关卡(不表态/形态未知/能力未登记/不可关闭) | **保留四关的顺序与语义**,判据从 bool 换成档位(§4.1) |
|
||||||
|
| 6 | 能力未登记 → warning 后尽力注入 | **保留**(新模型不该被库挡住,ARCH §5 R4) |
|
||||||
|
| 7 | `observe_thinking` 多信号裁定三态 | **保留**,不改一行 |
|
||||||
|
| 8 | `reconcile_thinking` 声明 × 观测对账,矛盾返回文案、不抛错 | **保留**,判据扩展到档位(§4.3) |
|
||||||
|
| 9 | transport 按 `(source, model, direction)` 节流告警 | **替换**: 节流键的 `direction` 换成生效档位——同一模型 low 与 max 是两个独立的矛盾 |
|
||||||
|
| 10 | `ThinkingUnsupportedError(ValueError)`,由 transport 翻译为 `RequestRejectedError` | **保留**,新增的档位错误走同一条路 |
|
||||||
|
| 11 | `_build_payload` 中 `resolve_thinking` 结果先于 `extra_body`/`overlay` | **保留**(顺序即优先级,issue #4 决策 A) |
|
||||||
|
| 12 | 缓存 key 不含任何推理参数 | **修复**(§5,现存缺口) |
|
||||||
|
| 13 | 遥测无档位列 | **新增**一列(§6) |
|
||||||
|
|
||||||
|
**有意放弃**: 无。第 3 条的 `can_disable` 是唯一的破坏性变更,迁移见 §12。
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
### 3.1 档位词汇
|
||||||
|
|
||||||
|
八档封闭枚举,取四家参考实现共同收敛的词汇(cherry / OpenRouter / LiteLLM / new-api 用的是同一套):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Effort(StrEnum):
|
||||||
|
NONE = "none"; AUTO = "auto" # 不推理 / 推理但档位由模型自定
|
||||||
|
MINIMAL = "minimal"; LOW = "low"; MEDIUM = "medium"
|
||||||
|
HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
|
||||||
|
```
|
||||||
|
|
||||||
|
`none` 即「不推理」,与强度档同处一个词汇表——这是关键的表达力来源: 「能不能关」不再是独立的布尔,而是 `none` 在不在该模型的支持列表里。
|
||||||
|
|
||||||
|
`auto` 不可省(自审补): newapi 上 26 个模型里有 9 个是**纯开关型**(qwen 五个、MiniMax-M3、glm-5/5.1/4.6v),它们能开推理但没有档位名可填。没有 `auto` 就只能拿某个强度档冒充「开」,而那正是现有 `thinking_on` 硬编码 `medium` 的病根。`auto` 的 wire = `on_base` 不附 `effort_key`,恰好等于旧的 `thinking_on` 行为。四家参考实现都有这一档(cherry 的 canonical selection `'default'|'none'|'auto'|Effort`;new-api 的 `ModeAdaptive`)。
|
||||||
|
|
||||||
|
`Effort` 归 `types.py`(最内层纯值类型),与 `ThinkingObservation` 同处一处,理由相同: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import。
|
||||||
|
|
||||||
|
### 3.2 `ThinkingCapability`: 能力(按 model)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
supported_efforts: tuple[Effort, ...] # 顺序 = 由弱到强
|
||||||
|
evidence: str
|
||||||
|
```
|
||||||
|
|
||||||
|
**不设 `default_effort` 字段**(Codex 审查采纳): 初稿有此字段,唯一消费者是「`enable_thinking=True` 等价于哪档」;自审把该语法糖改成 `Effort.AUTO` 后它就没有消费方了——§4 不用它决策,§6 遥测在不表态时记 `NULL`(库并不观测模型内部默认档,记推定值等于把「没看见」说成「发生了」,违既有纪律)。厂商默认档是**文档知识**,写进 `evidence` 文本即可,不必升格为必须逐模型维护的 API 字段(P1 YAGNI)。
|
||||||
|
|
||||||
|
三个派生量,不单独存字段(存了就会漂移):
|
||||||
|
|
||||||
|
| 派生 | 定义 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `can_disable` | `Effort.NONE in supported_efforts` | 兼容旧语义 |
|
||||||
|
| `cheapest_effort` | 除 `none` 外的第一档 | 不可关闭时的可执行替代(§4.1 Phase 5) |
|
||||||
|
| 是否档位型 | 除 `none`/`auto` 外仍有 ≥1 档 | 决定告警文案(纯开关型不该说「可选档位」) |
|
||||||
|
|
||||||
|
OpenRouter 与 LiteLLM 两家**独立收敛到了同一形状**(`supported_efforts`+`default_effort` / `reasoning_effort_levels`+`default_reasoning_effort`),这是「档位清单即能力」这一形状可靠的旁证。我们只取其前半——两家都是**面向展示**的目录(要在 UI 上显示默认档),本库是**执行**路径,默认档不参与任何判定,故不设该字段。
|
||||||
|
|
||||||
|
### 3.3 `ProviderProfile`: 形态(按 provider)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingWire:
|
||||||
|
off: Mapping[str, Any] | None # 关闭档的片段;None = 该 provider 无关闭形态
|
||||||
|
on_base: Mapping[str, Any] | None # 开启档的固定部分;None = 形态未知
|
||||||
|
effort_key: str | None # 档位写进哪个键;None = 该 provider 无档位概念
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProviderProfile.thinking_on/thinking_off` 由 `thinking: ThinkingWire` 取代。`None` 表示「未知」这一语义原样保留(issue #5 的核心成果,不可退回)。
|
||||||
|
|
||||||
|
四个形态样例(经 new-api 中转的口径):
|
||||||
|
|
||||||
|
| provider | off | on_base | effort_key |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| zhipu | `{"thinking":{"type":"disabled"}}` | `{"thinking":{"type":"enabled"}}` | `reasoning_effort` |
|
||||||
|
| qwen | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None`(无档位,只有 toggle) |
|
||||||
|
| openai / anthropic / google | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
|
||||||
|
| minimax | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
|
||||||
|
|
||||||
|
### 3.4 为什么不需要 cherry 的 endpoint contract 与 wireDialect
|
||||||
|
|
||||||
|
cherry 有两层我们**明确不做**:
|
||||||
|
|
||||||
|
1. **endpoint-keyed 的 per-model wire 覆盖**。它需要这层,是因为同一模型在 `openai-chat` / `openai-responses` / `anthropic-messages` / `google-generate-content` 四种协议下形态不同。**本库只有一个 chat transport(`openai_compat.py`)**,所有请求都是 OpenAI 兼容形态,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent,见 `relaykit/relayconvert/reasoning/intent.go`)。一个协议 = 一层形态。
|
||||||
|
2. **`wireDialect` 代际方言**(Claude 4.6+ `adaptive` vs ≤4.5 `budget_tokens`;Gemini 3 `thinkingLevel` vs 2.x `thinkingBudget`)。这是**原生协议**才有的问题;我们发 OpenAI 形态的 `reasoning_effort`,代际差异由网关吸收。
|
||||||
|
|
||||||
|
同理,`glm-5.2`(有 `none` 档)与 `glm-5.3`(无 `none` 档)**共用同一份 wire**——差别落在 capability 的 `supported_efforts` 上。本库既有的「形态按 provider、能力按 model」分层,恰好容纳档位而无需新增一层。
|
||||||
|
|
||||||
|
## 4. 解析
|
||||||
|
|
||||||
|
### 4.1 `resolve_thinking`: 五道关卡
|
||||||
|
|
||||||
|
判定顺序即语义。前三关是既有的,判据从 bool 换成档位;**Phase 4「可执行替代」是新增的**,Phase 5 是既有第 4 关的档位化推广。
|
||||||
|
|
||||||
|
| Phase | 条件 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 生效档位为 `None`(调用方不表态) | 返回 `{}`,不注入 |
|
||||||
|
| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`;`none` 方向须 `off` 与 `on_base` **皆为 `None`** 才算「整体形态未知」——单 `off is None` 是「该 provider 关不掉」,归 `_inject` 说清缺的是哪半边,2026-09-05 实现时补正) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
|
||||||
|
| 3 | 能力未登记 | warning 后按 wire 尽力注入,**不校验档位** |
|
||||||
|
| 4 | 请求 `none` 而该模型无 `none` 档 | `ThinkingUnsupportedError`,**给出 `cheapest_effort` 作为替代** |
|
||||||
|
| 5 | 其余档位不在 `supported_efforts` 且未开映射 | `ThinkingUnsupportedError`,列出该模型可选档 |
|
||||||
|
|
||||||
|
**4 必须先于 5**(自审补): `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就退化成「不支持 none,可选 low/high/max」——丢掉了「这个模型根本关不掉」这个关键信息与可执行替代。
|
||||||
|
|
||||||
|
Phase 4 的文案是本设计的一个交付物,而非装饰:
|
||||||
|
|
||||||
|
> 模型 'glm-5.3' 无法关闭推理(官方 `thinking.type` 只接受 enabled);最省的档是 'low',请配 `LLM__ZHIPU__1__REASONING_EFFORT=low` 或调用时传 `reasoning_effort=Effort.LOW`。evidence: ...
|
||||||
|
|
||||||
|
理由: 该分支若只报错不给出路,下游会去找 `extra_body` 那条绕过的路——**那正是 issue #20 的成因**。报错必须带可执行替代,否则等于把用户推回起点。
|
||||||
|
|
||||||
|
映射(Phase 5 的逃生口)默认关闭,由 `SourceConfig.effort_fallback="nearest"` 显式开启,按 `supported_efforts` 的顺序取最近档并 warning。
|
||||||
|
|
||||||
|
> **Codex 审查异议与人类复核**: Codex 指出本项当前无可复验的消费者,引入它要带来配置项、映射算法、warning 口径与测试面。人类 2026-09-04 复核后**确认实现**——理由是这条逃生口的价值不取决于今天有没有人用它:换模型是常态,而「换完就跑不起来」与「换完静默涨价」之间需要一个下游可以显式选择的中间档。故本项**随本期一并实现**,含映射方向、warning 口径与测试。默认关闭的理由是钱: 一次静默的 `medium→max` 在 GLM-5.3 上是数倍账单,「严禁默认值掩盖错误」(P5)在此有真金白银的含义。
|
||||||
|
|
||||||
|
### 4.2 生效档位的优先级
|
||||||
|
|
||||||
|
```
|
||||||
|
request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语法糖) > None
|
||||||
|
```
|
||||||
|
|
||||||
|
`enable_thinking` **保留不删**(它已被三项目消费,迁移兼容约束见 ARCH §5.1),降级为语法糖:
|
||||||
|
|
||||||
|
| 旧写法 | 等价于 |
|
||||||
|
| --- | --- |
|
||||||
|
| `enable_thinking=False` | `reasoning_effort=Effort.NONE` |
|
||||||
|
| `enable_thinking=True` | `reasoning_effort=Effort.AUTO`(注入 `on_base`,不附档位),不依赖能力表 |
|
||||||
|
|
||||||
|
**`True` 的等价性分两种**(2026-09-04 实现时发现,更正初稿「与旧行为逐字节等价」的说法):
|
||||||
|
|
||||||
|
| provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `on_base` 完整表达「开」(qwen/deepseek/zhipu/moonshot) | `{"enable_thinking": True}` 等 | 同左 | **逐字节等价** |
|
||||||
|
| 靠档位表达「开」(openai/anthropic/google) | `{"reasoning_effort": "medium"}` | `{}`(不注入) | **行为变更** |
|
||||||
|
| 同上但**默认不推理**(minimax) | `{"reasoning_effort": "medium"}` | 同左(2026-09-05 回退) | **逐字节等价** |
|
||||||
|
|
||||||
|
第一行是**有意的**: 旧版那个 `medium` 是库替下游做的档位判断(profile 注释自己承认「取 medium 是因为它是五档里语义最接近厂商正常强度的一档」),而 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本设计要消灭的东西。语义仍是「开」(这三家的模型经 OpenRouter 登记默认即推理),只是不再强制一个档;要指定强度请显式配 `REASONING_EFFORT`。须进 CHANGELOG 的行为变更条目。
|
||||||
|
|
||||||
|
**第三行是 2026-09-05 的回退(issue #21,人类拍板的最小修复)**: 上述「语义仍是开」依赖「模型默认就推理」这个前提,T10 真实网关实测证明 MiniMax-M3 不满足它——不发任何推理参数时 5/5 轮不推理。故 `minimax` 段的 `on_base` 改回 `{"reasoning_effort": "medium"}`,存量 `ENABLE_THINKING=true` 的行为逐字恢复。这是权宜之计: 正解是让 `auto` 受能力表约束(模型不支持「由模型自定」时报错并指路显式档位),属公共行为变更,下一版处理。
|
||||||
|
|
||||||
|
**同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾**(如 `True` + `none`)→ **构造期 `ValueError`**。不做「后者赢」的静默兜底: 两个字段表达同一件事时,矛盾是配置错误,不是优先级问题。
|
||||||
|
|
||||||
|
### 4.3 `reconcile_thinking`: 对账扩展
|
||||||
|
|
||||||
|
现有对账只判「要求关闭却观测到推理」与「要求开启却未推理」。档位化后新增一类可判定的矛盾:
|
||||||
|
|
||||||
|
- 请求 `none`、模型登记 `can_disable=True`、却观测到 `OBSERVED` → 既有文案,**保留**(这正是 issue #20 第 3 条要恢复的机制)。
|
||||||
|
- 请求非 `none` 档、观测到 `ABSENT` → 既有文案,保留。
|
||||||
|
- **不做**「档位高低与 `reasoning_tokens` 多少的对账」: 档位与 token 数没有可判定的函数关系(issue #20 自己的数据里 glm-5.3-flash 的 medium 档 rt 在 8~56 之间跳),拿它报警必然是噪声。这条留给 §11 的压测,不进库。
|
||||||
|
|
||||||
|
### 4.4 归一化不变式(实现期补,2026-09-05)
|
||||||
|
|
||||||
|
库内一切档位判定都是 `is Effort.X` 的身份比较,故**每条能让档位进入库内的入口都必须先归一**
|
||||||
|
(`types.coerce_effort`)。裸字符串不归一的后果不是报错而是**静默判否**——`("none" is Effort.NONE)`
|
||||||
|
恒假,于是「已关闭」被当成「没表态」。
|
||||||
|
|
||||||
|
已知三条入口,缺一即漏:
|
||||||
|
|
||||||
|
| # | 入口 | 归一点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `.env` / `from_env()` / `from_settings()` | `config._cast` 委托 `coerce_effort` |
|
||||||
|
| 2 | 构造函数全量注入 `SourceConfig(...)` 与 `chat(reasoning_effort=...)` | `SourceConfig.__post_init__` / `chat()` 入口 |
|
||||||
|
| 3 | **缓存命中回放** `LLMResponse.applied_effort` | `CacheMW._coerce_applied_effort` |
|
||||||
|
| 4 | **公共函数 `resolve_thinking()` 直调** | 函数入口自行 `coerce_effort`(2026-09-05 独立验证查出) |
|
||||||
|
|
||||||
|
第 3 条是 T8 加 `applied_effort` 字段时才浮现的: 响应进 Redis 走 JSON,`StrEnum` 存成裸串,
|
||||||
|
命中回放时类型已丢。与 `thinking_observation` 当年的坑**逐字相同**(见 issue #16/#17),故按同一
|
||||||
|
先例处置: 域外取值降级为 `None` 且**不作废整条缓存**——多项目共用 Redis 时互相打缓存是老问题,
|
||||||
|
为一个可观测字段丢掉整条响应不划算。
|
||||||
|
|
||||||
|
第 4 条是本次换代**自己造出来的**: 该函数在 `__all__` 里,第三参数由 `bool` 换成 `Effort` 后,下游最自然的写法就是从 JSON/配置读出来的裸串 `"low"`。不归一则 `_inject` 撞 `.value` 抛 `AttributeError`——一个未文档化、不属四分类的异常。
|
||||||
|
|
||||||
|
新增第五条入口时(新工厂、新 transport 参数、新的反序列化路径)必须同样过 `coerce_effort`。
|
||||||
|
|
||||||
|
## 5. 缓存 key
|
||||||
|
|
||||||
|
**更正一个误判(Codex 审查指出)**: 源级 `extra_body` 与 `enable_thinking` **早已进 key**——经 `build_model_fingerprint` 的 `_fingerprint_mark`(`client.py`),由 issue #4/#5 落地,ARCH §7.5 有明文。本设计**不存在**先前稿本断言的「现存毒化缺口」,那是把 `CacheMW` 只读 `request.sampling` 误当成了全部 key 来源。
|
||||||
|
|
||||||
|
真正需要处置的是两处,均因请求级档位而新增:
|
||||||
|
|
||||||
|
| 层 | 处置 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 源级 `reasoning_effort` | 并入 `_fingerprint_mark`,与 `enable_thinking` 同规则(**仅表态时**追加) | 与既有一致;全源不表态时指纹字面量不变,存量缓存不冷启动 |
|
||||||
|
| 请求级 `reasoning_effort` | 进 `build_cache_key`,仅非 `None` 时参与 | `model_fingerprint` 是**装配期**算的集合级指纹,覆盖不到逐调用变化的值。不进 key 则同 messages 跑 low 与 max 会互相命中——issue #4「5 个 seed 全命中同一响应」的逐字翻版 |
|
||||||
|
|
||||||
|
**已知取舍原样延续**: ARCH §7.5 已记载 `model_fingerprint` 是**集合级**而非本次选中源的指纹,同 scope 各源配置不同时仍可能返回另一源的响应;要求逐源可复现应让每源独享 scope 或 namespace。加入 `reasoning_effort` 后该取舍不变,本设计不扩大战线去改它。
|
||||||
|
|
||||||
|
**冷启动代价**: 只有新配 `REASONING_EFFORT` 的源冷启动一次;存量只配 `ENABLE_THINKING` 的源字面量逐字不变。
|
||||||
|
|
||||||
|
**key 用请求档,不用 `nearest` 映射后的生效档**(T6 实现时定,理由在此补正): 决定性的原因是
|
||||||
|
`CacheMW` 位于洋葱中比 transport 更外的一层,查缓存时 `resolve_thinking` 尚未执行,生效档
|
||||||
|
**根本拿不到**。副作用是被映射到同一档的两个请求(`minimal` 与 `low` 都映射到 `low`)各占一个
|
||||||
|
缓存槽,存两份相同响应——浪费但不毒化,可接受。
|
||||||
|
|
||||||
|
**由此带来一条已知边界**(与 ARCH §7.5 既有两条并列,不在本设计处理): 能力表更新导致映射结果
|
||||||
|
变化时(如某模型新增 `minimal` 档),请求档 `minimal` 算出的 key 不变而实际发出的字节变了,
|
||||||
|
会命中按旧映射存下的响应。能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表
|
||||||
|
与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace` 或 `cache_salt`。
|
||||||
|
|
||||||
|
## 6. 遥测
|
||||||
|
|
||||||
|
`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27;两套口径的区分见 `telemetry/schema.py` 模块 docstring)。
|
||||||
|
|
||||||
|
记的是**本次调用生效的档位**,不是配置值——`None`(不表态)与 `'low'` 必须能区分,故可空。
|
||||||
|
|
||||||
|
不加此列则你要做的压测「不同档位是不是真有用」在数据侧无法分组: 现在 25 列里没有任何一列能回答「这一行用的是哪档」。补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE` 机制,两端 DDL 与 `COLUMNS` 同源(schema.py 是单一事实源)。
|
||||||
|
|
||||||
|
## 7. 备选方案对比
|
||||||
|
|
||||||
|
| | 方案 | 改动面 | 权衡 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **A** | **最小补丁**: 只补 `zhipu` profile,`thinking_on` 填一个档,维持 bool | `providers.py` 一条 + `thinking.py` 两条 | issue #20 字面满足。但 §1 三条结论全部无解: GLM-5.3 填什么档都是错(`medium` 是空档、`none` 是未定义值);`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」之间二选一。**治标** |
|
||||||
|
| **B** | **能力表档位化 + 源级/请求级双入口**(本设计) | `types.py` 加 `Effort`、两个公共类型重构、`resolve_thinking` 加两关、缓存 key、遥测加列、`.env` 加键 | 表达力对齐现实;下游不必再走 `extra_body`;压测可按档位分组。代价是公共类型破坏性变更 + 一次缓存冷启动 |
|
||||||
|
| **C** | **照抄 cherry 的完整 wire DSL**: closed operation 集合、`effortMap`、`budgetWire`、endpoint-keyed contract | B 的全部 + 一套 wire 解释器 + per-model wire 覆盖表 | 能表达 budget 型(qwen `thinking_budget`)与原生协议代际差异。但本库只有一个 OpenAI 兼容 transport(§3.4),这层复杂度当前无消费者——**违 P1 YAGNI** |
|
||||||
|
|
||||||
|
**推荐 B**。A 治不了 issue #20 描述的病;C 的两项额外能力(多协议 wire、token 预算)在本库当前没有消费者,等真出现 budget 型需求时,`ThinkingWire` 增一个 `budget_key` 字段即可增量抵达,不必现在就上解释器。
|
||||||
|
|
||||||
|
## 8. 初始能力表(全部标注「文档推定,待实测」)
|
||||||
|
|
||||||
|
来源: 官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉。**这是待验证的假设,不是结论**——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下是三档、在 `perplexity/` 下是六档,中转会改档位有第三方证据。人类已定:能力表数据以后统一经 new-api 实测。
|
||||||
|
|
||||||
|
**落库规则**(Codex 审查补): 本表是**调研素材**,不是可直接转代码的表。只有 `supported_efforts` 能写成合法 `Effort` 元组的条目才进 `DEFAULT_CAPABILITIES`。分三档处置:
|
||||||
|
|
||||||
|
| 情形 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 档位清单与「能否关闭」皆无冲突 | 直接登记 |
|
||||||
|
| **档位清单三源一致,仅「能否关闭」存疑**(如 kimi-k3: 官方档位无 `none`,OpenRouter 却标 `mandatory:false`) | 按**保守方向**登记(不含 `none`),evidence 注明存疑点。理由: 不登记会退回 Phase 3 的「尽力注入」,下游配 `none` 时静默失效——**那正是 issue #20 的病**;保守登记则报错并给出最低档,明确且有出路 |
|
||||||
|
| 档位清单本身无该型号直接证据(`未查到`,或仅由**同系**推定如 `推定同上`) | 不登记,走 Phase 3 |
|
||||||
|
|
||||||
|
第二档与第三档的分界是**有没有该型号自己的档位证据**,不是「关不关得掉存不存疑」: `kimi-k3` 进第二档,因为月之暗面官方文档直接写明它的三档是 `low/high/max`,只有「能否关」两源分歧;而 `gemini-3-flash`、`claude-haiku-5` 的档位清单是从同系型号(3.1-pro / opus-5)推来的,**没有该型号自己的文档**,故进第三档。Phase 3 并非静默——它会 warning 指路「实测后用 `register_capability` 登记」,且未登记模型的运行期对账文案也专门写了这一句;登记一个纯推定值反而会让下游以为库确认过。T10 实测时这三个型号优先补。`default` 列只是调研记录,按 §3.2 并入 `evidence` 文本,不进字段。
|
||||||
|
|
||||||
|
| 模型 | supported_efforts(推定) | 厂商默认(入 evidence) | 关? |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| glm-5.3, glm-5.3-flash | low, high, max | max | ✗ |
|
||||||
|
| glm-5.2 | none, high, max | max | ✓ |
|
||||||
|
| kimi-k3 | low, high, max | max | ?(OR 标可关,但官方档位无 `none`——**待实测**) |
|
||||||
|
| kimi-for-coding | 未查到 | — | ? |
|
||||||
|
| deepseek-v4-pro / -flash / -flash-vision-exp | none, high, max | high | ✓ |
|
||||||
|
| gpt-5.4, gpt-5.5 | none, low, medium, high, xhigh | medium | ✓ |
|
||||||
|
| claude-opus-5, claude-sonnet-5 | low, medium, high, xhigh, max(+`none` 经网关转 `thinking` 关闭) | high | ✓ |
|
||||||
|
| claude-haiku-5 | 推定同上 | — | ? |
|
||||||
|
| gemini-3.1-pro | low, medium, high | 官说 high / OR 说 medium(**打架**) | ✗ |
|
||||||
|
| gemini-3-flash | low, medium, high | — | ? |
|
||||||
|
| MiniMax-M3 | none, auto | auto | ✓ |
|
||||||
|
| MiniMax-M2.5, M2.7 | auto(**仅此一档**) | auto | ✗ |
|
||||||
|
| glm-5, glm-5.1, glm-4.6v | none, auto | auto | ✓ |
|
||||||
|
| qwen-plus-latest, qwen3.5-flash, qwen3.6-plus, qwen3.7-max, qwen3.7-plus | none, auto | auto | ✓ |
|
||||||
|
|
||||||
|
三个 embedding 模型(text-embedding-v2/v4、qwen3-vl-embedding)无推理语义,不入表。
|
||||||
|
|
||||||
|
## 9. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 回答 |
|
||||||
|
| --- | --- |
|
||||||
|
| **并发** | 两张表仍是 `MappingProxyType` + 纯函数查找,无共享可变状态。transport 的 `_warned_models`/`_warned_mismatches` 是实例级 `set`,读写之间无 `await`,单事件循环内原子。节流键加入生效档位后基数上升(源×模型×档位),仍为有界小集合 |
|
||||||
|
| **取消** | 档位解析全部是同步纯函数,不含 `await`,不改变 `CancelledError` 的穿透路径。既有保证不受影响 |
|
||||||
|
| **降级方向** | 推理档位属**请求正确性**而非资源闸,故一律**报错不放行**(Phase 2/4/5(下同)),与「限流/熔断后端不可用须报错」同向。能力**未登记**是唯一例外——warning 后尽力注入,理由是新模型上线不该被库挡住(既有决策,保留) |
|
||||||
|
| **幂等** | 纯函数,无副作用,同输入恒同输出。重复调用安全 |
|
||||||
|
| **持久化** | 两处一次性影响: ① 缓存 key 变化 → 已配推理参数的 namespace 冷启动一次;② 遥测补列 → 走既有 `PGW_TELEMETRY_SCHEMA_MODE`,补列语句与 DDL 同源。均无部分写入风险(补列是 DDL 原子操作,缓存 miss 不损坏数据) |
|
||||||
|
|
||||||
|
## 10. 错误处理与测试策略
|
||||||
|
|
||||||
|
**错误分类**: 全部落 `RequestRejectedError`(不重试、不换源、不计熔断)。理由: 档位不支持是确定性的配置/参数问题,重试与换源都不会让它变对。路径与既有一致——`thinking.py` 抛 `ThinkingUnsupportedError(ValueError)`,transport 在请求期翻译。
|
||||||
|
|
||||||
|
装配期 vs 运行期: 源级配置(`SourceConfig.reasoning_effort`)在**构造期**校验并报错;请求级(`ChatRequest.reasoning_effort`)只能在**运行期**校验,落 `RequestRejectedError` 上抛。
|
||||||
|
|
||||||
|
**测试策略**(先失败后通过,每条对应一个行为):
|
||||||
|
|
||||||
|
| 层 | 用例 |
|
||||||
|
| --- | --- |
|
||||||
|
| unit | 五道关卡各自的触发与不触发;`enable_thinking` 语法糖的三种等价;矛盾配置构造期报错;`nearest` 映射的取档方向;派生量(`can_disable`/`cheapest_effort`)与 `supported_efforts` 一致 |
|
||||||
|
| unit | Phase 4 文案**含** `cheapest_effort` 与 env 键名(这是交付物,要断言内容而非只断言抛错) |
|
||||||
|
| unit | 缓存 key: 同 messages 不同档位 → key 不同;不表态时 key 与存量形状一致(回归) |
|
||||||
|
| integration | 遥测 `reasoning_effort` 列在两端(sqlite/pg)落值正确,不表态时为 NULL |
|
||||||
|
| e2e(`slow`) | 经 new-api 对 §8 表逐模型实测,校正 `supported_efforts`;标 `slow`(成败取决于外部服务当下状态) |
|
||||||
|
|
||||||
|
## 11. 明确不做
|
||||||
|
|
||||||
|
1. **档位与 `reasoning_tokens` 的运行期对账**(§4.3): 无可判定的函数关系,拿它报警是噪声。
|
||||||
|
2. **token 预算型控制**(`thinking_budget`/`budget_tokens`): qwen 系支持,但当前无下游需求;`ThinkingWire` 可增量加 `budget_key` 抵达。
|
||||||
|
3. **原生协议 wire 与代际方言**(§3.4): 本库只有一个 OpenAI 兼容 transport。
|
||||||
|
4. **档位对采样参数的联动**: DeepSeek 思考模式不支持 `temperature`/`top_p`,Moonshot kimi-k2.5+ 固定采样参数,传别的值 400。**本设计不代下游做参数裁剪**——这是模型的约束,应由 evidence 记录并让 400 如实抛出,库替下游删参数是「默认值掩盖错误」。记入能力表 evidence,不写进代码逻辑。
|
||||||
|
5. **压测本身**: 「不同档位是不是真有用」是 `harness-eval` 范畴,依赖本设计的遥测列,不属于本设计。
|
||||||
|
|
||||||
|
## 12. 迁移与兼容
|
||||||
|
|
||||||
|
**破坏性变更五处**(初稿只列了第 1 条,其余四条为 2026-09-05 独立验证实测补全——照初稿写 CHANGELOG 会让下游撞上没有预告的 `TypeError`):
|
||||||
|
|
||||||
|
| # | 位置 | 变更 | 谁会断 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `ThinkingCapability` | 构造签名 `can_disable` → `supported_efforts` | 自建能力表的调用方 |
|
||||||
|
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
|
||||||
|
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort` | 任何自建 recorder 实现 |
|
||||||
|
| 4 | `thinking.resolve_thinking()` | 第三参数换语义(`bool` → `Effort`),**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 读侧代码一律断 |
|
||||||
|
| 5 | `providers.ProviderProfile` | `thinking_on`/`thinking_off` → `thinking: ThinkingWire` | 自建 profile 的调用方 |
|
||||||
|
|
||||||
|
五者都在包根导出面或端口面上。
|
||||||
|
|
||||||
|
**先更正**(Codex 审查指出): 初稿称「已核实 `reference/` 三项目无调用点,实际影响面为零」——**该结论不成立**。`reference/` 下当前**没有** GovDoc-SaaS / Video-Tree-TRM5 / CHSAnalyzer 三个目录(工作区实际只有本次调研克隆的四个开源项目),此前的 `grep` 因目录不存在而输出空,被误读成「无匹配」。
|
||||||
|
|
||||||
|
真实的库内调用点(可复验):
|
||||||
|
|
||||||
|
| 位置 | 用法 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `thinking.py:169` | 读 `capability.can_disable` | 改读派生属性,行为不变 |
|
||||||
|
| `tests/unit/test_thinking.py:128` | `ThinkingCapability(True, "实测")` **位置参数构造** | 随实现同步改——这是不可兼容的部分 |
|
||||||
|
| `tests/e2e/test_thinking_live.py:455` | 读 `can_disable` | 派生属性覆盖 |
|
||||||
|
| `__init__.py` | 包根导出 `ThinkingCapability`/`register_capability` | 符号名不变,构造形态变 |
|
||||||
|
|
||||||
|
**兼容策略**: 保留 `can_disable` 为只读派生属性(`Effort.NONE in supported_efforts`),**读侧代码一律不改**;位置参数构造无法兼容,库内三处随实现同步修改。
|
||||||
|
|
||||||
|
**下游影响面: 推断而非核实**。三项目尚未迁移接入本库(M4 才做),`ThinkingCapability` 是 2026-08-02 才加入的库内表,下游调用它的可能性低——但工作区读不到三项目源码,这条只能是推断。**须人类在审批时确认**,或在实现计划里加一步「三项目可读时复验调用点」。版本号取 **1.3.3**(2026-09-05 人类指令,不走 minor)。
|
||||||
|
|
||||||
|
**非破坏**: `SourceConfig.enable_thinking` 保留,行为等价(§4.2);`.env` 的 `ENABLE_THINKING` 键保留;新增键 `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT`。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况**本来就是静默失效**,现在会明确报错并给出替代档。
|
||||||
|
|
||||||
|
## 13. 验收标准
|
||||||
|
|
||||||
|
1. 五道关卡各有先失败后通过的测试证据;Phase 5 文案内容被断言。
|
||||||
|
2. 同 messages 不同档位不再互相命中缓存。
|
||||||
|
3. 遥测能按档位分组(压测的前置条件)。
|
||||||
|
4. `.env` 只配 `ENABLE_THINKING` 的存量下游行为不变(回归测试)。
|
||||||
|
5. 进 `DEFAULT_CAPABILITIES` 的条目**仅限** §8 中无 `?`/无冲突者,每条 `evidence` 标注「文档推定,待实测」并附出处;其余条目留在设计文档里等实测,不登记。
|
||||||
|
6. import-linter 契约不破(`Effort` 落 `types.py`,不产生反向依赖)。
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-09-09-134-thinking-contracts-design
|
||||||
|
title: "1.3.4 推理意图与测试证据设计"
|
||||||
|
date: 2026-09-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.4:推理意图的可满足性与测试证据契约
|
||||||
|
|
||||||
|
> 日期:2026-09-09。状态:**已通过独立审查并获人类正式批准;进入实施计划阶段,编码须先完成计划审查**。
|
||||||
|
> 用户已批准合并处理 #25/#26/#21,及 D1(未知 AUTO 尽力+告警)、D2(受管推理与 raw 冲突拒绝)、D3(下游显式迁移 namespace/salt)。下文细则供正式设计审查,不重新悬置已决方向。
|
||||||
|
> 本轮只修订本文件;未改生产代码/测试、未提交、未启动子代理、未执行真实付费调用。既有审查历史与剩余证据门见 §11。
|
||||||
|
|
||||||
|
## 1. 目标、非目标与事实源
|
||||||
|
|
||||||
|
本批修复共同问题:库声明的推理意图、实际发送参数和验证证据应一致;无法确认的事实不得变成成功声明。
|
||||||
|
|
||||||
|
| 范围 | 交付 | 明确不做 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| #21 | 已登记 AUTO 成员检查;移除默认 provider 偷选 medium;冲突守卫;能力证据审计与迁移说明 | 新推理 DSL、通用 overlay 系统、预算型推理、模型名猜测、无证据批量加 AUTO |
|
||||||
|
| #25 | 测试侧窄归因函数、逐轮留证、未覆盖汇总;纯本地断言离线化 | 改四分类、整类异常 skip、重跑到绿、扩大生产遥测、自动豁免发布覆盖 |
|
||||||
|
| #26 | 真实 client→治理→emitter→recorder 守护无推理路径,附变异证据 | 新端口、替换 reasoning_applies、遥测 schema 改造 |
|
||||||
|
| 后续批次 | #19/#23 归 1.3.5,#22 归 1.3.6,#24 独立设计 | 调用上下文 API、deadline、429 算法、对冲或取消结算重构 |
|
||||||
|
|
||||||
|
规范依据:CLAUDE.md、brainstorming/structured-logging skill、ARCHITECTURE D11、§4.5/5.1/6/7.5/7.8、docs-convention。
|
||||||
|
旧设计:`2026-09-04-reasoning-effort-design.md` §3–6/8/12;旧实验:`findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`、`2026-08-25-thinking-observability-regression.md`。
|
||||||
|
Issue 原文:`/tmp/polygateway-issue-triage/open-issues.json`。旧文档的“26 模型”“仅 reasoning_tokens 可靠”“下游尚未迁移”不是本轮验证结论。
|
||||||
|
|
||||||
|
## 2. 当前代码审计
|
||||||
|
|
||||||
|
| 证据 | 事实与根因 |
|
||||||
|
| --- | --- |
|
||||||
|
| `thinking.py::_settle_tier` | `effort is AUTO or effort in supported_efforts` 无条件放行 AUTO;形态与模型能力不能共同约束它 |
|
||||||
|
| `providers.py::ThinkingWire` | 空 on_base 当前文案混同协议形态与“任意模型默认推理”;需分开 |
|
||||||
|
| `DEFAULT_PROFILES[minimax]` | on_base 当前为 reasoning_effort=medium,M3 不是仍必然不推理;问题是 applied_effort=auto 却发 medium |
|
||||||
|
| `openai_compat.py::_build_payload` | resolution 后依次浅层 update extra_body/overlay;raw 可改写或新增推理控制,成功档位与最终字节失配 |
|
||||||
|
| `client.py::_guard_thinking`/`middleware/cache.py` | 工厂仅解析源级意图;缓存命中早于 transport,请求级、全量注入及同版本策略变化都可能回放旧语义 |
|
||||||
|
| `middleware/telemetry.py` | 成功尝试、失败尝试、缓存命中、终态失败的档位来源不同,不能统称“成功记实际档” |
|
||||||
|
| `embedding.py::_emit`/`ocr.py::_emit` | 当前正确传 False;只测 emitter 不覆盖调用点,成功响应默认 None 还会掩盖变异 |
|
||||||
|
| `test_thinking_live.py`/`test_embed_probe.py` | 前者按异常类别 skip、用正文子串识别 model_not_found;后者捕全部 PolyGatewayError 当“不支持”,都可能遮蔽库回归 |
|
||||||
|
|
||||||
|
本轮核对 `origin/main..HEAD` 为既有 `758a127`/`6a09054`,保留历史提交、不重写;其外因判据按 §6 窄修正。
|
||||||
|
当前 `reference/` 只有 cherry-studio、litellm、new-api、vercel-ai-sdk;GovDoc-SaaS、Video-Tree-TRM5、CHSAnalyzer 工作区均缺失,**未核验三项目现行配置**。迁移示例不是下游迁移完成证据。
|
||||||
|
|
||||||
|
## 3. 备选方案与已批方向
|
||||||
|
|
||||||
|
| 方案 | 收益 | 代价/结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A:supported_efforts 包含 AUTO 的语义能力 | 不新增模型字段,统一可满足性判据 | 部分旧 True 配置报错,须审计清单并迁移;**用户已选 A** |
|
||||||
|
| B:新增模型默认推理三态 | 默认开启与未知可分别表达 | 多维护一套事实,易与能力清单漂移;不选 |
|
||||||
|
| C:provider/模型把 AUTO 映射固定档 | 保持旧配置字节 | 库代选付费档位,模型事实塞进 provider;用户不选 |
|
||||||
|
|
||||||
|
| 决策 | 已批准 | 不采纳的备选及理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 | 未登记 AUTO 保留尽力注入+warning,不保证开启;已登记一律成员检查 | 不改成未知即拒绝;也不把未知当能力已验证 |
|
||||||
|
| D2 | 有受管意图时拒绝 raw 推理冲突;无受管意图保留 raw | 不保留双来源互相覆盖,不从最终 payload 反向猜档位 |
|
||||||
|
| D3 | 下游显式换 namespace/salt 隔离语义变化 | 不加自动 revision/包版本,不让所有 chat 擅自冷启动,不把能力解析搬进缓存 |
|
||||||
|
|
||||||
|
Issue #25 选“离线硬契约+有限可执行归因+显式未覆盖”;#26 选真实客户端与轻量 recorder 为日常测试、临时 SQLite 为持久化锚点,不要求远程 PG。
|
||||||
|
|
||||||
|
## 4. 推理语义与最终请求体契约
|
||||||
|
|
||||||
|
### 4.1 词汇与决策矩阵
|
||||||
|
|
||||||
|
`supported_efforts` 表示模型在**当前登记的 OpenAI 兼容 wire** 下、有依据可执行的语义集合,不是上游字符串枚举的逐字镜像。
|
||||||
|
`AUTO` = 要求开启、不指定强度;不是 Python None(不表态),不是库任选一档,也不是上游必须接收 `auto` 字面值。
|
||||||
|
`on_base=None` 是开启形态未知;`on_base={}` 是该协议不加开启字节,**能否据此满足已登记模型 AUTO 另查清单**。非空开关也不豁免成员检查。
|
||||||
|
请求档>源级 reasoning_effort>enable_thinking 糖(True→AUTO、False→NONE);None 不覆盖源级表态,源上两字段矛盾的既有构造校验保留。
|
||||||
|
|
||||||
|
| 生效输入/条件 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| effort=None | 不注入,applied_effort=None;raw 仍按旧优先级发送,不推断其档位 |
|
||||||
|
| 所需方向形态未知 | ThinkingUnsupportedError,指路注册 profile;不因模型未登记而假造 wire |
|
||||||
|
| 已登记 AUTO 在清单 | 仅发 on_base,不附强度,applied_effort=AUTO |
|
||||||
|
| 已登记 AUTO 不在清单 | 明确拒绝并列可用档;fallback=nearest 同样拒绝,不能建议 nearest 修复 |
|
||||||
|
| 已登记显式强度不支持+nearest | 保留最近开启档、等距弱侧;只有纯开关候选时可强度→已登记 AUTO,不能 AUTO→强度 |
|
||||||
|
| NONE 不支持/没有 off 形态 | 保留专用不可关闭错误;替代配置由用户选择,不自动执行 |
|
||||||
|
| 未登记 AUTO/强度/NONE | 按已知 wire 尽力注入+warning;wire 表达不了仍拒绝,不编造支持清单,不保证开启/关闭/强度有效 |
|
||||||
|
|
||||||
|
未知 AUTO 的空 on_base 可能发送零推理字节,返回 AUTO 仅表示尽力编码的选择,**不构成能力验证 PASS**。既有 warning 需明确能力未知、可能不生效,保留实例节流。
|
||||||
|
NONE 方向仍按 `_wire_unknown_for` 既有分工:off、on_base 皆 None 才是整体未知;off None 而 on_base 已知是缺关闭形态。
|
||||||
|
|
||||||
|
### 4.2 MiniMax 与能力证据
|
||||||
|
|
||||||
|
移除默认 minimax.on_base 的 medium,改为空映射;M3 不加 AUTO。显式 medium 仅作为恢复旧字节的迁移示例,不是库推荐的最优成本档。
|
||||||
|
按本轮源码清点,默认表 24 个型号、10 个含 AUTO;live 候选 26 型号不等于能力表 26 条。**本候选不授权新增任何 AUTO 条目**;追加须另附逐型号证据并复审,不因未知、不可达或同厂近代型号有能力而补登。
|
||||||
|
|
||||||
|
| 模型组 | 已有证据与本批处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| MiniMax-M3 | 2026-08-25 裸 HTTP 无参数不推理、medium 有推理;2026-09-05 evidence 同向。不加 AUTO,显式档保留 |
|
||||||
|
| MiniMax-M2.5/M2.7 | 已有 AUTO;历史默认/mandatory 旁证及 T10 开启观测。T10 未留最终 wire,移除 medium 后需补空 on_base 实测,不冒充已复验 |
|
||||||
|
| qwen3.7-plus/max、qwen3.6-plus、qwen3.5-flash、qwen-plus-latest、glm-4.6v | 保留已有 AUTO 与逐型号证据,不推及其他型号 |
|
||||||
|
| glm-5/5.1 | 保留既有文档推定 AUTO;T10 回报 glm-5.3,身份不足,不算本型号实测 |
|
||||||
|
| deepseek-v4-pro/flash/flash-vision-exp、glm-5.2/5.3/5.3-flash、kimi-k3/kimi-for-coding | 现无 AUTO;显式档成立不等于 AUTO wire 成立,本批仍拒绝其 AUTO |
|
||||||
|
| gpt-5.5 | 现无 AUTO;历史报告 `tier_probe_20260905_184307.md:97` 无参数基线 5/5 rt=18、身份一致,是候选线索,但最终 payload/raw 覆盖未留证,不直接追加 |
|
||||||
|
| gpt-5.4、claude-opus-5/sonnet-5、gemini-3.1-pro | 现无 AUTO;限额/上游错误导致未覆盖,不能以“默认 medium”或同代替代证据 |
|
||||||
|
| claude-haiku-5、gemini-3-flash | 未登记 live 候选,按 D1 尽力+告警;不自动登记 |
|
||||||
|
|
||||||
|
历史报告仅局部抽查;本轮未全表复验、未新跑付费实验。默认 wire 改变的 M2 两型是发布前显式缺测项,不能靠缓存回放旧 medium 的成功结果过门。
|
||||||
|
|
||||||
|
### 4.3 D2 冲突规则(窄边界,不做通用 overlay)
|
||||||
|
|
||||||
|
“受管意图”指按 §4.1 得到的 effort 非 None,**包括 NONE、AUTO、糖及未知模型**。单凭 raw 不构成受管意图。
|
||||||
|
当前浅层次序保持为 `基础 payload → resolution.payload → source.extra_body → request.overlay`;禁止改成深合并。守卫只校验所有权,不重写/删除 raw,不反推档位。
|
||||||
|
|
||||||
|
| 检查对象 | 精确规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已知 raw 推理控制 | 顶层 `reasoning_effort`、`enable_thinking`、`thinking`、`thinking_budget`、`reasoning`、`thinkingConfig` 为控制根;`output_config` 为对象且含 `effort` 也算控制。这是显式有限词表,不按任意键的子串/模型名猜测 |
|
||||||
|
| 当前 profile 的控制根 | 加入 on_base、off 的全部顶层键及非 None 的 effort_key;即使本次为 AUTO 且 on_base 为空,也保护 effort_key/off 根。保护范围取开关两向并集,不只取本次实际注入键 |
|
||||||
|
| 同值与遮蔽 | 有受管意图时,extra_body 或 overlay **任一层**出现控制根即拒绝,值相同也拒绝;被后一层遮蔽也不豁免,避免双来源随配置变化重新失配 |
|
||||||
|
| 嵌套/浅覆盖 | `thinking={}`、`thinking={"budget_tokens":100}` 也拒绝:替换整个根会删除受管 type。当前 wire 持有某根时,raw 仅改其看似无关子键仍拒绝。根未被 wire 持有时,`output_config={"format":"json"}` 不因兄弟键 effort 被保护而误拒 |
|
||||||
|
| 新增而非覆盖 | AUTO 的空片段遇 raw reasoning_effort=high 仍拒绝;qwen 受管开关遇 raw reasoning_effort/thinking_budget 也拒绝,不能只检查字典交集 |
|
||||||
|
| 无受管意图 | 即请求、源级档与糖都不表态,保留 extra_body→overlay 原有浅覆盖(含 raw 推理),applied_effort=None;原 model/messages/stream/stream_options 禁写规则照旧 |
|
||||||
|
|
||||||
|
自定义 wire 不新增路径 DSL:effort_key 仍是一个**顶层字面键**,不把点号解释成嵌套路径;on_base/off 可含嵌套对象,raw 守卫保护其整个顶层根。
|
||||||
|
自定义 on_base 不得包含自己的 effort_key,也不得借标准 reasoning_effort 偷带档位;无论其值是 medium、auto 或 None 都拒绝为配置错误,不能默默删键。标准嵌套 `output_config.effort` 同属禁带强度的已知路径;不解析任意私有嵌套方言。需要强度请走显式档,不能将其固化在开启片段。
|
||||||
|
其余自定义不透明方言的语义真实性由注册者提供证据;本批保证声明键不被 raw 冲掉,**不宣称可以识别所有未声明的私有别名/预算语义**。扩展别名应登记 wire 后受控,不新增通用参数解释器。
|
||||||
|
|
||||||
|
### 4.4 守卫时机、错误与保证范围
|
||||||
|
|
||||||
|
| 入口 | 时机/职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工厂 `_guard_thinking` | 已有 profile/capability/source 材料齐全;装配期(网络及准入前)校验 wire、源级可满足性与源 extra_body 冲突,抛 ThinkingUnsupportedError(配置 ValueError)。工厂拒绝的源不能靠未来请求覆盖“救活” |
|
||||||
|
| `chat` 前置参数校验 | 保留 validate_request_overlay;请求显式档+调用 overlay 的已知控制词表冲突可在进入洋葱/准入前报配置 ValueError。不新增全源能力预解析,不声称这里可见自定义 transport 的注册表 |
|
||||||
|
| 默认 transport `_build_payload` | 以本次选中源、实际 profile、请求覆盖后的意图,对两层 raw 再做完整守卫;全量注入与自定义注册表同样覆盖。ThinkingUnsupportedError 翻译 RequestRejectedError,HTTP 发送前拒绝,无重试/换源/故障熔断计数 |
|
||||||
|
| 自定义 Transport | 不通过默认 transport 的调用仍由端口实现方履约;本批不添加 preflight 端口,不反射读取私有注册表,不承诺能验证任意注入实现 |
|
||||||
|
|
||||||
|
transport 守卫**可能已经经过选源、限流预扣与准入**,拒绝后的结算沿既有 finally 路径;“零 HTTP”不等于“零准入操作”。不移动洋葱层次以制造所有请求均前置拒绝的过宽保证。
|
||||||
|
真实成功尝试将同一 resolution.applied_effort 传给响应与 emitter,不重算;AUTO 是未指定强度的选择,不是服务端内部强度。服务端是否接受/执行推理由 ThinkingObservation 回答,失败与缓存行另见 §8。
|
||||||
|
缓存命中不经过上述 transport 检查,所有“缓存不得绕过新拒绝”验收都以 §5 完成迁移为前置。
|
||||||
|
|
||||||
|
## 5. D3 缓存迁移与下游配置
|
||||||
|
|
||||||
|
### 5.1 显式迁移边界
|
||||||
|
|
||||||
|
缓存身份继续沿 ARCH §7.5;**不加入自动 revision、包版本、fallback、能力表或 wire 版本字段**,不删除旧键、修改共享 Redis 或重写历史遥测。
|
||||||
|
语义变更包括本次 AUTO 成员检查/raw 冲突规则、MiniMax wire 变化,以及同版本下 fallback=nearest→error、能力增删/映射变化、自定义 profile wire 变化;这些都由下游显式换 namespace/salt 隔离。现有源指纹包含部分配置不代表包含全部语义。
|
||||||
|
|
||||||
|
| 操作 | 下游必须做/边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| 迁移前 | 盘点工厂与全量注入、scope/租户、源级糖/档/raw、请求级覆盖、fallback、自定义 wire,以及实际 per-call namespace/salt 覆盖 |
|
||||||
|
| 切换 | 为受影响调用集合选择从未承载旧语义的 namespace 或 salt;在首次新语义读写前部署到该集合全部调用者。租户前缀与原 epoch salt 保留后再追加人工迁移标记,不共享租户身份 |
|
||||||
|
| 多源 | 一个缓存 scope 内只要有源受影响,必须隔离该共享身份的调用集合;要求更细范围由下游拆独立 scope/namespace,本批不替下游重分组 |
|
||||||
|
| 并行与回滚 | 新旧客户端不得共享迁移后身份;回滚到旧 namespace 会重见旧语义,不能说旧键已被清理。未来策略变更须再次显式迁移,不能复用一次标记包办所有变化 |
|
||||||
|
| 未完成迁移 | 旧缓存或同版本 nearest 写入可被 error 客户端命中,库不会自动验证其新可满足性。文档警示是操作前置,不是新增的自动安全机制 |
|
||||||
|
|
||||||
|
**不要求所有 chat 冷启动**;未受影响调用可保留身份。若受影响与不受影响调用原本共享一套身份,下游须明确选择整体换标记的成本或先拆分,库不代选。
|
||||||
|
|
||||||
|
### 5.2 旧→新配置与验证矩阵
|
||||||
|
|
||||||
|
下表是**配置迁移示例及拟新增离线节点**,不是已执行测试。显式档均为当前清单成员示例,不代表所有渠道已实测、不作成本代选。所有受影响且启用缓存的行还须执行 §5.1。
|
||||||
|
|
||||||
|
| ID/旧配置 | 受影响模型 | 新行为(源级工厂/请求级默认 transport) | 用户明确选择的新配置 | 离线验证锚点(拟) |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| M1 `ENABLE_THINKING=true` 或 `REASONING_EFFORT=auto` | MiniMax-M3 | 装配 ThinkingUnsupportedError/请求 RequestRejected,零 HTTP | 删除糖,`REASONING_EFFORT=medium` 可恢复旧 medium 字节;也可选择表内其他档 | `test_thinking.py`:M3 AUTO 拒绝/medium payload |
|
||||||
|
| M2 同上 | deepseek-v4-pro/flash/flash-vision-exp、glm-5.2 | 同上;开关 on_base 非空也拒绝 | 删除糖,显式 `REASONING_EFFORT=high` 或经用户选择 max | `test_thinking.py`:非空 wire AUTO 成员约束 |
|
||||||
|
| M3 同上 | glm-5.3/5.3-flash、kimi-k3/kimi-for-coding | 同上,nearest 不能解 AUTO | 删除糖,显式 `REASONING_EFFORT=low`(也可选 high/max) | `test_thinking.py`:AUTO+nearest 拒绝 |
|
||||||
|
| M4 同上 | gpt-5.4/5.5、claude-opus-5/sonnet-5、gemini-3.1-pro | 同上;不可达不补 AUTO | 删除糖,用户选表内 `REASONING_EFFORT=medium`;这不是新增 live 证明 | `test_thinking.py`:空 wire 非成员拒绝 |
|
||||||
|
| M5 同上 | MiniMax-M2.5/M2.7 | 仍 AUTO,on_base 从 medium 改空,真实语义待补测 | 保留 True/AUTO 并迁移缓存;需要旧 raw 字节者须完全退出受管意图,不可谎称该模型支持 medium | `test_thinking.py`:空 wire AUTO 字节;live 单列缺测 |
|
||||||
|
| M6 同上 | qwen 五型、glm-5/5.1/4.6v | AUTO 仍是成员,原开关形态保留 | 保留配置;glm-5/5.1 仍身份未覆盖 | `test_thinking.py`:已登记 AUTO 放行 |
|
||||||
|
| M7 同上 | 未登记模型(含两个 live 候选) | 已知形态尽力+warning,不保证开启 | 可保留 AUTO;要求确定保证者先取得能力证据再登记,不自动加表 | `test_thinking.py`:未知空/非空 wire 警告 |
|
||||||
|
| M8 源 HIGH+`EXTRA_BODY={"reasoning_effort":"high"}`;或请求 AUTO+raw HIGH | 所有受管模型,含未知 | 同值也拒绝;工厂或前置/transport 对应守卫报错 | 保留受管档并删除两层 raw 控制键;或清空源糖/档、请求不表态,仅 raw | transport 单测:同值、嵌套、被遮蔽、raw-only |
|
||||||
|
| M9 请求 medium,源 `EFFORT_FALLBACK=nearest` 改 error | 如 glm-5.3(映射 low→拒绝) | 源未表态时两工厂均可装配;旧身份可命中,新隔离身份在 transport 拒绝 | 改 error 的同时显式迁移 namespace/salt | cache 单测:nearest 写入→error 读,新身份必须 miss |
|
||||||
|
|
||||||
|
配置实例(仅列需替换项,其他已校验的源配置保留):
|
||||||
|
|
||||||
|
| 场景 | 旧 | 新 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M3 源级 | `LLM__MINIMAX__1__ENABLE_THINKING=true` | 删除该键;`LLM__MINIMAX__1__REASONING_EFFORT=medium` |
|
||||||
|
| 请求级 AUTO | 源不表态;`chat(..., reasoning_effort="auto")` | 源仍不表态;用户选择 `chat(..., reasoning_effort="medium", cache_salt="epoch-7:thinking-134-a")`(M3 示例) |
|
||||||
|
| 工厂缓存 | `PGW_CACHE_NAMESPACE=lab:tenant-a` | `PGW_CACHE_NAMESPACE=lab:tenant-a:thinking-134-a`(人工标记,不是新增配置键) |
|
||||||
|
| per-call 租户覆盖 | `cache_namespace="tenant-a", cache_salt="epoch-7"` | `cache_namespace="tenant-a:thinking-134-a", cache_salt="epoch-7"`;只改工厂默认值对此路径无效 |
|
||||||
|
| 全量注入 | 构造参数 `cache_namespace="lab:tenant-a"` | 改成上述新 namespace;既有 cache/ttl 参数照常注入 |
|
||||||
|
| 自定义 wire 偷带强度 | `on_base={"reasoning_effort":"high"}` | `on_base={}`、保留 effort_key;用户显式选 HIGH(须模型支持),并迁移缓存 |
|
||||||
|
|
||||||
|
三项目迁移验收须由各自负责人提供脱敏的实际配置/装配与调用位置,映射 M1–M9、提交所选替代与缓存身份切换证据。**当前三项目均为未核验**;Protocol 合成兼容测试通过也不能代替现行配置迁移验收。
|
||||||
|
|
||||||
|
### 5.3 旧行为处置
|
||||||
|
|
||||||
|
| 旧行为 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| True→AUTO、请求>源>糖、None/NONE、未知尽力+warning | 保留;未知仍受 wire 可表达性约束 |
|
||||||
|
| 已登记 AUTO 无条件放行、MiniMax 偷带 medium、受管与 raw 双来源 | 替换,明确放弃这些隐式兼容;迁移见上 |
|
||||||
|
| 原 raw-only、普通采样优先级、显式强度 nearest | 保留,不做通用 overlay 重构 |
|
||||||
|
| 缓存旧键/历史遥测/响应字段/端口签名/成本口径 | 保留数据及签名,语义变化靠显式缓存身份迁移,历史不伪造新观测 |
|
||||||
|
| 任务恢复/断点续跑 | 不适用,无任务状态;并行版本与持久化纪律见 §5.1 |
|
||||||
|
|
||||||
|
## 6. #25:可执行的测试侧归因与证据
|
||||||
|
|
||||||
|
### 6.1 输入来自哪里
|
||||||
|
|
||||||
|
仅在测试侧增加一个纯分类函数及窄取证 fixture,复用 Markdown 报告;不新增生产事件、字段、端口或通用诊断框架。**不能从压平的最终异常字符串重建缺失尝试**。
|
||||||
|
|
||||||
|
| 输入 | 可实现来源与限制 |
|
||||||
|
| --- | --- |
|
||||||
|
| 预期请求 | 测试矩阵显式提供目标 model、POST 端点路径、stream、允许的源 origin、预期推理/结构化片段与提示词摘要;不能调用待测 `_build_payload` 生成“预期” |
|
||||||
|
| 实际请求与 HTTP 响应 | 利用既有 `OpenAICompatTransport(client_factory=...)` 注入带 httpx request/response hooks 的真实 AsyncClient;request hook 检查 method、规范 URL、JSON model/stream/控制片段,Authorization 与该源凭据仅在内存精确比较,输出布尔值 |
|
||||||
|
| HTTP 错误体 | response hook 保留本次响应引用;该次 complete 结束后读取**已缓冲** content(最多接受 64 KiB 完整内容作为分类输入)。未缓冲/超限/解析失败均标证据不足;不在 hook 预读成功 SSE,不另发请求,不以摘要假装完整 JSON |
|
||||||
|
| attempt 关联 | 测试专用窄 Transport 委托器原样转发 complete/embed 参数与异常,将入参 call_id(attempt UUID)放入任务局部 ContextVar 供 hooks 使用;finally 复位。只改测试装配,实际 payload/解析仍由真实 transport 执行 |
|
||||||
|
| 逻辑调用与错误 | 每轮 chat 显式传 session_id=运行 ID、parent_call_id=本轮 UUID,并由测试在 chat 外围将同一二元组绑定任务局部上下文、finally 复位;委托器从该上下文建立二元组→attempt UUID 关联,保存原始异常类/cause 与 HTTP 记录。归因用逐次记录,不靠最后一个错误推断所有前序 |
|
||||||
|
|
||||||
|
带取证的 live 用例走既有全量注入路径,工厂与默认 client_factory 的装配/鉴权构造另有离线回归,不能用测试工厂替换后宣称原工厂已验证。需验证工厂本身的 live 用例若没有该证据通道,失败就保持 FAIL,不补生产接口凑证据。
|
||||||
|
请求 hook 校验不通过须记证据并使测试 FAIL;不得改写请求后再称原请求正确。凭据不写哈希、不落盘;URL 去 userinfo/query,只存安全 origin 标识与路径。
|
||||||
|
|
||||||
|
### 6.2 精确分类:默认 FAIL
|
||||||
|
|
||||||
|
分类输出为 FAIL 或外部未覆盖;外部未覆盖在 pytest 中可呈 SKIP,但报告与覆盖汇总必须记“未覆盖”。成功响应的行为断言仍单独执行,不经此分类器放宽。
|
||||||
|
|
||||||
|
| 情形 | 精确规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 环境前置缺失 | 测试矩阵列出的必需凭据/可选外部 Protocol 包未提供,网络前记未覆盖;键存在但配置格式错误、值校验失败是 FAIL |
|
||||||
|
| 404 model_not_found | 仅当本轮恰有一次实际 HTTP 尝试、无其他错误,请求检查全通过,响应为 404,完整 JSON 对象的 `error` 是对象且 `error.type == "model_not_found"`,默认 transport 对外为 RequestRejectedError 且 status 一致,才记“端点回报该请求型号不可用,未覆盖”。JSON 重复键也拒绝作为证据 |
|
||||||
|
| 普通 404/伪机器字段 | 只在 message 出现子串、字段类型错误、截断体、未缓冲体、端点/model/鉴权不符、响应与 attempt 无法配对,一律 FAIL |
|
||||||
|
| 429/5xx/401/403、网络错误 | **本批不建立自动外因豁免**:当前资料未给可核验的网关机器码白名单及独立归因来源,全部 FAIL 并保存实收证据;不能仅凭 HTTP 状态、Transient/SourceDead 类或“请求离线测过”跳过 |
|
||||||
|
| no_sources/stalled/retry_exhausted/AllSourcesExhausted | FAIL;本批不从生产遥测补失踪逐次原因,不将“曾见过一条 429”推断为整个终态均外因 |
|
||||||
|
| SSE/JSON 解析、空补全、ValueError、一般 RequestRejected/ResultInvalid、断言失败 | FAIL;请求正确不证明解析器或治理正确 |
|
||||||
|
| CancelledError | 原样穿透,finally 清理,不变成 SKIP |
|
||||||
|
| 成功但模型身份缺失/不符 | 实发 model 检查不通过是 FAIL;最终 model_reported 缺失/不符且没有独立原始响应身份取证时,保持 FAIL 并标记“身份来源无法区分”,不能推断上游没报。只有独立原始响应证据证明上游身份缺失/不在显式别名集合,才可记能力未覆盖;原始身份正确而解析/搬运丢失或改错必须 FAIL。本批不新增成功 SSE 捕获器,无该证据通道时按 FAIL 处理;别名不按前缀猜测 |
|
||||||
|
|
||||||
|
没有证据通道时宁可 FAIL,不用抽象的“已证明外因”做逃生条件。运维人工确认可附外部证据供发布负责人决定豁免,**不自动把 FAIL 改 PASS 或扩大分类白名单**;未来扩大自动归因须独立给真实样本及反例契约。
|
||||||
|
不追加裸 HTTP 对照诊断、自动重跑或悄悄缩小超时/stall;新增调用须先有人类模型/轮次/并发预算。
|
||||||
|
|
||||||
|
### 6.3 覆盖判据与报告
|
||||||
|
|
||||||
|
**先声明测试命题,再解释观测**:下述关闭成功判据只适用于“支持 NONE 的型号应成功关闭”,不是要求所有型号均可关闭。
|
||||||
|
|
||||||
|
| 测试命题 | 观测与结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已声明可关闭,验证受支持 NONE | 任一 OBSERVED 证伪关闭保证,FAIL;完整必需轮次且请求/身份合格、每轮 ABSENT 才可关闭覆盖 PASS;混入 UNKNOWN 记未覆盖 |
|
||||||
|
| 已声明不可关闭,T10 绕过库能力守卫验证上游 | 合格 OBSERVED 是本轮仍推理的证据,可支持该测试条件下的不可关闭声明,不是关闭成功,也不得仅因 OBSERVED 而 FAIL;必须按预先固定命题及轮次集合比较观测与声明,UNKNOWN 不补足证据,不据有限探测声称证明所有上游参数均无法关闭 |
|
||||||
|
| 有意请求不支持档位,验证预期拒绝 | 独立于外因分类器,直接断言预先声明的拒绝类型/状态与机器字段。符合预期的 400/RequestRejected 是负向契约通过,不是外因 SKIP;非预期错误仍 FAIL。上游探测照过请求资格、独立身份可得性与逐轮证据要求 |
|
||||||
|
|
||||||
|
增加离线反例:可关闭声明+OBSERVED 必须 FAIL;不可关闭声明+合格 OBSERVED 不得仅因出现推理而 FAIL;原始 JSON/SSE 含正确 model 但公共响应丢失/改错,必须 FAIL 而非 SKIP。全 UNKNOWN 绝不能算关闭覆盖通过。
|
||||||
|
本候选不采用 completion_tokens 长短作为通用关闭证明;旧 prompt_tokens 差异只保留为指定历史样本的 wire/观测回归锚点,不使 UNKNOWN 升格关闭能力 PASS。没有经单独审定的独立关闭证据就记未覆盖,运行时 UNKNOWN 不告警的既有语义不变。
|
||||||
|
开启测试保留显式轮数和多数 OBSERVED 规则(`observed_count > planned_rounds / 2`),但必须先保证全部计划轮次完成且请求/身份合格;失败/缺轮不得从分母删除。未知模型单次成功不自动变成能力登记。
|
||||||
|
|
||||||
|
逐轮先保存结果再判断后续处置;第 2 轮失败不能丢第 1 轮。报告字段包含矩阵 ID、provider、请求/回报模型、stream、请求档/响应 applied_effort、轮次、parent/session、attempt UUID、请求校验结果、异常类/status/安全摘要、已完成轮数及覆盖状态。
|
||||||
|
完整错误体仅内存分类,落盘只存机器字段与沿用 2048 字符上限的脱敏摘要;清除已知凭据及私有提示词回显,无法安全保留则摘要省略并标明。不得写 Authorization、完整 .env 或私有提示词。
|
||||||
|
每轮用唯一运行目录与轮次文件安全写入,汇总不能覆盖前轮失败;报告写失败使测试 FAIL,不允许无证据 skip。取消 finally 关闭自建资源,不引入无界网络等待。
|
||||||
|
|
||||||
|
| 既有测试接缝 | 窄修正 |
|
||||||
|
| --- | --- |
|
||||||
|
| L9 `_MYSTERY_PROFILE` | 保留显式全 None profile,将“未知形态报错”纳入离线断言;默认 openai 已非未知 |
|
||||||
|
| L8 `can_disable=False` | 装配拒绝只记本地契约通过,不写“与实测一致”;L8/T10 的 UNKNOWN 按上述覆盖门修正 |
|
||||||
|
| `_rounds_or_skip`、默认基线、T10 | 统一精确分类与逐轮留证;部分档未覆盖不能汇总为模型全覆盖 |
|
||||||
|
| compat Protocol/平铺键 | 完整合成 env/注入组件离线测装配;外部 Protocol 缺包只标该兼容项未覆盖,不声称读过缺失项目 |
|
||||||
|
| embedding 探测 | 去掉捕全错误为“不支持”,使用同一窄判据及 finally 关闭;不扩展端点能力 |
|
||||||
|
|
||||||
|
发布门分开统计离线契约与预先声明的 live 单元。必需单元 SKIP/UNKNOWN/缺行/未完成不能凭 pytest exit 0 放行,须重测或人类明示豁免;不自动缩小覆盖集合。
|
||||||
|
|
||||||
|
## 7. #26:真实调用链与变异证据
|
||||||
|
|
||||||
|
复用 `test_embedding.py`、`test_ocr_client.py` 的真实 client、脚本 transport、recorder 工装;仅替换外部传输,不 mock emitter,不手工 emit_attempt 伪造 False。
|
||||||
|
|
||||||
|
| 验证 | 必须断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| embed、recognize_text、parse_layout | 源分别填 True 糖/显式 HIGH;一次 Transient 后成功,每路径确有两条尝试行(一失败一成功),reasoning_effort 全 None |
|
||||||
|
| 拒绝与耗尽 | 至少一条已发生的失败尝试落库且档位 NULL,主异常仍上抛;不凭本 issue 新增不存在的终态行 |
|
||||||
|
| chat 阳性 | 已登记 AUTO 源的 True 糖失败行 auto;显式档失败行请求意图;nearest 成功行映射档;emitter 恒 NULL 必须被抓住 |
|
||||||
|
| SQLite 锚点 | 每个无推理入口至少一组真实 client+临时 SQLite,断言总行数、失败行数和 NULL 数,不用 all([]),不接共享 llm_calls |
|
||||||
|
| wire 边界 | MockTransport/录制响应验证 embedding、OCR 不发推理参数;layout POST+ZIP GET 属同一次治理尝试,不误算两条遥测 |
|
||||||
|
| 并发 | 共享 recorder 并发 chat/无推理调用,**按测试指定的 (session_id, parent_call_id) 分组逻辑调用**;组内 call_id 是不同 attempt UUID,集合无交集、档位不串,不把 call_id 当所有重试共用的 ID |
|
||||||
|
|
||||||
|
隔离副本/worktree 逐一变异并还原:embedding False→True;OCR False→True(两个入口分别红);chat True→False;移除 emitter applies 短路。记录节点、目标语义失败断言、退出码,原实现及还原后通过。
|
||||||
|
Issue #26 当前实现正确,先红来自上述变异,不为 TDD 改坏主工作区;仅因无关签名异常变红不算杀死目标变异。注入资源由测试自己关闭。
|
||||||
|
|
||||||
|
## 8. 非功能与四种遥测行口径
|
||||||
|
|
||||||
|
继续通过 `TelemetryEmitter` 唯一出口与现有 `llm_calls.reasoning_effort`;**不新增生产遥测字段、表、事件或旁路日志流水**。
|
||||||
|
|
||||||
|
| 行类型 | reasoning_effort 来源 | 分析限制 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 真实成功尝试 | response.applied_effort(nearest 后);无推理路径由 applies=False 短路为 NULL | 不重算;未知 AUTO 仅尽力编码,不证明上游能力;raw-only 为 NULL |
|
||||||
|
| 失败尝试 | effective_effort(请求>源>糖);无推理路径 NULL | 是请求意图,不是已发出的/映射后的档,也可能零 HTTP |
|
||||||
|
| 缓存命中 | **本次请求级** reasoning_effort | 不取历史 response.applied_effort,不推源级档;thinking_observation 回放历史,不是本次实测 |
|
||||||
|
| scope 终态失败 | **本次请求级** reasoning_effort | 可能未选源;不推源级档或实际档,不凭本批补充逐次根因 |
|
||||||
|
|
||||||
|
实际档分析仅使用真实成功尝试(排除 cache_hit、失败/终态行),还须保留未知/raw-only 的语义限制。#26 的 NULL 与 chat 阳性不能误套到缓存/终态来源上。
|
||||||
|
|
||||||
|
| 维度 | 约束 |
|
||||||
|
| --- | --- |
|
||||||
|
| 并发/幂等 | 能力表不可变、注册返回新表;解析局部结果,不用全局最后档;同声明同意图同结果;报告按运行/逻辑调用/attempt 隔离 |
|
||||||
|
| 取消 | 同步守卫不捕 BaseException;CancelledError 穿透,既有 in-flight finally 释放;不重构真实调用/遥测取消时序 |
|
||||||
|
| 降级 | 缓存/遥测故障 warning 降级,限流/熔断后端不可用仍报错;配置守卫与运行期四分类见 §4.4 |
|
||||||
|
| 持久化/原子性 | 无 schema/DDL;SQLite 临时文件,旧缓存不改写,报告安全写且失败显式失败;无任务恢复子系统 |
|
||||||
|
| 告警/评估 | 未登记与对账 warning 保持实例节流;错误含 model、请求档、支持集合与可执行配置例,不泄露凭据;离线矩阵和变异须全过,live 覆盖基线待实际运行 |
|
||||||
|
|
||||||
|
## 9. 实施接缝与文档同步
|
||||||
|
|
||||||
|
| 接缝 | 预期改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| thinking/providers | AUTO 成员约束、nearest 边界、文案、空 wire 语义、D2 窄纯校验;已有能力证据保留出处,不无证据追加 |
|
||||||
|
| client/默认 transport | 工厂及请求前置可执行的守卫、最终双 raw 守卫与错误翻译;不改端口、不移动层序、不引入全源请求准入解析 |
|
||||||
|
| cache | 本批不加语义 revision 或自动校验;只交付显式迁移回归与边界说明 |
|
||||||
|
| 单元/轻集成/e2e | 真实调用链+SQLite、分类输入与反例、逐轮完整性、隔离变异;M3 True 改为“本地拒绝”和“medium 真实开启”两个命题 |
|
||||||
|
| 文档 | 实施时同步 ARCH D11/§5.1/7.5/7.8、README、CHANGELOG、.env.example、源码 docstring;旧设计标注被替代段,不追改历史实验事实 |
|
||||||
|
|
||||||
|
Wiki 已下线,按 docs-convention 落到上述文件,不虚报 wiki 多页;schema/端口未变不 bump 字段数。本次仅写本设计,其他同步留正式批准后的计划。
|
||||||
|
|
||||||
|
## 10. 验收矩阵与审批门
|
||||||
|
|
||||||
|
下列均为**待实施验收**,不是本轮通过记录。凡缓存不得绕过/救活新拒绝的断言,统一以**已完成 §5.1 显式迁移前置**为条件。
|
||||||
|
|
||||||
|
| 层 | 必需验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| 解析离线 | None/NONE/AUTO/强度、已登记含/不含 AUTO、未知空/非空/未知 wire、nearest 两方向、默认与自定义注册表;AUTO 拒绝不提示 nearest |
|
||||||
|
| raw 冲突 | 源/请求双入口,同值、被遮蔽、嵌套根替换、新增控制键、自定义 effort_key/off 根、非法 on_base;无受管意图保留 raw,普通采样不误拒 |
|
||||||
|
| 守卫时机 | 工厂失败零准入/零 HTTP;前置可判请求冲突零准入;默认 transport 拒绝允许既有准入但零 HTTP、正确结算、无重试换源;全量注入同测 |
|
||||||
|
| 缓存迁移 | 旧客户端写旧身份→新客户端使用全新身份 miss 并执行新拒绝;nearest 写→error 读、能力表/wire 变化均换身份;工厂默认、per-call 覆盖、全量注入、多源集合、并行旧新客户端覆盖 |
|
||||||
|
| 缓存已知边界 | 另测未迁移的共享身份可能命中并绕过 transport;将其明确记录为操作风险,不把迁移前未拒绝伪装为迁移后安全已验证;无强制全部 chat 冷启动 |
|
||||||
|
| 归因/防假绿 | 404 精确 type/仅 message/截断/重复键;400/429/503/SSE/no_sources 默认 FAIL;故意改错 model、Authorization、端点、SSE 解析必须红;第二轮失败保留第一轮证据;取消穿透 |
|
||||||
|
| 遥测 | 四种行来源逐项断言;三个无推理入口真实调用链/SQLite NULL 与 chat 阳性;按 parent/session 归组且 attempt UUID 唯一;四类变异被目标断言杀死 |
|
||||||
|
| 真实核心 | M3 AUTO/True 拒绝零网络、M3 medium 流/非流、M2.5/M2.7 空 wire AUTO、qwen AUTO;缺观测保留未覆盖,不靠旧缓存 |
|
||||||
|
| 真实扩展 | 逐型号/模式列出默认基线、开启/关闭;身份缺失、UNKNOWN、不可达不算关闭 PASS,不用同系替代;新增 AUTO 如另获批准,逐型单列最终 wire/身份/信号 |
|
||||||
|
| 迁移/全局门 | 三项目实际配置另取证;conda 静态检查、日常套件、独立 verifier;发布按 CLAUDE 显式跑 slow 与下游视角包验证,不能以本设计代替运行结果 |
|
||||||
|
|
||||||
|
付费 live 必须先通过离线构造契约,再经人类批准模型/轮次/并发预算;生产超时不为赶结果压小。本文件不授权任何新真实付费调用。
|
||||||
|
|
||||||
|
## 11. 审查历史、修订对应与当前状态
|
||||||
|
|
||||||
|
前稿记录 Codex 4 项 Important;本轮按下表修订,**修订不等于独立复审通过**。上一轮 Claude 输出不对应本仓库,整份无效且不作为任何技术结论/通过证据引用。
|
||||||
|
|
||||||
|
| Codex 项目 | 本轮修订与复审锚点 |
|
||||||
|
| --- | --- |
|
||||||
|
| 缓存迁移边界不足 | §5.1/5.2 M9、§10:D3 显式迁移,不加 revision;同版本 fallback/能力/wire 变化、全量注入和 per-call 覆盖均纳入;未迁移仍可能绕过 |
|
||||||
|
| 遥测“成功=实际档”过宽 | §8 四种行逐项来源,真实成功尝试才读 applied_effort;缓存与终态仍读请求级,不扩大生产遥测 |
|
||||||
|
| UNKNOWN 被计关闭覆盖 PASS | §6.3/§10:UNKNOWN 不证实关闭,未覆盖不占 PASS;长度差不作通用关闭证明,运行时 UNKNOWN 语义不变 |
|
||||||
|
| 缺下游迁移矩阵 | §5.2 M1–M9 与具体键/调用实例;受影响型号、错误时机、人工替代、离线锚点齐全;三项目现行配置明确未核验 |
|
||||||
|
|
||||||
|
已决 D1–D3 的旧“未决推荐”段已移除。2026-09-09 第二轮 Codex 审查对修订版无 Critical/Important/Minor;native reviewer 另发现两个测试归因 Important:公共响应身份不足以归因上游,以及 NONE 关闭成功与不可关闭负向研究混同。父会话已在 §6.2/6.3 作最小修订(证据不足 FAIL、不新增成功 SSE 捕获器、按测试命题判定),native reviewer 定向复审通过(run `2f92c90a-298d-459d-8fcb-087dea475770`),无新增 Critical/Important/Minor。
|
||||||
|
剩余证据门为 M2 空 wire 实测、其他 live 缺测及三项目实际迁移,不重新悬置已决 D1–D3。2026-09-09 用户已明确选择“批准并继续”,正式批准本文件;独立审查及 native reviewer 定向复审均已通过。
|
||||||
|
**当前结论:设计已批准,进入实施计划;计划独立审查通过后直接执行。** 设计批准不等于变异、真实矩阵或下游迁移已通过;尚未取得的证据仍按 §10 门控。
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# 1.3.5:逻辑调用统计与结构化失败诊断
|
||||||
|
|
||||||
|
> 状态:独立审查及定向复审通过,**人类已于 2026-09-09 正式批准**(§10 六项批准项全数获批,可据此实施公共 API)。实施计划见 `research-wiki/plans/2026-09-09-135-call-observability.md`。
|
||||||
|
> 日期:2026-09-09。范围:issue #19、#23。基线源码 HEAD e71a623。
|
||||||
|
> 复用现有 Emitter / schema / 三条治理循环,不引入追踪平台、不做 deadline / hedging、不在库内复制上下游关联状态。
|
||||||
|
|
||||||
|
## 1. 问题与边界
|
||||||
|
|
||||||
|
治理单位是一次逻辑调用,但当前 `latency_ms` / `call_id` 只描述**单次尝试**:重试几次、等了多久、缓存有没有参与,SQL 答不出来。诊断侧,transport 抛出的领域错误已带 `status_code` / `operation` / `body_text`(errors.py:52),进 emitter 前却被 `str()` 压平成一列自由文本。终态失败可能没有选中源,但 **scope 始终已知**。
|
||||||
|
|
||||||
|
中转把供应商 529 改写成 503 后,库只能如实记 503,不能猜回 529;未知成本、未知状态一律 NULL。本版不改重试预算、429 语义、stall、推理能力表与取消结算算法,不新增核心依赖。
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
| 方案 | 收益 | 代价 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A:只在 RetryMW 加两个计数、错误列细分 | 补丁最小 | 漏缓存命中、结构化重问、embedding 分批与三类失败终态,回答不了"整次调用" |
|
||||||
|
| B:每调用局部统计 + 领域异常下沉到单一出口,复用现有遥测行 | 统一边界、字段保真、不默认加成功行、不双计费用 | 增加内部上下文、响应字段与 recorder 字段;须补齐缺失的失败终态行 |
|
||||||
|
| C:新增独立逻辑调用表 / 通用事件端口 | 完整追踪与任意事件分析 | 新存储与运维面,超出当前需求 |
|
||||||
|
|
||||||
|
推荐 **B**。显式传递局部对象,不用 client 共享可变计数、不用模块级 ContextVar;不统一三条治理循环,只统一计数与诊断出口。
|
||||||
|
|
||||||
|
## 3. 逻辑边界与公开统计
|
||||||
|
|
||||||
|
四种响应(`LLMResponse`、`EmbeddingResponse`、`OcrTextResult`、`OcrLayoutResult`)追加 `call_stats: CallStats | None = None`。新增 frozen `CallStats` 并由包根导出——四份平铺字段会立刻漂移。
|
||||||
|
|
||||||
|
| CallStats 字段 | 语义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `logical_call_id: str` | 每次公开调用一个 UUID;重试、重问、分批共享;不占用既有 `parent_call_id` |
|
||||||
|
| `attempts: int` | 准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝;**不是 HTTP 请求条数** |
|
||||||
|
| `total_latency_ms: int` | 从输入校验通过到返回/异常传播前的单调时钟快照;含缓存、等待、重问、分批、内联记账与资源收尾 |
|
||||||
|
|
||||||
|
输入校验异常发生在统计边界之外,保持原行为。第三方合成响应的 `None` 表示未知,不得默认伪造 0。
|
||||||
|
|
||||||
|
- **缓存命中**:`attempts=0`、新 logical ID、本次缓存路径耗时;不回放历史统计。缓存持久化排除 `call_stats`(`_serialize` 显式剔除),`_rehydrate` 显式覆盖为 `None`——`_RESPONSE_FIELDS` 过滤会放行历史 dict,不覆盖就会有 dict 冒充 `CallStats`。缓存 key 白名单不变。
|
||||||
|
- **embed 空输入**(embedding.py:180-190):合法零尝试,返回真实统计(`attempts=0`),**不写任何遥测行**——与 cache_hit 不同,不要按"必录"推断它有台账行。
|
||||||
|
- **OCR layout** 的 POST + ZIP GET 在同一 transport 调用内(monkey_ocr.py:262-296),计 1 次尝试。
|
||||||
|
- **chat 重问 / embedding 多批**计入同一上下文,不重置计数(重问经 `call_next` 重入 RetryMW,已核对)。
|
||||||
|
|
||||||
|
`latency_ms` / `call_id` / `parent_call_id` 语义不变;"总耗时减最后一次尝试耗时"不等于纯等待(含其他本地工作)。不在响应里挂每次尝试的明细列表,避免公共响应无界增长。
|
||||||
|
|
||||||
|
### 3.1 失败与取消
|
||||||
|
|
||||||
|
本版**不向异常对象附加可变 `call_stats`**:第三方可能复用同一异常实例,first-write-wins 会把首次调用的统计误读成本次,覆盖写则串扰;复制任意异常又保证不了构造签名与自定义属性。异常类型与分类原样保留,失败侧的统计走 §6 的终态行。
|
||||||
|
|
||||||
|
- 取消:`CancelledError` 保持原类型与语义,不在其上加字段;取消路径只**尽力**写一条终态,不 shield、不开新后台任务。
|
||||||
|
- 任意内部非领域异常(编程错)原样传播,本版**不承诺**为其提供任何统计或终态行,也不偷偷改分类。
|
||||||
|
|
||||||
|
## 4. 最小内部接缝
|
||||||
|
|
||||||
|
`CallStats` 与私有可变 `_CallContext` 都落 `types.py`:`types.py` 不反向依赖实现层,不产生循环,也不动 import-linter 分层(`ports : types : errors` 并列最内层)。私有上下文只持计数、单调时钟与必要去重状态,不做 I/O。
|
||||||
|
|
||||||
|
`ChatRequest` 追加内部上下文字段(`default=None, compare=False, repr=False`);`StructuredMW` 的 `dataclasses.replace` 保留同一引用(structured.py:96-110,已核对)。响应统计只在公共出口经 `replace` 附加。`GatewayClient` 需自存注入的 `now`(client.py 现未保存),**冻结快照是同步动作,不 await**。
|
||||||
|
|
||||||
|
- 上下文创建/冻结:`GatewayClient` 在公开入口创建、`finally` 冻结;`RetryMW` 只在 transport 调用前登记一次尝试。
|
||||||
|
- **OCR 例外(M1)**:`image` 的类型/空校验在 `_call` 内(ocr.py:238-241)而非公开方法,故上下文在该校验**通过之后**创建,§3 的"校验在边界外"对 OCR 才成立。
|
||||||
|
- `EmbeddingClient` / `OcrClient` 显式把同一上下文传到每批/每次尝试;统计生效与否**不由 telemetry 是否启用决定**。
|
||||||
|
- 每次尝试 ID 仍在当前循环产生,与上下文的 logical ID 一起交给 Emitter。不持久化断点,不引入任务恢复。
|
||||||
|
|
||||||
|
## 5. 诊断字段与归因方式
|
||||||
|
|
||||||
|
保留现有 `error` 字符串供人阅读;Emitter 改为接受**领域异常对象**而非调用方先 `str()`,由单一 helper 提取有限诊断字段。普通超时/网络翻译在 transport 侧保留直接 `__cause__` 类型,空 `str()` 退回类名;**不遍历任意异常对象、不猜测正文**。
|
||||||
|
|
||||||
|
| 新增 INSERT 列 | 值域 / 来源 |
|
||||||
|
| --- | --- |
|
||||||
|
| `scope` | 配置池名,构造期注入 Emitter(见下);不拿 `source_name` 顶替 |
|
||||||
|
| `operation` | **公开方法固定四值**:`chat` / `embed` / `recognize_text` / `parse_layout`;由三个 client 在调用点给定 |
|
||||||
|
| `logical_call_id` | 当前调用上下文的 UUID;上下文缺席(库内现场构造的 `ChatRequest`)→ **NULL,不造 ID** |
|
||||||
|
| `event_kind` | `attempt` / `cache_hit` / `terminal_failure`;旧行 NULL,不回填 |
|
||||||
|
| `http_status_code` | **仅 attempt 行**:失败异常实收状态;无 HTTP 或未知 NULL;成功行不统一填 200 |
|
||||||
|
| `error_type` | 该行自身错误的领域类名;成功行 NULL |
|
||||||
|
| `cause_type` | **仅 attempt 行**:transport 直接捕获的底层异常类名,未知 NULL |
|
||||||
|
| `error_body` | **仅 attempt 行**:既有 `summarize_body` 有界摘要,未知 NULL;不存 `raw_text`、不存全量原文 |
|
||||||
|
| `attempts` / `total_latency_ms` | **仅 terminal_failure 行**填逻辑快照,其余行 NULL;快照在写入前冻结 |
|
||||||
|
|
||||||
|
全部可空,追加到物理列末尾(与旧表 ALTER 追加位置一致,`schema.py` 的 DDL / BACKFILL / COLUMNS 三处同改)。
|
||||||
|
|
||||||
|
**归因方式(C1,决定性)**:`GatewayUnavailableError` 家族从不携带 `status_code` / `body_text`(errors.py:118-166),终态行的 `http_status_code` / `cause_type` / `error_body` 因此**保持 NULL,这是它自身的真实状态**——不把最后一次 attempt 的状态码与正文搬上来伪装成整池诊断(那正是"不拿最后一个源冒充整池归因"的同一条红线)。终态行的 `error_type` 落它自己的类名(`AllSourcesExhausted` / `CircuitOpenError` / …),scope 级 reason 沿用**已有 error 文案**(`str(exc)` 已是 `"{scope} 网关暂时不可用: {reason}"`,不新增列)。逐源现场由同一 `logical_call_id` 的 attempt 行给出。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- #19 验收:一次逻辑失败调用的完整现场(终态 + 各次尝试)
|
||||||
|
SELECT event_kind, source_name, http_status_code, error_type, cause_type, error_body, error
|
||||||
|
FROM llm_calls WHERE logical_call_id = :lcid ORDER BY created_at;
|
||||||
|
```
|
||||||
|
|
||||||
|
该查询必须同时给出"整池为何失败"(终态行 error 文案)与"每个源怎么死的"(attempt 行状态码/正文),测试按它断言。因此**本版不再为 `reason` / `per_source_reasons` 扩列**。
|
||||||
|
|
||||||
|
**结构化耗尽的可定位性(C2)**:`ResultInvalidError("结构化输出阶梯耗尽")` 的 message 不含 `validation_errors` / `repair_error`(structured.py:80-86),而该失败发生在 StructuredMW 之上——RetryMW 侧的 attempt 行全是成功行,终态行是唯一记录。做法:**不写 `error_body`**(该列只属 attempt),由 Emitter 的单一 helper 对 `ResultInvalidError` 生成**有界结构化说明**并入现有 `error` 字符串,复用 structured.py 已有的取材口径(至多 3 条、每条 200 字符,与 `_format_errors` 同参数,拼装函数收敛在 helper 一处)。`raw_text` **不重复落库**(它是模型正文,attempt 行的 `response` 列已按 `text_cap` 记过一份;再存一份等于绕过既有正文预算)。该说明可能包含模型输出片段,故遵循与 `error` 现有正文相同的隐私边界,不额外扩大保留范围。
|
||||||
|
|
||||||
|
**operation 的数据源(I1/I2)**:openai_compat.py:169 的 `_status_to_error` 硬编码 `operation="chat"`,而 `embed()` 的非 200 分支(:512)也走它 → 现存所有 embedding HTTP 失败的 `exc.operation` 都是错的。(行号勘误 2026-09-09:本句原写 "(:512、:527)",实测 `:527` 属 `_complete_stream`即流式 chat,`embed` 只有 `:512` 一处;归属以实施计划 §2 表为准,决策未变。)修正:`_status_to_error` 增 keyword `operation`,`embed` 传 **`"embedding"`**(沿用该异常侧既有词表,不改 `chat` / `ocr_text` / `parse` / `download_result` 四值)。新列 `operation` 与 `exc.operation` 是**两个语义**:前者是公开方法,后者是 HTTP 子操作;新列由调用点给定,**绝不读 `exc.operation`**,两者不做自动转换。OCR 两个公开方法各自在调用级给定自己的值。
|
||||||
|
|
||||||
|
**scope 注入(M6)**:`TelemetryEmitter` 现在不知道 scope,`CacheMW` / `TelemetryMW` 自己也拿不到。构造期注入(三个 client 各一行),使 attempt / cache_hit / terminal_failure 三类行都带 scope,避免改三条调用链。model / provider / source 未选出时仍留原空值。
|
||||||
|
|
||||||
|
**成功侧不加承诺**:成功行不承诺 HTTP 状态与错误体;**本版不宣称 SQL 可直接统计所有成功逻辑调用的总耗时**(成功不加终态行)。`error_body` 沿用 `summarize_body` 的既有上限,**不纳入 `PGW_TELEMETRY_TEXT_CAP` 覆盖面**(该键现覆盖四处,详见 §8)。
|
||||||
|
|
||||||
|
## 6. 行语义与终态:只补确实缺失的失败
|
||||||
|
|
||||||
|
保留每次 attempt 与 cache_hit 的既有行,**不为成功新增终态行**。终态行不得复制已有 attempt 的 token 与成本。所有统计边界内的领域失败均尝试写终态,**包括已有 attempt 错误行的直接 RequestRejectedError/ResultInvalidError**;不再沿用“该类错误已录所以外层不录”的旧假设,400 密集负载的错误行可能翻倍,调用失败计数必须只取 terminal_failure。
|
||||||
|
|
||||||
|
**不变量(I3,统一措辞)**:
|
||||||
|
|
||||||
|
| 结束形态 | 终态行数 |
|
||||||
|
| --- | --- |
|
||||||
|
| 以**领域错误**结束的逻辑调用 | 每次调用**尝试写一条**;持久化 best effort(recorder 写失败按既有降级只落 warning),故 SQL 可见行数 ≤ 1 |
|
||||||
|
| 取消 | 三个 client **同策略尽力写一条**,允许 0 条 |
|
||||||
|
| 非领域异常(编程错) | **0 条**,原样传播,本版无统计保证 |
|
||||||
|
|
||||||
|
需要补的路径:chat 结构化耗尽(发生在 transport 成功之后,现无任何失败行)、embedding / OCR 的无源、准入拒绝、重试耗尽与尝试外取消。**取消口径统一(I4)**:chat 现由 TelemetryMW 对任何取消补终态(telemetry.py:487-495,含尝试内取消),embedding / OCR 按同一口径尽力补,避免下游按 `event_kind` 统计取消时拿到路径相关的结果。
|
||||||
|
|
||||||
|
终态与 attempt **不是重复事实**(前者描述逻辑终态,后者描述尝试),用 `event_kind` 区分;**禁止按 `error IS NOT NULL` 跨两类直接计失败调用次数**。chat 现有 TelemetryMW 终态路径收敛到公开边界的单一 helper,避免两处同时写;embedding / OCR 复用该 helper。
|
||||||
|
|
||||||
|
**终态行的请求摘要(M4)**:复用既有 200 字符输入摘要口径,描述**本次调用的整体输入**,但不扩大单行正文预算——embedding 终态取 `<embed texts=N batches=M>` 计数占位 + 第一批(至多 `batch_size` 条、每条 200 字符,与逐批行同款构造);OCR 终态沿用 `<ocr:{kind} image_bytes=…>` 占位,图像 bytes 永不入库。**失败批的具体文本由同 `logical_call_id` 的 attempt 行给出**,终态行不保存全量原输入。
|
||||||
|
|
||||||
|
**错误文本口径(I7)**:OCR 现落 `"类名: msg"`(ocr.py:445-449,按类名归组的既有 metric 口径),chat / embed 落裸 `str(exc)`。改成"Emitter 收异常对象"后,该差异由统一出口的**显式文本策略参数**保留(OCR 保留类名前缀),**不再在三处复制参数列表**。
|
||||||
|
|
||||||
|
**取消时的终态写(残余风险,显式定策)**:该 `await` 本身是新的取消点。策略是**取消优先、不屏蔽**:外部取消落在这一 await 上时,`CancelledError` 照常传播(调用方可能因此看到 `CancelledError` 而非领域错误,与 TelemetryMW 现有行为同款);不 shield、不建新后台任务。冻结统计快照是同步动作,不 await。
|
||||||
|
|
||||||
|
终态行成本 NULL、usage `unavailable`;聚合费用仍只由 attempt / cache_hit 行决定。终态快照在写入前冻结,故不含自身写入耗时;成功响应快照包含其返回前已完成的内联遥测耗时。失败异常不附快照,不为对齐再 UPDATE 旧行。
|
||||||
|
|
||||||
|
取消 attempt 既有字符串 `"cancelled"` 保留:Emitter接收 `PolyGatewayError | str | None`,字符串不解析猜测诊断,error_type/cause_type/http_status_code/error_body均NULL;终态取消同样使用明确取消文案。终态既有latency_ms与新增total_latency_ms取同一冻结快照,避免双时钟微差。
|
||||||
|
|
||||||
|
## 7. recorder 兼容:装配期机械闸(C3)
|
||||||
|
|
||||||
|
`TelemetryEmitter._record` 的 `except Exception` 会把旧 recorder 的 `TypeError` 吞成 warning(telemetry.py:463),后果是自定义 recorder 在下游升级后**100% 丢遥测且调用照常成功**——正是"遥测必录"要防的形态。文档级迁移清单挡不住它。
|
||||||
|
|
||||||
|
机械闸:在 `TelemetryEmitter.__init__`(三个 client 的唯一汇流点,与 `text_cap` 值域校验同处)对 `recorder.record_llm_call` 做**一次** `inspect.signature(...).bind(**<完整新 kwargs 形状>)`,**不执行写入**;含 `**kwargs`(`VAR_KEYWORD`)者自动通过。校验失败 → 装配期抛错。若目标不可 inspect(C 实现等),同样按**配置错误**当场报错,不进入"运行期静默丢行"。
|
||||||
|
|
||||||
|
边界诚实声明:签名 bind 只证明该形状能被接受,**不能证明函数体真的落这些列**;这是一道装配闸,不是行为验证。它既不是被否掉的 `runtime_checkable` 判定,也不是捕 `TypeError` 重试写入。是否扩主 `TelemetryRecorder` Protocol(备选:独立扩展端口保留旧实现)是 §10 的人类批准项;本草案不同时实现两套接口,自带两个 recorder 的 `**fields` 签名可接受新增参数,但其 schema、INSERT 字段与契约测试仍须同步,不能称后端完全不受影响。
|
||||||
|
|
||||||
|
校验所用参数名从 `TelemetryRecorder.record_llm_call` 的协议签名派生,不手抄第四份字段清单;只用哨兵值做bind形状校验,不读取真实请求数据。结构化说明的限条数/限长复用现有常量,若需命名常量则在既有规则所有者中定义并由两个消费者引用,不复制数值。
|
||||||
|
|
||||||
|
## 8. 下游可见变更与文档同步
|
||||||
|
|
||||||
|
**SQL 迁移清单(I6,批准项须按此逐条看)**:
|
||||||
|
|
||||||
|
| 影响面 | 变化 | 下游动作 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 失败行数 | 新增 `terminal_failure` 行(每失败调用至多 1) | 计失败调用改 `WHERE event_kind = 'terminal_failure'` |
|
||||||
|
| `error IS NOT NULL` | 同时命中 attempt 与 terminal 两类 | 不再作为"失败调用数"的判据 |
|
||||||
|
| `AVG(latency_ms)` | terminal 行携带**逻辑总耗时**,量级大于单次尝试 | 时延看板一律按 `event_kind` 分组或过滤 |
|
||||||
|
| 费用聚合 | terminal 行 `cost` 恒 NULL、usage `unavailable` | 费用仍只由 attempt / cache_hit 行决定,口径不变 |
|
||||||
|
| 成功侧 | **不加**任何成功汇总行 | 成功逻辑调用总耗时仍从响应 `call_stats` 读,不从 SQL 读 |
|
||||||
|
| `http_status_code` | 失败行上可能是 200(monkey_ocr.py:265-270 的 `success != true` 带 200 上抛) | 该列不可作失败判据 |
|
||||||
|
|
||||||
|
**文档同步(M5,发布前必须同批)**:README:23 的"必录 26 字段"、README:399 与 ARCHITECTURE.md:592 的 `PGW_TELEMETRY_TEXT_CAP` 覆盖面四处枚举(须明确:`error_body` 沿用 `summarize_body` 上限,`error` 保留既有文本口径并仅对新增结构化说明限长;二者**不在 cap 覆盖内**)、ARCHITECTURE.md:565 的必录字段清单与 §7.8 补列一节、`.env.example` 相关注释、CHANGELOG 与 wiki(docs-convention §2)。**字段数与物理列数一律以 `inspect.signature` / `len(COLUMNS)` 实测改写,不凭记忆**(现状:26 个 INSERT 字段 + `created_at` = 27 物理列;本版新增 10 列)。
|
||||||
|
|
||||||
|
## 9. 非功能与测试矩阵
|
||||||
|
|
||||||
|
| 维度 | 要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| 并发 | 每调用独立对象;同一 client 并发不串 logical ID / 计数 / 统计;无全局状态 |
|
||||||
|
| 取消 | 各等待点穿透;`finally` 释放既有资源;终态写取消优先;不新增 shield 与后台任务 |
|
||||||
|
| 降级 | recorder 写失败不改统计与主结果;准入后端仍 fail-closed;收尾失败仍原 warning |
|
||||||
|
| 持久化 | schema 单一事实源;SQLite auto / PG manual 裁剪 INSERT 保持;不 ALTER 默认生产 PG、不改旧列、不回填旧行 |
|
||||||
|
| 幂等 | attempt ID 唯一,终态独立 ID,不重复写同一终态;缓存命中不复制历史统计;不 UPDATE 计费 |
|
||||||
|
|
||||||
|
验收优先离线:真实 client + 内存后端 + FakeClock + MockTransport + 临时 SQLite,复用 1.3.4 设施;不重跑未变的模型能力矩阵。
|
||||||
|
|
||||||
|
| 测试族 | 必须证明 |
|
||||||
|
| --- | --- |
|
||||||
|
| logical 计数 | 一次成功、失败重试、免预算 429、多源拒绝、缓存命中、结构化重问、embedding 多批、OCR 双 HTTP、空输入(0 尝试且 0 遥测行) |
|
||||||
|
| 计时 | 缓存 IO、退避、准入等待、重问、收尾均计入;关闭 recorder 仍正确;毫秒/秒不混用 |
|
||||||
|
| 失败与取消 | 领域失败恰一次尝试写终态;取消三条路径同策略(允许 0 行);**终态写 await 上被取消 → `CancelledError` 传播**;permit / 探针释放不变;非领域异常 0 行且原样传播 |
|
||||||
|
| 保真诊断 | 503 包装不改回 529;直接 529 记 529;空 Connect/Read/Write/PoolTimeout 文案有类型;embedding HTTP 失败的 `exc.operation` 为 `embedding`;新列 `operation` 恒为四值之一且不随异常变化 |
|
||||||
|
| 归因 SQL | §5 那条按 `logical_call_id` 的查询同时给出终态 reason 文案与逐源状态码/正文;终态行三列为 NULL;结构化耗尽的 `error` 含有界 validation/repair 说明且不含 `raw_text` |
|
||||||
|
| 行语义 | 三类行均带 scope;每失败调用至多一条终态;attempt 与 terminal 区分;费用不重复;`AVG(latency_ms)` 按 `event_kind` 分组的断言 |
|
||||||
|
| 装配闸 | 旧签名 recorder → 装配期报错(非 warning);`**kwargs` recorder 通过;不可 inspect → 配置错误 |
|
||||||
|
| 存储兼容 | SQLite 新旧表、PG manual 缺列裁剪 / auto 追加、旧行 NULL、新旧进程混写 |
|
||||||
|
| 变异 | 计数位置、上下文复制、缓存历史回放、提前 `str()` 压平、终态双计费用分别红→绿;PG 真实集成 + 常规全套 + 独立验证 |
|
||||||
|
|
||||||
|
## 10. 集中人类批准项
|
||||||
|
|
||||||
|
| # | 决策 | 推荐 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 公开响应结构 | 一个 `CallStats` 对象而非四类响应各铺三字段;需确认命名与消费便利性 |
|
||||||
|
| 2 | 失败侧统计范围 | 不改/不复制异常;失败统计只落终态行,调用方仅在成功响应读 `call_stats`;需确认该取舍可接受 |
|
||||||
|
| 3 | recorder 兼容路线 | 扩主 `TelemetryRecorder` Protocol + 装配期 bind 闸;备选独立扩展端口保留旧实现;需确认是否存在必须兼容的自定义 recorder |
|
||||||
|
| 4 | 新增失败终态行 | 补漏但不加成功汇总;须批准 §8 表中**全部五项**下游可见变化(不止行数) |
|
||||||
|
| 5 | 取消时终态写取消优先 | 调用方可能看到 `CancelledError` 而非领域错误(同 TelemetryMW 现状);需确认接受 |
|
||||||
|
| 6 | `operation` 值域与异常侧修正 | 新列固定四值;`_status_to_error` 增 `operation` 参数、`embed` 传 `embedding`(修正现存误标,属下游可见的历史数据口径变化) |
|
||||||
|
|
||||||
|
## 11. 独立审查处理表
|
||||||
|
|
||||||
|
| 项 | 结论 | 落点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 终态缺诊断来源 | **不采纳"定向读 `__cause__` / `per_source_reasons` 扩列"**;终态保留自身 NULL 状态,归因由 `logical_call_id` 关联 attempt 行 + 既有 reason 文案完成,并写死 SQL 验收 | §5 |
|
||||||
|
| C2 结构化耗尽可定位 | 采纳(变形):有界说明并入现有 `error` 字符串,不写 `error_body`、不重复存 `raw_text` | §5 |
|
||||||
|
| C3 recorder 静默失败 | 采纳:装配期一次 `signature.bind` 形状校验,含 `**kwargs`,不可 inspect 即配置错误;明确不验证函数体 | §7 |
|
||||||
|
| I1 / I2 operation 污染与未归一 | 采纳:新列固定四值由调用点给定,绝不读 `exc.operation`;只修 embedding 误标,其余异常侧词表不动 | §5 |
|
||||||
|
| I3 / I4 终态不变量与取消口径 | 采纳(定稿措辞):领域失败每调用尝试写一条、持久化 best effort;取消三路同策略尽力允许 0;非领域异常 0 条 | §6 |
|
||||||
|
| I5 上下文缺席语义 | 采纳:`None` → NULL,不造 ID | §5 |
|
||||||
|
| I6 迁移影响不止行数 | 采纳:列全五项 SQL 影响并进批准项 | §8、§10 |
|
||||||
|
| I7 OCR error 文本口径 | 采纳:保留类名前缀,由统一出口的显式文本策略参数承载,不复制参数列表 | §6 |
|
||||||
|
| M1 OCR 校验与统计边界 | 采纳:上下文在 `image` 校验通过后创建 | §4 |
|
||||||
|
| M2 embed 空输入 | 采纳:0 尝试且不写任何遥测行 | §3 |
|
||||||
|
| M3 失败行可能带 200 | 采纳:写明该列不可作失败判据 | §8 |
|
||||||
|
| M4 终态请求摘要未定 | 采纳:复用 200 字符口径,终态描述整体输入,不扩预算,不存全量原输入 | §6 |
|
||||||
|
| M5 文档同步缺项 | 采纳:列全六处并要求实测改数字 | §8 |
|
||||||
|
| M6 Emitter 不知 scope | 采纳:构造期注入 | §5 |
|
||||||
|
| 残余风险(终态写成新取消点) | 采纳:显式定策"取消优先不屏蔽",并进测试族与批准项 | §6、§9、§10 |
|
||||||
|
|
||||||
|
自审:方案 B 复用既有 Emitter / schema 与三条循环,不需要 #22 / #24 的新调度。未采纳的两项(异常 first-write-wins、终态搬运最后一次 attempt 的状态与正文)理由已写在正文,不是遗漏。本文档无代码实现与测试通过声明;**§10 六项已于 2026-09-09 获人类批准**,实施边界与红绿证据要求以上述实施计划为准。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:issue15-telemetry-pool-lifecycle
|
||||||
|
title: "issue #15: 遥测连接池的资源语义与生命周期"
|
||||||
|
date: 2026-08-24
|
||||||
|
---
|
||||||
|
|
||||||
|
# issue #15: 遥测连接池的资源语义与生命周期
|
||||||
|
|
||||||
|
正文: `2026-08-24-issue15-telemetry-pool-lifecycle-design.md`。状态: **已实施(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`)**——人类已确认方案、Codex 已审并逐条处置(正文 §9),T0–T7 全部完成;独立验证发现的 5 个问题已处置,实施期修订见正文 §10。前序: [[design:issue9-telemetry-ddl-probe]](判死判据的上一次收窄)、[[design:issue13-schema-mode]](schema 单一事实源)。
|
||||||
|
|
||||||
|
- **现象**: 共享 PG 实例余量紧张时,遥测**建池**失败 → `_failed` 永久置位 → 该 client 此后一行遥测都不落库,只有一条 warning,靠人肉对账才发现(19 次调用、成本少记约 $5)。
|
||||||
|
- **选定方案(四组一次做完)**: A 池语义(`min_size=0` + `max_size` 可配,缺省 4 + 整次写入硬预算 5s);B 失败三分(配置级致命 / 环境级不可用 / 行级拒绝)+ 60s 冷却降级取代永久判死;C 降级可见(共用 `TelemetryStatusTracker` + 节流复述 + 只读快照 `TelemetryStatus`,走**独立**端口 `TelemetryStatusProvider`);D 资源所有权纪律统一(谁建的谁关)。
|
||||||
|
- **地基是一条实测**: asyncpg `pool.py:457` 的 `if self._minsize:` ——`min_size=0` 时建池**零成本、不触库**(实测 0.000s、指向不可达端口照样成功)。这一步把"建池失败"从"混着瞬时错误的一刀切判死"变回真正的确定性失败,于是 issue 提的三个方向里,**方向 3(退避重试)大部分不必新建机制**(连接失败自动落到 `acquire`,那里本来就是"丢一行、池自恢复"的正确行为),**方向 2(共享池)从刚需降级为可选的显式能力**(闲时占 0)。
|
||||||
|
- **判据是主要交付物(两句,经审查补全)**: ①**致命 = 失败原因完全在进程内部且不可变**,其余一切失败都可能被外部修好,故一律带冷却重试;②**行级 vs 环境级看失败与这一行的数据有没有关系**——只与本行数据有关(换一行可能成功)= 行级,与数据无关、每行都会同样失败 = 环境级。按此,致命档窄到只剩"DSN 本身不可解析";认证失败、库不存在、表建不出来、权限被收、表被迁走一律归环境级(修好即自动恢复)。分类按 **PG SQLSTATE**(切到具体码,非前两位整类)而非 asyncpg 异常类白名单,不随驱动版本漂移。
|
||||||
|
- **`postgres.py:104-105` 的注释与代码不一致才是病灶**: 注释写"池建不出来 = 确定写不进去",这在 `min_size=10` 下是假的(连接耗尽只是这一秒写不进去)。冷却重试正面回应了该注释真正的顾虑("每次调用都内联吞一次 connect 超时"): 最坏成本变成"每 60s 一次、上界 5s"。
|
||||||
|
- **D 组是范围扩展,理由是同一根因的另外三个表现**: `GatewayClient.aclose` 关掉**注入的** telemetry(共享 recorder 被第一个关闭的 client 弄死,三处复制)、`RedisCache.aclose` 关掉注入的 redis 客户端、自建的 limiter/breaker redis 客户端**从来没人关**(泄漏)。不修它,ARCH §7.7 R5 的"共享必须显式注入"这条正道就一直是坏的——缺陷的放大器长在架构里,不在某个默认值里。纪律推广自库内已有的正确先例 `RedisLimiter._owns_client`。
|
||||||
|
- **被否决备选**: 只调默认值(判据错位仍在,下次 PG 重启照样永久失能);只加建池退避(`min_size=0` 后建池已无可重试的失败);隐式全局池注册表(违反"无全局状态、无模块级单例"铁律);暴露 `min_size`(唯一作用是把脆点装回来);**遥测改异步队列 + 后台 flush**(真正彻底消除"遥测拖慢业务",但引入进程崩溃丢数窗口,与遥测作为**审计证据**的定位正面冲突,见 [[design:issue12-telemetry-retention]] 决策 E-a);把遥测失败塞进 `errors.py` 四分类(那套语义是"决定重试/换源/熔断",遥测不冒泡也不参与,塞进去污染分类)。
|
||||||
|
- **SQLite 侧有意只做一半**: 补可见性(今天初始化失败后写入连 warning 都没有),**不做** lazy 化与冷却。它的失败模式(本地目录不可写)在装配期就暴露,不是"跑到一半悄悄断",永久降级语义基本正确;tracker 与快照两侧共用,不产生第二套概念。与 [[design:issue9-telemetry-ddl-probe]] 的"两侧有意不对称"同一先例。
|
||||||
|
- **发布**: 版号发布时由人类定(semver 指向 1.3.0,但项目既有口径偏 patch: issue #11 扩端口列落 1.2.1、issue #14 设计写 1.3.0 实际发成 1.2.4)。两处需"请先读这一条"待遇: 遥测常驻连接从 `10 × client 数` 变按需(监控曲线会突变);`aclose` 不再关闭注入的组件(修正越权,但依赖过"注入后由 client 代关"的下游会漏关)。
|
||||||
|
|
||||||
|
- **审查留痕(Codex,2026-08-24)**: 报 3 阻断 + 3 应改 + 1 可选,核实后 6 条采纳、1 条改判为实现约束。三条最重的都是同一类错误——**承诺比实现能给的更强**: ① "写入墙钟上界 = 一个预算"不成立,`async with pool.acquire()` 的释放路径是 shielded 且复用 acquire 的 timeout(`pool.py:886-889, 930-937`),真实上界 ≈ 2 × 预算;② `Pool.close()` 等 in-flight 释放会**无限挂**,60s 只 warning(`pool.py:939-948, 961-972`),"关了就是关了"必须自己限时 + `terminate()`;③ 原稿"`TelemetryRecorder` 加 `health` 属性零成本"只覆盖静态类型,漏了它是 `@runtime_checkable`(`ports.py:246`)——加属性会让只实现 `record_llm_call` 的对象**当场不再满足协议**,库内 `tests/unit/test_ports.py:137,141` 的 isinstance 断言会红。
|
||||||
|
- **审查还逼出判据本身的自相矛盾**: 原稿只有"致命 = 进程内不可变"一句,却把 SQLSTATE `42` 整类归了行级——而 42501(权限被收)、42P01(表被迁走)恰恰是"能被外部修好"的。补出第二句判据(**行级 vs 环境级看失败与这一行的数据有没有关系**),两者改判环境级,`42703` 缺列成为唯一具名例外(它由 [[design:issue13-schema-mode]] 的"缺列须逐行暴露"承诺定死)。
|
||||||
|
- **自查另补两条 Codex 未发现的**: `health` 一词在 `ports.py` 已被占用两次(`check_health` 源探活、`health(source_name) -> float` 成功率 EWMA),故快照改名 `TelemetryStatus`(P2 领域术语);`asyncio.timeout` 是 3.11 新增而 `requires-python = ">=3.11"`,3.11.0/3.11.1 的 `uncancel` 有已知缺陷,实施时须在"抬最低版本"与"改用 `wait_for`"之间选一。
|
||||||
|
- **最低 Python 提到 3.12(人类决策,2026-08-24)**: 顺带消解了原 §6 那条取舍(`asyncio.timeout` 是 3.11 新增、3.11.0/3.11.1 的 `uncancel` 有缺陷),现在可直接用、不必退回 `wait_for`。代价有两项且**顺序不可颠倒**: conda 环境 `PolyGateway` 当前是 3.11.15,须先重建;ruff `target-version = "py312"` 立刻启用 UP047,`gather_bounded`/`_anext_within`/`stream_with_liveness_timeouts` 三处要改 PEP 695 语法,而该语法在 3.11 是 **SyntaxError**——只能在 3.12 环境就位之后改。版号因此确定 **1.3.0 起步**: 缩小支持面(3.11 下游 `pip install` 会被 pip 直接拒绝)比新增能力更该进 minor。
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:reasoning-effort
|
||||||
|
title: "推理档位一等化设计(issue #20 及其一般形式)"
|
||||||
|
date: 2026-09-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理档位一等化设计(issue #20 及其一般形式)
|
||||||
|
|
||||||
|
正文: `2026-09-04-reasoning-effort-design.md`。状态: **2026-09-04 人类已批准**。
|
||||||
|
|
||||||
|
- **选定方案**: 方案 B「能力表档位化 + 源级/请求级双入口」。`Effort` 八档封闭枚举(含 `auto`)入 `types.py`;`ThinkingCapability` 由 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`(「能不能关」= `none` 在不在列表里);`ProviderProfile` 的两个固定片段改为 `ThinkingWire(off / on_base / effort_key)`;生效档位 = 请求级 > 源级 > `enable_thinking` 语法糖。
|
||||||
|
- **触发与真实缺口**: issue #20 字面要一条 zhipu profile,但补它不能解决它自己描述的失败——GLM-5.3 官方强制推理(智谱文档、cherry、OpenRouter 三源一致),`none` 是我们发出去的**未定义值**;而 `medium`(现 minimax profile 硬编码的档)在 GLM/kimi/deepseek 上根本不存在。缺口是**类型**表达不了现实,不是表里少一行。
|
||||||
|
- **人类三项拍板(2026-09-04)**: 作用域「源级默认 + 请求级覆盖」;档位打空时「默认报错 + 可显式开 nearest 映射」;不可关闭时「报错并给出该模型最低档作为可执行替代」。复核 Codex 异议后追加确认: `effort_fallback` 随本期实现,不因当前无消费者而推迟。
|
||||||
|
- **被否决备选及理由**: **方案 A 最小补丁**(只补 zhipu profile、维持 bool)——`thinking_on` 填什么档都是错的,`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」间二选一,治标;**方案 C 照抄 cherry 完整 wire DSL**(closed operations、`effortMap`、`budgetWire`、endpoint-keyed contract)——它需要那层是因为要支持四种端点协议,而本库只有一个 OpenAI 兼容 transport,跨协议转换由 new-api 服务端完成,该复杂度当前无消费者(P1 YAGNI);**`default_effort` 字段**——自审把 `enable_thinking=True` 的语法糖改成 `Effort.AUTO` 后失去唯一消费方,厂商默认档降为 `evidence` 文本;**档位与 `reasoning_tokens` 的运行期对账**——无可判定的函数关系(实测同档 rt 在 8~56 间跳),报警必成噪声;**代下游裁剪采样参数**(DeepSeek 思考模式不支持 `temperature`)——那是「默认值掩盖错误」,记入 evidence 而不写进逻辑。
|
||||||
|
- **审查留痕**: Claude 自审揪出两处实质缺陷(词汇缺 `auto`,导致 9 个纯开关型模型无档可填、等于把要修的 bug 重新实现一遍;五关顺序错置,使「关不掉」的特殊文案被通用分支吞掉)。Codex 独立审推翻两条**错误断言**: ① 源级 `extra_body`/`enable_thinking` **早已**经 `build_model_fingerprint` 进缓存 key(ARCH §7.5 有明文),不存在先前稿本断言的「现存毒化缺口」;② 「三项目无调用点」是对**不存在的目录**做 grep 得到的空结果,`reference/` 下当前并无三项目,迁移安全性只能是推断。另采纳其三条: 删 `default_effort`、补能力表落库规则、列出库内真实会断的调用点(`tests/unit/test_thinking.py:128` 的位置参数构造)。
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
> **定位**: 用户文档站 = Gitea Wiki(`https://gitea.iomgaa.online/iomgaa/PolyGateway/wiki`);本文规定它的结构、更新时机与写作纪律。研发知识(设计/决策/验收)仍归 `research-wiki/`,两者职责不重叠。
|
> **定位**: 用户文档站 = Gitea Wiki(`https://gitea.iomgaa.online/iomgaa/PolyGateway/wiki`);本文规定它的结构、更新时机与写作纪律。研发知识(设计/决策/验收)仍归 `research-wiki/`,两者职责不重叠。
|
||||||
|
|
||||||
## 1. 结构:Diátaxis 四区(2026-07-23 建站,17 页)
|
> [!CRITICAL]
|
||||||
|
> **现状(2026-08-02 起):文档站已全量下线,当前只剩 `Home` 一页占位。** 八轮审查累计确认 93 处与源码不一致,近半落在参考区(手工镜像源码里已有的事实,必然漂移),且修正本身在引入次生偏差,逐轮修补不收敛——过期文档比没有文档更危险,它看起来权威。
|
||||||
|
> Home 页现在做的唯一一件事是**把下游指向真实事实源**:签名/字段/参数语义 → 源码 docstring;全量环境变量键 → `.env.example`;版本变更与下游注意事项 → `CHANGELOG.md`;架构决策与行为论证 → `research-wiki/ARCHITECTURE.md`;快速上手 → `README.md`。
|
||||||
|
> 历史内容未丢失,全在 wiki 仓库的 git 历史里(`git checkout e78bfb9 -- .`)。**本文以下各节描述的是重建时的目标结构与纪律,不是当前站点的现状**;在文档站重建之前,下面凡指向具体 wiki 页面的条目一律**不可执行**。
|
||||||
|
|
||||||
|
## 1. 结构:Diátaxis 四区(重建目标;2026-07-23 建站 17 页,2026-08-02 全量下线)
|
||||||
|
|
||||||
| 区 | 页面 | 职责(读者此刻要干什么) | 禁止 |
|
| 区 | 页面 | 职责(读者此刻要干什么) | 禁止 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -25,6 +30,8 @@
|
|||||||
|
|
||||||
**门**: 版本 bump 的提交不允许单独存在——同一次交付里必须包含对应的 wiki/CHANGELOG 同步(发布检查清单第一项)。
|
**门**: 版本 bump 的提交不允许单独存在——同一次交付里必须包含对应的 wiki/CHANGELOG 同步(发布检查清单第一项)。
|
||||||
|
|
||||||
|
**站点下线期间(2026-08-02 至文档站重建)本表如何执行**: 上表左列的判据照旧,右列中指向具体 wiki 页面的项**全部落空,不必也无法执行**;仍然必须做的是 `CHANGELOG.md`、`README.md`、`.env.example` 与 `research-wiki/ARCHITECTURE.md` 四处。这道门因此**没有放松**——只是承接方从 wiki 换成了这四个文件,漏改它们与从前漏改 wiki 是同一性质的失败。
|
||||||
|
|
||||||
## 3. 写作纪律
|
## 3. 写作纪律
|
||||||
|
|
||||||
- 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。
|
- 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-08-25-thinking-observability-regression
|
||||||
|
title: "issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号"
|
||||||
|
date: 2026-08-25
|
||||||
|
---
|
||||||
|
|
||||||
|
# issue #16/#17 实测:M3 推理正常,失效的是推理的**可观测信号**
|
||||||
|
|
||||||
|
> 类型:finding|日期:2026-08-25|网关 `newapi.iomgaa.online`
|
||||||
|
> 本文推翻 issue #16/#17 的原始诊断("模型不再推理"),是 `designs/2026-08-25-thinking-observability-design.md` 的事实基础。
|
||||||
|
|
||||||
|
## 1. 为什么要重测
|
||||||
|
|
||||||
|
issue #16/#17 判定 MiniMax-M3 的开启推理"静默失效:模型没有推理",依据是 `tests/e2e/test_thinking_live.py` 的 L2/L3b/L4/L5 四条全红,四条的共同判据是 `reasoning_tokens > 0`。issue 自己留了一个未区分的岔路:网关侧模型行为变了,还是库的注入失效了。区分方法写得很清楚——抓一次真实请求体与原始响应。本文就是那次抓取。
|
||||||
|
|
||||||
|
## 2. 方法
|
||||||
|
|
||||||
|
两层探针,都不走 slow 套件:
|
||||||
|
|
||||||
|
其一**绕开库**,用裸 `httpx` 直接 POST `/chat/completions`,矩阵化七种参数形态 × 流式/非流式,记录完整 `usage` 与 `message` 的键集合。绕开库是必要的——要证的命题之一正是"库有没有把参数弄丢",用库测这一条是循环论证。
|
||||||
|
|
||||||
|
其二**用库本身**跑 `GatewayClient.chat`,记录 `LLMResponse` 的 `reasoning_tokens` 与 `thinking` 两个字段。两层对照才能定位缺口落在哪一层。
|
||||||
|
|
||||||
|
对照组取 `qwen3.7-plus` 与 `deepseek-v4-pro`——同一网关、同一 key,用来区分"MiniMax 这一路变了"与"网关全局变了"。
|
||||||
|
|
||||||
|
## 3. 原始观测
|
||||||
|
|
||||||
|
### 3.1 MiniMax-M3,裸 httpx,非流式
|
||||||
|
|
||||||
|
| 变体 | prompt | completion | `completion_tokens_details` | `reasoning_content` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 不注入(基线) | 194 | 3 | **整个容器缺失** | 无 |
|
||||||
|
| `reasoning_effort=medium` | **216** | **48** | 整个容器缺失 | 无 |
|
||||||
|
| `reasoning_effort=high` | **216** | **65** | 整个容器缺失 | 无 |
|
||||||
|
| `reasoning_effort=none` | 194 | 3 | 整个容器缺失 | 无 |
|
||||||
|
| `thinking={"type":"enabled"}` | 194 | 3 | 整个容器缺失 | 无 |
|
||||||
|
| `enable_thinking=true` | 194 | 3 | 整个容器缺失 | 无 |
|
||||||
|
| 非法值 `definitely-not-a-real-level` | 207 | 87 | 整个容器缺失 | 无 |
|
||||||
|
|
||||||
|
### 3.2 MiniMax-M3,裸 httpx,流式
|
||||||
|
|
||||||
|
| 变体 | delta 的键集合 | `reasoning_content` 累计 | usage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 不注入 | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
|
||||||
|
| `reasoning_effort=medium` | `content`,**`reasoning_content`**,`role` | **124 字符,完整推理过程** | prompt 216 / completion 60,无 ctd |
|
||||||
|
| `reasoning_effort=none` | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
|
||||||
|
|
||||||
|
流式 medium 档抓到的推理正文(前 120 字符):`We need answer Chinese, only two digits. Chickens x rabbits y. x+y=35,2x+4y=94 => x+y*? 2*35+2y=94 y=12, x=23. Output 23`
|
||||||
|
|
||||||
|
### 3.3 对照组(流式)
|
||||||
|
|
||||||
|
| 模型 | 变体 | `reasoning_content` | `completion_tokens_details.reasoning_tokens` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| deepseek-v4-pro | 不注入 | 135 字符 | **88** |
|
||||||
|
| deepseek-v4-pro | `effort=medium` | 134 字符 | **89** |
|
||||||
|
| deepseek-v4-pro | `effort=none` | 0 | 容器缺失 |
|
||||||
|
| qwen3.7-plus | 不注入 | 350 字符 | **158** |
|
||||||
|
| qwen3.7-plus | `effort=medium` | 606 字符 | **229** |
|
||||||
|
| qwen3.7-plus | `effort=none` | 0 | 容器缺失 |
|
||||||
|
| qwen3.7-plus | 非法值 | — | **HTTP 400** |
|
||||||
|
|
||||||
|
### 3.4 用库跑(`LLMResponse` 字段)
|
||||||
|
|
||||||
|
| 场景 | `reasoning_tokens` | `thinking` 字符数 | completion |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M3 开启,流式 | None | **185** | 69 |
|
||||||
|
| M3 开启,非流式 | None | **0** | 53 |
|
||||||
|
| M3 关闭,流式/非流式 | None | 0 | 3 |
|
||||||
|
| M3 不干预 | None | 0 | 3 |
|
||||||
|
| qwen 开启,流式 | **205** | 484 | 213 |
|
||||||
|
| qwen 关闭,流式 | None | 0 | 5 |
|
||||||
|
|
||||||
|
## 4. 五条结论
|
||||||
|
|
||||||
|
**① M3 的推理完全正常,issue 的诊断是错的。** 流式 medium 档抓到 124 字符完整推理过程;`prompt_tokens` 194→216(供应商注入推理指令)、`completion_tokens` 3→60(推理段被计费)。三个独立信号一致。
|
||||||
|
|
||||||
|
**② 真正变的是 MiniMax 这一路不再返回 `usage.completion_tokens_details`。** 而 qwen 与 deepseek 在同一网关同一 key 上照常返回。所以这不是网关全局改了 usage 处理,是 MiniMax 这一路上游的 usage 形态变了。`reasoning_tokens` 恒 NULL 由此而来。
|
||||||
|
|
||||||
|
**③ 库自己已经握有决定性证据,却没有用。** `LLMResponse.thinking` 在 M3 开启档流式路径下是 185 字符的实打实推理正文。e2e 的 `_reasoning_on` 只看 `reasoning_tokens` 与 `completion_tokens` 长度,从不看 `thinking`——四条红是判据的盲区,不是功能的失效。
|
||||||
|
|
||||||
|
**④ M3 非流式路径下推理内容整体丢失,且下游在付费。** `completion_tokens` 53 vs 关闭档 3,说明推理段确实产生并计费;而 `message` 的键集合只有 `content`/`role`,`reasoning_content` 不存在。下游用非流式调 M3 开推理 = 付钱买看不见的东西,且当前库不告诉它。这不是库能修的(上游不返回),但库必须让它可见。
|
||||||
|
|
||||||
|
**⑤ 三家供应商在"未推理"时都是整个 `completion_tokens_details` 缺失,无人上报 `0`。** 与 2026-08-02 findings §4c 的记录一致。推论:**"容器在不在"不能当作"有没有推理"的判据**——它与真实信号高度混淆,拿它做裁定等于把噪声当信号。
|
||||||
|
|
||||||
|
## 5. 顺带纠正的两处既有认识
|
||||||
|
|
||||||
|
**`enable_thinking` / `thinking:{type:enabled}` 对 M3 无效这一条仍然成立**(prompt 恒 194 = 基线),只有 `reasoning_effort` 是真开关。`providers.py` 的 minimax profile 用的正是 `reasoning_effort`,选型至今正确。
|
||||||
|
|
||||||
|
**L3b 的"非法值反证"手法只对不校验值的 provider 成立。** minimax 对非法 `reasoning_effort` 返回 200 且照常推理(prompt 207,介于基线 194 与 medium 216 之间,说明走了第三条模板路径);qwen 对同样的非法值直接 **HTTP 400**。这条手法写进测试时只在 minimax 上验过,它不可移植——若哪天把 L3b 套到别的 provider 上会得到假红。
|
||||||
|
|
||||||
|
## 6. `can_disable` 复测
|
||||||
|
|
||||||
|
M3 的 `ThinkingCapability(can_disable=True)` 的 evidence 停在 2026-08-02。2026-08-25 复测:`reasoning_effort=none` → prompt 194(= 基线)、completion 3、无 `reasoning_content`。**声明依然成立**,只需刷新 evidence 日期并补记本文新发现的两条限制(非流式不可观测、仅 `reasoning_effort` 有效)。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-08-26-issue18-shared-pg-test-isolation
|
||||||
|
title: "issue #18 实测: 偶发红的是安全网本身,不是被测脚本"
|
||||||
|
date: 2026-08-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# issue #18 实测:偶发红的是**安全网本身**,不是被测脚本
|
||||||
|
|
||||||
|
> 类型:finding|日期:2026-08-26|实例 `polygateway` 库(PostgreSQL 16.14,共享)
|
||||||
|
> 本文是 `designs/2026-08-26-issue18-pg-test-isolation-design.md` 的事实基础。
|
||||||
|
> 实测与推断在 §5 明确分界——推断部分未做复现实验,不当作既定事实使用。
|
||||||
|
|
||||||
|
## 1. 失败断言的唯一归属
|
||||||
|
|
||||||
|
`assert 12 == 61` 只能对应 `test_retention_tool_pg.py::TestPlainTableBatches::test_apply_deletes_only_expired_rows_in_batches` 的最后一行:
|
||||||
|
|
||||||
|
| 断言 | 形态 |
|
||||||
|
|---|---|
|
||||||
|
| `_call_ids(schema_dsn) == ["fresh-1", "fresh-2"]` | 列表比较,失败会打印列表 |
|
||||||
|
| `"将删除行数: 5" in result.stdout` 等五条 | 子串判定,失败不打印数字对 |
|
||||||
|
| `await _public_count(dsn) == before_public` | **整型比较,唯一能报出 `12 == 61`** |
|
||||||
|
|
||||||
|
`before_public` 在 seed 之前取,`12` 是脚本跑完后的复测值。
|
||||||
|
|
||||||
|
## 2. 被测脚本没有越界
|
||||||
|
|
||||||
|
失败发生在最后一条,意味着它前面全部通过:`_call_ids(schema_dsn)` 恰为 `["fresh-1","fresh-2"]`(临时 schema 里 5 行过期行被删、2 行新鲜行留下)、stdout 里出现 `<临时schema>.llm_calls`、`将删除行数: 5`、三条批次行齐全。
|
||||||
|
|
||||||
|
若 `search_path` 曾失效、脚本打到了 `public.llm_calls`,那么临时表 7 行一行不少,第二条断言就会先红。**故本次失败与 `telemetry_retention.py` 的行为无关**。
|
||||||
|
|
||||||
|
## 3. 共享表的实测现状
|
||||||
|
|
||||||
|
以 `.env` 的 `PGW_TELEMETRY_PG_DSN` 直连查得(2026-08-26):
|
||||||
|
|
||||||
|
| 项 | 实测值 |
|
||||||
|
|---|---|
|
||||||
|
| `public.llm_calls` 行数 | **11**,非分区普通表 |
|
||||||
|
| 这 11 行的 `created_at` | 全部落在 `2026-07-22 14:00 ~ 14:26` |
|
||||||
|
| 这 11 行的 `call_id` 形态 | 裸 hex 前缀(`3c915c04`、`c8071b6a` …)与一个 `c1`,**不是** `pgwtest-` 前缀 |
|
||||||
|
| 表属主 / ACL | `app` / `{app=arwdDxt/app, chs3_test=ar/app}`(无 PUBLIC 授权) |
|
||||||
|
| `.env` 里那个角色 | `app`,`rolsuper = true`、`rolcreatedb = true`、`rolcreaterole = true` |
|
||||||
|
| 服务端版本 / 连接 | PostgreSQL 16.14;`max_connections = 100`,查时 54 个连接在用 |
|
||||||
|
| 残留临时 schema / 角色 | 无(`pgw%` 命名下均为空) |
|
||||||
|
|
||||||
|
失败时的 `12` 与这个 `11` 行基线同量级;`61` 意味着取快照那一刻库里另有约 49 行,随后消失。那 11 行是一个多月前留下的**孤儿行**:它们早于 7 天截止线,任何一次带 `--apply` 的存量清理都会删掉它们——这本身说明真实共享表上确实存在"测试/工具写完没清干净"的历史。
|
||||||
|
|
||||||
|
## 4. 本仓库自己就是共享表的写入方
|
||||||
|
|
||||||
|
`tests/integration/test_postgres_telemetry.py` 存在两套并行的隔离手法:
|
||||||
|
|
||||||
|
| 手法 | 用在哪 | 是否触碰 `public.llm_calls` |
|
||||||
|
|---|---|---|
|
||||||
|
| 临时 schema(`legacy_schema`、`fresh_schema`、`pre_tenant_schema`、`partitioned_schema`、`least_privilege_dsn`、`least_privilege_pre_tenant_dsn`、`production_template`) | 需要特定表形态的用例 | 否,teardown 走 `DROP SCHEMA CASCADE` |
|
||||||
|
| `_RUN_PREFIX` 前缀(模块级 `pgwtest-<uuid8>`) | `TestObservabilityColumns::test_values_round_trip`、`TestSchema` 三条、`TestDegradation` 两条、`TestPoolFootprint` 一条,**共 7 条** | **是**,写入真表,`dsn` fixture teardown 执行 `DELETE ... WHERE call_id LIKE '<前缀>-%'` |
|
||||||
|
|
||||||
|
前缀隔离对**读**是完备的(每个进程只看自己的行),对**全表口径的观测**不设防——而 `_public_count` 正是全套件里唯一一处全表口径。
|
||||||
|
|
||||||
|
## 5. 实测与推断的分界
|
||||||
|
|
||||||
|
**实测(本会话工具输出)**:§1 的断言归属、§2 的失败顺序推理、§3 的全部数字、§4 的用例清单。
|
||||||
|
|
||||||
|
**推断(未做复现实验)**:那 49 行的来源。同一 pytest 进程内 `test_postgres_telemetry.py` 排在 `test_retention_tool_pg.py` 之前(文件名序),且其 `dsn` fixture 是函数级、每条用例后立即清理,故同进程解释不成立;最合理的解释是**另一个进程**在同一秒窗口内完成了一轮"写 7 条 → teardown 删掉"的循环——并行的另一个开发会话,或 `~/Projects/m4-worktrees/` 下迁移项目的批跑(三个迁移项目正是用本库往这张表写遥测)。
|
||||||
|
|
||||||
|
这条推断不影响结论:无论那 49 行由谁写删,`public.llm_calls` 的行数都是**不归本测试控制的全局可变量**,把它当断言基线在设计上就不成立。
|
||||||
|
|
||||||
|
## 6. 与 `_public_count` 的设计意图的落差
|
||||||
|
|
||||||
|
该断言的注释写明它要防的是"`search_path` 没生效导致静默删库"。行数快照防不住这件事:
|
||||||
|
|
||||||
|
- **假红**:任何外部写/删都让它红(本次即是),而脚本完全正常
|
||||||
|
- **假阴**:外部并发的增减可以与脚本的误删互相抵消,行数相等则静默放行——它守的是删库,这一半失效才是真正的代价
|
||||||
|
|
||||||
|
一个安全属性被编码成对全局可变量的观测,两个方向都不成立。
|
||||||
|
|
||||||
|
## 7. 方案可行性的实测(2026-08-26,同一实例)
|
||||||
|
|
||||||
|
用一次性角色/schema 做的证伪实验(建 `pgwprobe_r_*` 角色 + `pgwprobe_s_*` schema,跑完全部 `DROP`,实例上无残留):
|
||||||
|
|
||||||
|
| # | 探针 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 角色以自己身份建表 | 属主为该角色(与"用维护角色跑"的现场一致) |
|
||||||
|
| 2 | `to_regclass('"<schema>"."llm_calls"')` | 正常解析到该表 |
|
||||||
|
| 3 | `to_regclass('"nosuch_schema_xyz"."llm_calls"')` | **返回 NULL,不抛错** |
|
||||||
|
| 4 | `to_regclass('"<SCHEMA 大写>"."llm_calls"')` | **返回 NULL** —— 引号限定名区分大小写 |
|
||||||
|
| 5 | 临时角色**裸连**(不挂 search_path) | `SHOW search_path` = `"$user", public`,`to_regclass('llm_calls')` 命中真表 |
|
||||||
|
| 6 | 裸连对真表 `SELECT COUNT(*)` | `InsufficientPrivilegeError: permission denied for table llm_calls` |
|
||||||
|
| 7 | 裸连对真表 `DELETE ... WHERE created_at < now()` | `InsufficientPrivilegeError: permission denied for table llm_calls` |
|
||||||
|
| 8 | 角色名与 schema **同名**时裸连 | `"$user"` 命中自有 schema,**遮蔽 public** |
|
||||||
|
|
||||||
|
第 6、7 条是新方案的核心防线:最坏情况下脚本连数都数不出来,更谈不上删。第 8 条是一条必须写进设计的约束——今天 `least_privilege_dsn` 的角色与 schema 恰好同名,若沿用该形态,"search_path 落到 public"的最坏情况用例会走到自有 schema 上,测出来的是个假现场。
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-09-09-134-thinking-contracts-validation
|
||||||
|
title: "1.3.4 推理契约验证与发布准备"
|
||||||
|
date: 2026-09-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.4 推理契约验证与发布准备
|
||||||
|
|
||||||
|
> 最新状态(2026-09-09):**1.3.4 已发布并完成外部验收,#21/#25/#26 已评论关闭**。main/tag 指向 `af57f93adce24b43fd10b6d8e1281ab8ee43c0a8`;合并后门、下载独立安装及页面结果见文末。用户批准的未补全矩阵、FAIL/UNKNOWN/不可达/缺轮及缺下游配置例外保持原结论,不冒充 PASS。以下为分阶段历史,不追改当时结论;原始输出在 `tests/outputs/134/`,不提交。
|
||||||
|
|
||||||
|
## 基线与修改边界
|
||||||
|
|
||||||
|
| 项目 | 实际证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 起点 | `6a09054`,保留既有两个本地测试提交,工作区仅原 `.pi/` 与待提交设计/计划 |
|
||||||
|
| T0 静态 | `t0-check.log`/`.exit`:make check,退出 0,import-linter 1 kept |
|
||||||
|
| T0 指定测试 | `t0-baseline.log`/`.exit`:660 passed,退出 0 |
|
||||||
|
| 生产范围 | 只修改 thinking/providers/client/openai_compat 四文件;端口、类型、缓存指纹、遥测 schema、embedding/OCR 循环未改 |
|
||||||
|
| 文档回滚 | `2553fc7`/`dda5556`;wiki 工具 add_entity 会覆盖无 frontmatter 的同名文件,故先补原文 frontmatter,再以节点存在性保护调用工具,显式登记图节点/implements 边 |
|
||||||
|
|
||||||
|
## 红绿证据
|
||||||
|
|
||||||
|
| 任务 | 红证据 | 绿证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T1 AUTO 成员/MiniMax/未知告警 | `t1-red.log`:9 failed,103 passed;都是未拒绝/旧 medium/缺不保证文案 | `t1-green.log`:112 passed |
|
||||||
|
| T1 可执行迁移文案 | `t1-guidance-red.log`:1 failed,旧错误无配置例 | `t13-followup-green.log`:220 passed(含探针收尾) |
|
||||||
|
| T2 on_base 不偷带档 | `t2-wire-red.log`:12 failed,旧解析接受非法开启片段 | `t2-green.log`:137 passed |
|
||||||
|
| T2 raw 纯守卫 | 隔离 `raw-guard` 变异:28 failed,含嵌套根与所有档位 | 还原退出 0;详见 mutation-summary.json |
|
||||||
|
| T3 标准 raw 实际 HTTP | `t3-raw-red-valid.log`:21 failed,旧 transport 发出了冲突请求 | `t3-green.log`:526 passed(工厂/配置/transport/retry/纯解析) |
|
||||||
|
| T3 前置/准入 | `t3-entry-red.log`:4 failed,旧工厂构造后端/请求进入洋葱/冲突未拒绝 | 同上;额外半开探针测试证明可再次取得探针且 inflight=0 |
|
||||||
|
| T3 自定义根 | 隔离 `custom-guard` 变异:3 failed,丢 wire 后私有根绕过 | `t3-custom-green.log`:3 passed |
|
||||||
|
| T4 迁移 | `cache-isolation` 变异:去掉显式身份后能力/fallback/wire 各节点红 | `t4-final-green.log`:190 passed(缓存与遥测);新旧身份回滚、租户、多源和 per-call 覆盖均有断言 |
|
||||||
|
| T4 命中遥测 | `cache-row` 变异:1 failed,历史 low 不应替代本次 medium | 还原退出 0 |
|
||||||
|
| T7 客户端 NULL | embed False→True:6 failed;OCR False→True:12 failed(text/layout 独立红) | `t7-final-green.log`:328 passed |
|
||||||
|
| T7 阳性/emitter | chat True→False:并发实际档断言失败;去 applies 短路:6 failed | 各还原退出 0;chat 糖失败 auto、nearest 失败 medium/成功 low 另有真实链路断言 |
|
||||||
|
|
||||||
|
隔离副本来源由 `mutation-import.log` 验证,路径为 `/tmp/pgw134-mutation-*`;无 `.env`、reference、`.pi/`。脚本 `mutate.py`、汇总 `mutation-summary.json`、逐例 `mutation-*-red.log/.exit` 和 `mutation-*-restored.log/.exit` 均留存。11 个变异全部退出 1,逐例还原全部退出 0,恢复后校验文件散列。factory 三个变异分别抓到 Authorization 缺失、timeout 退回 5 秒、trust_env 写死 True。
|
||||||
|
|
||||||
|
## 调试记录(不把无效红当证据)
|
||||||
|
|
||||||
|
| 现象 | 根因与处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 初始 raw 红测试反而 21 passed | 测试选 qwen 纯开关形态却请求 HIGH,旧实现先因形态拒绝;改用可表达 HIGH 的 openai 后全部因目标未拒绝而红。`t3-raw-red.log` 不计红证据,使用 `t3-raw-red-valid.log` |
|
||||||
|
| 默认 HTTP factory 新测试 5 failed | 开发机 `socks://` 代理被 httpx 构造拒绝;测试 autouse 删除代理环境,仅隔离外部条件,不改生产 factory,仍分别验证 trust_env True/False。`t3-isolated-green.log`:207 passed |
|
||||||
|
| factory 失败随 T3 提交进入历史 | `8e61a66` 当时附带上述未隔离测试;随后 `71f1bdf` 独立修复。最终工作区全绿;不声称每个历史提交均全绿 |
|
||||||
|
| per-call 迁移工厂测试缺缓存参数 | 合成 env 仍为 cache_backend=none,loader 正确清空 namespace/ttl;改为 memory+显式 TTL,新测试 9 passed,不改生产配置默认 |
|
||||||
|
| conda run 默认捕获模式下 stdin 脚本未执行 | T0 第一次文档提交只有原始两文件;通过 --no-capture-output 重跑安全登记并单独提交,未将第一次零输出当登记成功 |
|
||||||
|
| pi-lens LSP 报缺 pytest/loguru、旧 StrEnum Literal 噪音、Python 3.12 语法不支持 | 非 conda 解释器限制;父监督明确批准记录并继续既定 conda pytest/ruff/import-linter,不改枚举/不加 ignore。后续异步 stale 测试报告已标 superseded,最终实际全量单测输出为准 |
|
||||||
|
|
||||||
|
## 当前检查与后续门
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | `last-unit.log/.exit`:**1241 passed,3.95 秒,退出 0** |
|
||||||
|
| `make check` | `last-check.log/.exit`:格式/ruff/import-linter通过,退出 0 |
|
||||||
|
| `git diff --check` | 通过 |
|
||||||
|
| 本轮网络/slow | 未执行;所有 HTTP 为 MockTransport,SQLite 为临时文件,无付费调用 |
|
||||||
|
| 独立 verifier/集成/slow/下游迁移 | 由父会话后续执行,本轮不声明通过;设计所列真实缺测和下游缺失仍有效 |
|
||||||
|
|
||||||
|
日志方案沿已批设计:未知能力沿既有 loguru warning,实际调用仍经 TelemetryEmitter 单点出口,四类行来源和 NULL 契约用既有 schema 验证,不新增运行时数据面。
|
||||||
|
|
||||||
|
## T5/T6 与 T8 文档续作(起点 16fa0ca)
|
||||||
|
|
||||||
|
本续作禁止发布/slow/付费调用,未改任何生产文件。已读完整批准设计、计划及 TDD/structured-logging/commit 技能。独立验证与全量集成/live 仍由父会话负责,本节不表示整个版本验收完成。
|
||||||
|
|
||||||
|
| 门/节点 | 本会话实际结果/原始日志(tests/outputs/134/) |
|
||||||
|
| --- | --- |
|
||||||
|
| 受影响基线 | `t56-baseline.log`:client/config/openai_compat **338 passed** |
|
||||||
|
| T5 新模块首次 | `t5-first.log`:98 passed;首次无行为红不计TDD,红证据来自下述隔离变异 |
|
||||||
|
| 当前受影响 | `t56-current-diagnostics-proof.log`:client/live_evidence/config **325 passed**;旧异步2/131通知已被当前结果取代 |
|
||||||
|
| 日常全单元 | `t56-accepted-unit.log/.exit`:**1357 passed,exit 0**(其后仅取证关联/报告字段收尾,受影响325再通过,最终门见提交日志) |
|
||||||
|
| 静态 | `t56-accepted-check.log`:make check 通过;compileall 测试支持模块通过;生产43模块123依赖、1契约通过 |
|
||||||
|
| live 采集 | `t56-accepted-collect.log/.exit`:e2e **90 tests collected,exit 0**;仅采集,不是真实通过 |
|
||||||
|
|
||||||
|
### 隔离语义红→还原绿
|
||||||
|
|
||||||
|
仓库外临时副本只复制 src/tests/必要工程文件,不复制 `.env`、reference、`.pi`;PYTHONPATH及 cwd 指向副本,import来源见 `t56-mutation-import.log`/`t56-consumer-mutation-import.log`。每个变异均目标 AssertionError、退出1,恢复散列一致后节点退出0,不靠 import error 当红。
|
||||||
|
|
||||||
|
| 变异 | 目标节点(tests/unit/test_live_evidence.py) | 红/还原 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 整类 skip | test_whole_exception_class_skip_is_forbidden | 1/0 |
|
||||||
|
| 正文子串 model_not_found | test_incomplete_or_ambiguous_error_body_fails | 1/0 |
|
||||||
|
| UNKNOWN 安静→PASS | test_coverage_is_proposition_specific | 1/0 |
|
||||||
|
| 缺轮缩分母 | test_missing_round_never_reduces_denominator | 1/0 |
|
||||||
|
| 身份无证据→skip | test_identity_requires_independent_raw_evidence | 1/0 |
|
||||||
|
| 丢第一轮 | test_round_consumer_keeps_first_success_when_second_assertion_fails | 1/0 |
|
||||||
|
| 部分档未覆盖→模型PASS | test_partial_uncovered_and_failed_rounds_never_become_model_pass | 1/0 |
|
||||||
|
| 不交付独立raw快照 | test_raw_identity_snapshot_reaches_round_consumer | 1/0 |
|
||||||
|
|
||||||
|
汇总与逐例日志:`t56-mutation-summary.json`、`t56-consumer-mutation-summary.json`、`t56-mutation-*-{red,restored}.log`;脚本 `mutate-live.py`/`mutate-live-consumer.py`。主工作区从未放回假绿策略。
|
||||||
|
|
||||||
|
### 实现与矩阵边界
|
||||||
|
|
||||||
|
取证按 session/parent→attempt→HTTP;零HTTP和多HTTP分开,404严格唯一完整证据,成功非流式原始JSON独立解析并拒重复键。成功SSE不预读、不捕获。真实RetryMW两并发逻辑轮次各503→成功验证精确成功call_id;取消ContextVar复位、资源关闭与逐轮报告写失败显式失败均有离线节点。报告不保存任何原始正文/异常,白名单字段含校验布尔、状态、身份资格与固定安全原因;假凭据/提示词sentinel逐文件无泄漏。
|
||||||
|
|
||||||
|
四live文件均迁入窄通道,装配拒绝/平铺键/合成Protocol移入日常;外部Protocol缺包单列未覆盖。M3真实开启改medium,M2 AUTO复用既有T10档;L4迁为退出受管的raw-only高档,双来源本地拒绝由已实现单测守卫。L8不可关闭装配拒绝不计live,未知wire仍显式全None离线验证。UNKNOWN不靠completion长度或prompt锚点升格;L2b仅保留指定历史prompt锚点命题,不是关闭能力。
|
||||||
|
|
||||||
|
默认轮数/并发未增加,删除额外UNKNOWN长度锚点调用;T10保留既有一次重试设置,去除60s stall缩小值。静态矩阵:L1–L7合计93逻辑调用,L8可关闭19型号×5=95,T10 NONE 26×5=130及条件长复核≤78,开启57档×5=285,默认基线15,其他chat6+embed1=7;总上界703,不含既有治理重试/结构化重问。没有执行这些调用。
|
||||||
|
|
||||||
|
### 调试与未验证项
|
||||||
|
|
||||||
|
| 项 | 实际处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新RetryPolicy测试参数误写base_delay_s | 当前工具实报TypeError,查源码后改backoff_base_s/backoff_max_s,239及后续242/325通过;该失败不计目标红 |
|
||||||
|
| make check初报SIM117/B017 | 合并测试上下文,按真实解析异常指定类型,不加ignore;最终静态门通过 |
|
||||||
|
| pi-lens解释器/StrEnum噪音 | 记录 `t56-diagnostics.txt`,conda内真实导入与ruff为门,不改任务外枚举;pytest wrapper generator的return report是协议必需,独立next/send/StopIteration.value测试通过 |
|
||||||
|
| T10型号→400机器字段基线不存在 | 父会话明确确认:不编造白名单,实际400默认FAIL并逐轮留证。纯负向契约精确类型/状态/type单独测试;具体live预期拒绝未验证、需人工基线 |
|
||||||
|
| 结构化反馈重问(已被本次审查修复替代) | 原固定摘要会误拒正常反馈重问,独立审查判 P1;不再保留为可接受限制,修复与真实 StructuredMW 离线两响应证据见下节 |
|
||||||
|
| 发布/集成/live/下游 | 本任务未执行,M2空wire、M3非流式UNKNOWN、身份不足、三项目实际配置缺失仍保留为证据门 |
|
||||||
|
|
||||||
|
文档已同步README M1–M9、CHANGELOG未发布段、env注释、ARCH D11/5.1/7.5/7.8、旧设计替代指针及既有schema/metric;无版本bump、无新生产字段/DDL。Wiki站已下线,不虚报线上页更新。
|
||||||
|
|
||||||
|
续作提交:`73008ad test: apply evidence-based live checks without hiding regressions`。最终提交前实际门:`t56-precommit-unit.log/.exit` **1357 passed/0**,`t56-precommit-affected.log` **331 passed**(包含生产默认factory节点),`t56-precommit-check.log/.exit` **make check通过/0**,`t56-precommit-collect.log` **90 collected**,`t56-precommit-compile.log`通过;`git diff --check`通过,`git diff --quiet -- src`确认生产零差异。T8仅文档部分完成,不勾选完整验收门。
|
||||||
|
|
||||||
|
## 独立审查四项修复(起点 d332287)
|
||||||
|
|
||||||
|
按 receiving-code-review 对照实际调用链核验 `verify134/live-contracts.md`:四项均成立。此处沿用户限定仅更新既有 finding/plan,不新建图实体或扩写设计。生产/版本零差异;没有 live、付费请求或子代理。本节是实现者核验,不冒充新一轮独立复审。
|
||||||
|
|
||||||
|
| 审查项/核验依据 | 最小修复与守卫 |
|
||||||
|
| --- | --- |
|
||||||
|
| P1 结构化重问:StructuredMW._with_feedback 追加两消息,旧 hook 固定完整摘要必错 | 仅结构化模型 smoke 启用原提示词前缀摘要、成对 assistant/user 字符串与已有预算;首个 attempt 仍精确原消息。薄委托保存本次摘要只校验 HTTP 保真,不从 wire 反填预期,不关闭重问。真实 GatewayClient+StructuredMW+MockTransport 缺字段→合法响应恰两 HTTP PASS;破坏前缀、角色、内容类型、配对、预算、wire 均 FAIL;首轮凭空反馈另有 FAIL 守卫 |
|
||||||
|
| P1 未登记候选:旧 None 分支被置 cannot_disable,ABSENT 假失败 | 明确 observation-only,先全轮请求/身份资格,再 UNCOVERED;ABSENT/OBSERVED/UNKNOWN 与资格 FAIL 四组运行真实 T10 消费者(剔除 .env 读取语句),不调用模型、不改长复核条件或轮次 |
|
||||||
|
| P1 结论重新 UUID,多个型号失去对应关系 | 每用例显式传同一 run/model;子运行用该 run 下唯一 matrix_id 关联,结论含完整计划/完成分母。L1–L8、T10短长/各档/默认全部复用;两型号一 PASS 一 UNCOVERED,在 NONE、tiers、L8 三组逐文件验证关联和轮数 |
|
||||||
|
| P2 机器字段未落盘 | 仅精确 model_not_found 保留;其他字符串(含假凭据 sentinel)为 omitted;写入端再拒未知机器值,绝不输出任意上游 type |
|
||||||
|
|
||||||
|
### 本轮先红后绿与验证
|
||||||
|
|
||||||
|
日志位于 `tests/outputs/134/`,各命令结果后立即保存 `.exit`,原命令不接管道。
|
||||||
|
|
||||||
|
| 命令/节点 | 红证据 | 绿证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py -k structured_reask -q` | `review-f1-red`:1 failed/6 passed,合法两响应误判 FAIL,exit 1 | `review-f1-green`:7 passed,exit 0 |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py -k first_attempt_requires -q` | `review-f1-first-red`:首轮多反馈被放行,1 failed,exit 1 | `review-final-affected`:含该节点共129 passed,exit 0 |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py -k unregistered_candidate -q` | `review-f2-red`:ABSENT 被误判FAIL,1 failed/3 passed,exit 1 | `review-f2-green`:4 passed,exit 0 |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py -k capability_conclusions -q` | `review-f3-red`:三组结论缺型号断言红,3 failed,exit 1 | `review-f3-green`:三组+未登记四组共7 passed,exit 0 |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py -k safe_machine_type -q` | `review-f4-red`:三组缺机器字段,3 failed,exit 1 | `review-f4-green`:3 passed,exit 0 |
|
||||||
|
| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | — | `review-final-unit`:**1375 passed,exit 0** |
|
||||||
|
| `make check` | — | `review-final-check`:格式/ruff/import-linter 1 kept,exit 0 |
|
||||||
|
| `conda run --no-capture-output -n PolyGateway pytest tests/e2e/ -m slow --collect-only -q` | — | `review-final-collect`:**90 collected,exit 0**;不是90通过 |
|
||||||
|
|
||||||
|
上表节点命令也均加 `conda run --no-capture-output -n PolyGateway`;未使用缺 import 的伪红。最终受影响文件129单测通过,相比111新增18节点。pi-lens仍提示非conda缺httpx/pytest/pydantic及StrEnum等旧噪音,按已批准方向记录后继续conda门;新增测试命名空间显式 Any 类型,不抑制真实错误。
|
||||||
|
|
||||||
|
**尚未完成**:修复后独立复审、集成/make test覆盖率、真实能力取证、下游迁移和发布;T8/T9保持未勾选。M2空wire、M3非流UNKNOWN、真实机器拒绝白名单及外部服务状态不因离线绿变成已覆盖。
|
||||||
|
|
||||||
|
## 重启恢复与 embedding 报告补漏(起点 3eb22d2)
|
||||||
|
|
||||||
|
恢复时实际分支为 `feature/1.3.4-thinking-contracts`,HEAD=`3eb22d2`,已跟踪工作区无差异,仅既有 `.pi/` 未跟踪;前轮代码提交仍在,重启未丢代码。按用户限定只修测试与本 finding,不动生产 API/版本/计划,不联网、不付费、不运行 slow、不派子代理。本节为实现者验证,不冒充独立复审或版本验收。
|
||||||
|
|
||||||
|
原 `tests/outputs/134/slow-gate.log` 保留,大小 1625 字节;`slow-gate.exit` 不存在,属于重启中断、未取得终态,不能宣称 slow 通过。修复前后 SHA-256 均为 `05967dcf749b13db815dd80449a0db5b3e7ad2144aa2ae216f8c9ab5ecb21bf5`。历史日志不覆盖、不续写,也不把旧 full-gate 作为本轮测试证据。
|
||||||
|
|
||||||
|
| 根因/范围 | 本轮修复与证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| embedding 消费者遗漏四个字段 | 仅在原 finally 报告出口增加 `requested_model=source.model`、`provider=source.provider`、`planned_rounds=1`、`completed_rounds=1`;完成计数指已收尾轮次,FAIL/UNCOVERED 也计入,不代表成功。沿用原 matrix/round/session/parent/attempt 关联,不新建报告框架 |
|
||||||
|
| 环境可覆盖请求型号 | 既有 `test_live_evidence.py` 增加真实 probe 消费者回归:AST 仅剔除 `.env`/pytestmark 顶层读取,合成环境实际经过 GatewaySettings;两种 probe 型号覆盖都与原 chat 型号不同,HTTP 与报告必须等于本次 source,不能拿默认型号占位 |
|
||||||
|
| 成功与所有目标错误路径 | 真实 OpenAICompatTransport+LiveCapture+ObservedTransport+报告写入;仅 HTTP 边界 MockTransport。两型号×成功/503/严格404/ConnectError 共8节点,分别 PASS/FAIL/UNCOVERED/FAIL;同时断言唯一报告、逻辑 UUID/attempt 配对、请求校验、状态/错误类型与客户端关闭 |
|
||||||
|
| 安全边界 | 假凭据及私有提示词 sentinel 放入成功 model 回显、错误正文和请求异常;逐份 Markdown 断言不泄漏,仍只写安全机器枚举/固定原因,不复制原始正文与异常 |
|
||||||
|
|
||||||
|
日志均在 `tests/outputs/134/`,命令不接管道,先保存真实退出码到同名 `.exit` 再展示输出。
|
||||||
|
|
||||||
|
| 命令(pytest 前缀均为 `conda run --no-capture-output -n PolyGateway`) | 实际结果/日志 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py -k embed_probe_report -q`(修复前) | `embed-report-red.log/.exit`:8 failed,129 deselected,exit 1;八例均在真实报告消费处 `KeyError: requested_model`,不是 import/mock 签名失败 |
|
||||||
|
| 同命令(四字段补齐后) | `embed-report-green.log/.exit`:8 passed,129 deselected,exit 0 |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py tests/unit/test_embedding.py tests/unit/test_openai_compat.py::TestDefaultClientFactory -q` | `embed-report-affected.log/.exit`:188 passed,exit 0 |
|
||||||
|
| `pytest tests/unit/ -q` | `embed-report-unit.log/.exit`:1383 passed,exit 0;格式化后 `embed-report-final-unit.log/.exit`:1383 passed,4.45秒,exit 0 |
|
||||||
|
| `make check` | 首次 `embed-report-check.log/.exit` 为新断言排版失败(exit 2),不是行为红;仅对该测试文件运行 conda ruff format。`embed-report-check-green.log/.exit`:94文件格式合格、ruff通过、import-linter 1 kept/0 broken,exit 0 |
|
||||||
|
|
||||||
|
pi-lens 仍报非 conda 解释器缺 httpx/pytest/dotenv/pydantic 及旧 StrEnum 噪音;按任务授权记录,不添加 ignore、不改枚举、不扩环境修复范围。实际 conda 解释器为 `/home/iomgaa/miniconda3/envs/PolyGateway/bin/python`,本会话导入四依赖成功(httpx 0.28.1、pytest 9.1.1、python-dotenv 1.2.3、pydantic 2.13.4)。conda 启动器自身另有 base Python 3.13 的 RequestsDependencyWarning;未静音,不宣称输出零告警,测试进程与静态门实际退出0。
|
||||||
|
|
||||||
|
本修复不补写真正缺失的历史报告、不改变 embedding 能力判据;真实服务、slow、下游与发布证据仍由后续验收负责。
|
||||||
|
|
||||||
|
## 独立审查补正:取消不计完成轮(起点 7f6a824)
|
||||||
|
|
||||||
|
独立 verifier 指出:probe 的 finally 无条件写 `completed_rounds=1`,但 CancelledError 穿透时仍是 `FAIL/轮次未完成`,分母记录自相矛盾。已对照源码并在真实消费者复现,接受该问题;上节“已收尾即完成”的措辞不适用于取消,本节修正为**取得正常成功或普通异常分类终态才算完成**,不是 finally 执行过就完成。
|
||||||
|
|
||||||
|
最小修复仅在 probe 初始化 `completed_rounds=0`,成功判定或普通异常分类返回后置1;finally 写实际计数。取消仍穿透、计数保留0,不新增捕获 BaseException、不动生产 API/版本/分类器。扩展原消费者参数化测试增加两型号取消节点:真实 task 在 MockTransport 进入等待后由调用方 cancel,断言 CancelledError 穿透、task.cancelled、报告 FAIL/未完成、planned=1/completed=0、原调用关联及客户端关闭;其他8例保持完成1。所有节点只替换外部 HTTP,不联网、不付费、不跑 slow。
|
||||||
|
|
||||||
|
| 命令(pytest 前缀为 `conda run --no-capture-output -n PolyGateway`) | 本轮实际证据(tests/outputs/134/,各有 .log/.exit) |
|
||||||
|
| --- | --- |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py -k 'embed_probe_report and cancelled' -q`,修复前 | `embed-cancel-red`:2 failed/137 deselected,exit1;两例均先验证取消穿透、报告存在及资源关闭,再因 `completed_rounds` 实际1而期望0失败 |
|
||||||
|
| `pytest tests/unit/test_live_evidence.py -k embed_probe_report -q`,修复后 | `embed-cancel-green`:10 passed/129 deselected,exit0;覆盖原8例与新增2例 |
|
||||||
|
| `pytest tests/unit/ -q` | `embed-cancel-unit`:1385 passed,4.08秒,exit0 |
|
||||||
|
| `make check` | `embed-cancel-check`:格式/ruff通过,import-linter 1 kept/0 broken,exit0 |
|
||||||
|
|
||||||
|
既有非 conda LSP 误报继续只记录(本次额外将 `asyncio.timeout` 误判为缺属性);conda pytest 实际可执行,base RequestsDependencyWarning 未静音。此处是针对独立审查问题的实现及自验,修复后独立复核仍交父会话;不冒称审查门或版本验收已通过。原 slow 日志不改写。
|
||||||
|
|
||||||
|
## 1.3.4 发布准备与用户验收例外(2026-09-09,起点 b7e6943)
|
||||||
|
|
||||||
|
**授权与边界**:用户正式批准不再补全模型矩阵,保留失败/UNKNOWN/不可达、未完成轮次及缺下游现行配置证据为本版验收例外,继续 1.3.4 发布准备。例外不是测试通过,不调整分类器/覆盖分母/能力表,不把渠道问题自动归因成库外错误,也不免除受影响下游首次新语义读写前的缓存迁移。原设计/计划的“需取证或人类明确豁免”分支由本次授权满足;不勾选完整 T8/T9 或任何尚未执行的发布门。
|
||||||
|
|
||||||
|
本轮仅修改 README、CHANGELOG、pyproject、包版本和本 finding。没有新增测试或生产行为变更;原红绿与变异按前述节点复用,不为了版本 bump 人为造红,不启动子代理,不重跑付费模型矩阵。`.env.example` 与 ARCH 的行为同步已在既有提交完成,Wiki 仍下线。
|
||||||
|
|
||||||
|
### 已核对并复用的证据
|
||||||
|
|
||||||
|
下表路径未写前缀时均相对 `tests/outputs/134/`;这些是已有原件,本轮只核对,不冒称本轮新跑。
|
||||||
|
|
||||||
|
| 门/范围 | 原始证据与适用结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 生产独立审查 | run `b8552a94-dc93-4834-b9ff-c6b457c315ae` 的 `verify134/production.md`:目标生产 diff 无 Critical/Important/Minor;后续仅测试补漏及本轮文档/版本,不重演同一生产审查 |
|
||||||
|
| 四项取证修复复审 | run `3bdee9d3-4678-4ddb-83d9-544156eb00cc` 的 `verify134/executable-retry.md`:HEAD 3eb22d2 四项真实消费者复审无问题,1375 单元/18 定向节点通过,90 仅采集 |
|
||||||
|
| 取消计数独立复核 | run `c317eac4-e214-46fc-86a5-08a0d2187d3b` 的 `recovery/cancel-recheck.md`:b7e6943 限定复核无阻塞,139 取证单测与 make check 通过;取消 completed=0、普通终态=1、穿透与资源关闭 |
|
||||||
|
| 红绿/变异 | 前文对应的11个契约变异、8个假绿变异、审查四项红绿及 embedding 报告/取消红绿均保留;不外推成新 live 证明 |
|
||||||
|
| 日常全量 | `full-gate.log/.exit`:1508 passed、23 skipped、108 deselected、95%覆盖率、exit0;`full-gate-monitor-note.md` 说明外层监控包装失败不等于 pytest 失败。该历史全量早于 embedding 报告补漏;其后测试改动由1385单元及独立复核补证,不声称是新 HEAD 的完整全量 |
|
||||||
|
| M2 空 wire AUTO | `recovery-20260909/m2-auto.log/.exit`:2 passed、80 deselected、exit0;M2.5 run `946ac7bf89d742aba8717e722c1e2f60`、M2.7 run `5587e6d9de1e4c7bb6a352afedb7e732` 各5/5轮流式、并发1,最终 wire 无偷带 medium、请求/身份资格及开启命题通过。只消除这两个单元的缺测,不外推其他模式/渠道 |
|
||||||
|
| Redis 时间语义 | `recovery-20260909/non-llm-slow.log/.exit`:contracts/integration slow **18 passed、156 deselected、exit0**(1142.05秒),不等同整个 slow 套件通过 |
|
||||||
|
|
||||||
|
上述独立报告原件位于 `/home/iomgaa/.pi/agent/sessions/--home-iomgaa-Projects-PolyGateway--/subagent-artifacts/outputs/<run>/`;本轮另原样复制到 `release/reused-reviews/`,不覆盖旧报告、不提交运行产物。
|
||||||
|
|
||||||
|
### 最新实测、网络诊断与明确未覆盖
|
||||||
|
|
||||||
|
| 项目 | 已取得的事实/本版结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 旧完整 slow 中断 | `slow-gate.log` 无对应 `.exit`,保留原 SHA-256 `05967dcf749b13db815dd80449a0db5b3e7ad2144aa2ae216f8c9ab5ecb21bf5`;不把日志中的局部成功当整套通过 |
|
||||||
|
| embedding 定向实测 | `recovery-followup-20260909/embedding.log/.exit`:1 failed、exit1。独立三请求诊断 `channel-diagnosis-20260909.jsonl` 同一源 `minimax_1`、请求 `text-embedding-v1` 得503,`error.type=new_api_error`、`error.code=model_not_found`、无可用渠道语义命中;**不是**404且 type 不匹配,仍 FAIL,不改成严格404未覆盖或“所有网关不支持 embeddings” |
|
||||||
|
| M3 与 claude 开启档位 | `recovery-followup-20260909/remaining-tiers.log/.exit`:1 passed、1 failed、60 deselected、exit1(首错停止)。M3 run `8c852937e7144fa4b1641744785c9b2b` 六个显式档各5轮、共30/30完成,流式开启命题 PASS;claude-opus-5 run `d0303b5033f4449d82838690f1671470` 五档各5轮、共25/25完成,但至少一个开启命题 FAIL。逐轮请求成功不等于型号能力 PASS,也不能据M3流式覆盖消除非流式 UNKNOWN |
|
||||||
|
| claude 独立网络诊断 | `channel-diagnosis-20260909.jsonl` 中 high+简单题/复杂题均 HTTP200、SSE有DONE、回报身份一致;usage推理token=0,reasoning_content原长1但去空白长0(只有空白)。可证明该次传输完成却缺非空推理信号,不能证明 high 已开启、不能把空白提升 OBSERVED,也不据此断言所有渠道/档位均不能推理。三请求诊断完成不等于三项能力通过 |
|
||||||
|
| 剩余矩阵停止 | `remaining-20260909/matrix.log/.exit`:选中40节点,在首个 gemini-3-flash NONE 节点约1080秒后 KeyboardInterrupt,exit1,无测试终态通过汇总。日志不能独立证明停止原因或网络根因;保持未完成,不算40失败或40通过,不继续补跑 |
|
||||||
|
| 其余证据缺口 | 历史 UNKNOWN/身份不足、未执行的型号/模式/关闭单元、型号级400机器字段基线、下游现行配置缺证据均按原记录保留。GovDoc/CHS 现行配置未取证,Video-Tree退出迁移后的历史兼容测试也非现行配置验收;不宣称三项目完成本版迁移 |
|
||||||
|
|
||||||
|
所有已有逐轮报告仍保留在 `live/<run>/`,汇总文件不能覆盖失败原件。本轮盘点共有453份报告:PASS标签255、FAIL标签108、UNCOVERED标签33、无status的轮数汇总57;**混有逐轮、命题、pytest及历史报告,不能相加成独立模型/测试通过率**。716个历史证据文件的 SHA-256 清单保存在 `release/prior-evidence-sha256.json`,最终复核字节不变。盘点首跑因轮数汇总无status产生 KeyError,保存 `release/evidence-audit.log/.exit`(exit1);修正盘点脚本区分汇总后 `release/evidence-audit-final.log/.exit` 为exit0,未修改原报告或生产代码。
|
||||||
|
|
||||||
|
### 本轮发布准备亲跑结果与交接门
|
||||||
|
|
||||||
|
| 检查/命令 | 实际结果/证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 先查远端占用 | 改文件前 `git ls-remote --tags origin refs/tags/v1.3.4 refs/tags/v1.3.4^{}`:exit0且空;匿名 GET 包 simple/polygateway 索引HTTP200、不含1.3.4;GET releases/tags/v1.3.4 HTTP404。日志 `release/remote-*`,未读取或输出凭据;未来发布前仍需复查以免竞态 |
|
||||||
|
| 版本与 README 数字 | 两处版本均1.3.4;README安装下界改为 `>=1.3.4,<2`,新增醒目迁移警示和例外指针;CHANGELOG按实际日期2026-09-09定版。`inspect.signature` 实测 TelemetryRecorder 不含self为26参,能力表24条/含AUTO10条;`release/evidence-audit-final.log` |
|
||||||
|
| `make check` | `release/check.log/.exit`:94文件格式通过、ruff通过、import-linter 1 kept/0 broken、exit0 |
|
||||||
|
| `conda run --no-capture-output -n PolyGateway pytest tests/unit/test_package.py -q` | `release/package.log/.exit`:**6 passed,0.08秒,exit0**,两版本一致且导出面可用 |
|
||||||
|
| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | `release/unit.log/.exit`:**1385 passed,4.41秒,exit0**,未新增测试,无新付费调用 |
|
||||||
|
|
||||||
|
conda 启动器既有 RequestsDependencyWarning 保留,不宣称零告警。本轮 conda 内实际导入 dotenv/httpx/redis.asyncio 成功;工具的非conda LSP旧诊断不转成代码修改或忽略规则。
|
||||||
|
|
||||||
|
**可移交合并与包发布,不等于已发布。** 父会话按本版例外边界完成本次文档/版本差异审查,再执行合并后静态/日常门及未豁免的发布检查;不把本次例外解释为必须补全模型矩阵,也不把豁免项勾成已跑通过。merge/push/tag/构建/twine上传/下载解包独立安装/Release及registry页面检查均尚未执行;只能在实际完成后记录。不得覆盖已有同版本不同字节。
|
||||||
|
|
||||||
|
## 1.3.4 发布完成与外部验收(2026-09-09)
|
||||||
|
|
||||||
|
父会话已审 `dae12f9` 发布准备 diff,用户授权发布及指定例外;本轮无新生产/测试改动、不派子代理。已有红绿/变异与独立报告按适用范围复用,716份历史证据 SHA-256 复核不变。以下日志均相对 `tests/outputs/134/publish-20260909/`,命令无掩盖退出码管道;长跑由 tmux `pgw134-publish` 串行执行,`PYTHONUNBUFFERED=1`,每门独立 `.exit`。
|
||||||
|
|
||||||
|
| 步骤 | 实际结果与证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 远端核验/合并 | fetch后 origin/main=`a716f12` 无新变更;精确文件名检查 registry 无1.3.4,tag无占用、Release404。`--no-ff` 合并为 `af57f93adce24b43fd10b6d8e1281ab8ee43c0a8`,与批准 `dae12f9` 树零差异;`merge.log`/`merge-summary.log` |
|
||||||
|
| 合并后 `make lint` | exit0,ruff/import-linter 1 kept、0 broken,无自动修改;`lint.log/.exit` |
|
||||||
|
| 合并后 `make test` | **1518 passed、23 skipped、108 deselected,95%覆盖率,276.42秒,exit0**;`daily.log/.exit`。skip与deselected不计通过 |
|
||||||
|
| M2 AUTO定向 slow | **2 passed、80 deselected,46.85秒,exit0**;各5轮、并发1,空wire流式开启命题通过;`m2-auto.log/.exit`。M2.5 run `d4b158162ad543e5a4745d21b29ab03e`,M2.7 run `07a388cafa9f4fcf95dc0c6876fd0751`,均 planned=completed=5、无缺轮;实际逐轮/命题报告已读取 |
|
||||||
|
| Redis时间语义 slow | contracts/integration选择 **18 passed、156 deselected,1109.45秒,exit0**;`redis-time.log/.exit`。`gates.exit=0`,未重启全型号矩阵 |
|
||||||
|
| push与tag | main推送成功;注释tag对象 `0bdb0f70e4667a2fe50a567f0809a5a421c5442a`,解引用同上述main;`push-main.log`/`push-tag.log`/`remote-refs-after.log` |
|
||||||
|
| 构建/上传 | 核实项目绝对dist路径且不是链接后清旧1.3.3产物;conda `python -m build`、`twine check dist/*`、twine上传均exit0,wheel/sdist各一份。凭据只从tea内存读取进入TWINE_PASSWORD,无token argv/日志;`build`/`twine-check`/`upload` 的 `.log/.exit` |
|
||||||
|
| 独立下载/解包 | registry下载wheel与sdist,与本地构建SHA-256逐字匹配;包内五个关键生产文件与发布提交相同,wheel METADATA版本/Markdown正文、sdist README均与已发布README相符;`download-final.log/.exit`、`download-verification.json` |
|
||||||
|
| 独立安装与行为 | `/tmp/pgw134-registry-eyges3d7/installed/polygateway/__init__.py`,仓库外 `python -I` 并显式target来源验证;M2空AUTO、M3 AUTO在error/nearest均拒绝、M3 medium、同值/新增raw冲突与raw-only保留全部通过 |
|
||||||
|
| Release/包页面 | Release POST201,正文逐字来自CHANGELOG本节。匿名实际读取包页面、单版Release及Releases列表,均HTTP200、正文含迁移/例外;包页面有目标仓库链接,认证包API的 repository.full_name=`iomgaa/PolyGateway`;`pages-final.log/.exit`、`external-verification.json`及HTML/可见文本原件 |
|
||||||
|
| issue收尾 | 仅#21/#25/#26评论后关闭,GET各自核实closed;评论明确验证范围、失败例外、缓存迁移及“不识别所有渠道故障”;`issues.log/.exit`和`issue*-after.json`。未改#19/#22/#23/#24 |
|
||||||
|
|
||||||
|
产物 SHA-256:wheel `86ad5cc025bfda127c4f0e4bbe1252d1bb8943b7678f9ff5561200bac71fe2a2`;sdist `85620f993ff9d19845088a071d251049e3dfae8d035c05652bdb62b4340a1a66`。
|
||||||
|
|
||||||
|
外部亲查地址:[包页面](https://gitea.iomgaa.online/iomgaa/-/packages/pypi/polygateway/1.3.4)、[v1.3.4 Release](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.3.4)、[Releases列表](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases)。结果补记在独立 `docs/1.3.4-release-evidence` 分支,不改变已发布main/tag或覆盖同版本产物。
|
||||||
|
|
||||||
|
### 真实失败与恢复(原件保留,不改写成功)
|
||||||
|
|
||||||
|
| 检查失败 | 根因/处置与边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| 最初索引grep误报占用 | `1.3.4` 被当正则匹配历史sha片段;改用完整字面 `polygateway-1.3.4`,并核对tag/Release均不存在。没有实际同版占用,没有覆盖 |
|
||||||
|
| 首次sdist下载exit1 | 私有索引无setuptools,pip即使 `--no-deps` 仍启构建隔离;`download.log/.exit`保留。复用已下载wheel,sdist加 `--no-build-isolation` 使用现有conda构建工具后exit0;不新增依赖、不重传包 |
|
||||||
|
| 包link POST400 | 返回 `invalid argument`,`release-create.log/.exit`保留exit1;不把400当成功/不猜根因。匿名页面已有仓库链接,认证GET包API证实本版正确关联,故无需解绑重挂;Release创建201不重复执行 |
|
||||||
|
| 首次页面验证exit1 | 三个匿名页面及Release正文均通过,但额外包API匿名GET401;`pages.log/.exit`保留。仅该API沿现有tea认证GET后200,最终所有页面/API核对exit0;不扩大为发布包匿名不可下载 |
|
||||||
|
|
||||||
|
上述操作差异已补入CLAUDE发布清单。conda启动器既有RequestsDependencyWarning仍保留。用户验收例外完全沿前节:embedding503与claude命题仍FAIL、M3非流UNKNOWN及其余未覆盖/缺下游资料未变;本轮成功不外推全矩阵或所有渠道。1.3.4发布任务到此停止,1.3.5由父会话继续。
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-09-09-135-call-observability-validation
|
||||||
|
title: "1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵"
|
||||||
|
date: 2026-09-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.5 T2/T3 验收证据
|
||||||
|
|
||||||
|
> 范围:**仅 T2(遥测 10 列 / 诊断保真 / scope+operation / 装配闸)与 T3(终态行 / 取消 / 统一出口)**,
|
||||||
|
> 外加计划 T4 中"签名与列数机械迁移"那一片(与 schema 同批完成,避免先提交 schema 却留写入缺键)。
|
||||||
|
> **不含** T4 的 PG 集成、变异矩阵与文档同步——另任务承接,缺口见 §5。
|
||||||
|
> 设计:`designs/2026-09-09-135-call-observability-design.md`;计划:`plans/2026-09-09-135-call-observability.md`。
|
||||||
|
> 基线 HEAD `87c261b`(T1 已提交,1419 unit 全绿)。全部命令在 `PolyGateway` conda 环境执行。
|
||||||
|
|
||||||
|
## 1. 红绿证据链
|
||||||
|
|
||||||
|
TDD 纪律要求"先失败后通过",且红必须是**行为红**而非 import 红。下表每行都对应本会话的真实工具输出。
|
||||||
|
|
||||||
|
| # | 阶段 | 命令 | 结果 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0 | 基线 | `pytest tests/unit -q` | **1419 passed**(对照底) |
|
||||||
|
| 1 | 红(T2/T3 目标行为) | `pytest tests/unit/test_telemetry.py -k "RowLevelObservability or RecorderShapeGate"` | **26 failed**,证据 `tests/outputs/135/red-01-emitter-rows.txt` |
|
||||||
|
| 2 | 实现后全量红面 | `pytest tests/unit -q` | **137 failed / 1308 passed**(机械迁移面暴露),`red-02-after-impl.txt` |
|
||||||
|
| 3 | 迁移中 | 同上 | 60 → 25 → 8 → 4 failed(`red-03`/`red-04`/`red-05`) |
|
||||||
|
| 4 | 绿 | `pytest tests/unit -q` | **1467 passed, 0 failed** |
|
||||||
|
| 5 | 静态门 | `make check`(ruff lint+format + import-linter) | **Contracts: 1 kept, 0 broken** |
|
||||||
|
|
||||||
|
第 1 步的红是行为红而非 import 红:新用例调用的是**已存在**的 `TelemetryEmitter`,
|
||||||
|
失败形态是"缺 `scope=`/`operation=`/`stats=` 关键字"与"断言的列不存在",不是模块导不进来。
|
||||||
|
|
||||||
|
补测阶段另有两次真实红(均由 conda 实跑暴露、当场修正,非噪音):
|
||||||
|
`test_client.py` 的 `NameError: ResultInvalidError / ChatRequest`(漏 import)、
|
||||||
|
`TestTerminalRowSqlSemantics` 的 `'coroutine' object has no attribute 'execute'`(async helper 漏 await)。
|
||||||
|
|
||||||
|
## 2. 核心要求逐条对应
|
||||||
|
|
||||||
|
### 2.1 36 列与 Emitter 完整一致(不留"schema 有列、写入缺键")
|
||||||
|
|
||||||
|
一次性同批改完五处列定义 + 端口签名 + Emitter 写入,故不存在中间态。
|
||||||
|
|
||||||
|
| 判据 | 证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| `len(COLUMNS) == 36`、物理列 37 | `test_telemetry.py::TestSchemaModule::test_columns_and_ddl_are_frozen`;`TestSQLiteSchemaMode` 断言 23 → 37 |
|
||||||
|
| 五处列序一致(DDL/BACKFILL×2/COLUMNS) | 新建库与 ALTER 追加列序同为 `_EXPECTED_COLUMNS` |
|
||||||
|
| 端口实测 36 字段、10 新参 keyword-only 且无默认值 | `test_ports.py::TestTelemetryRecorderSignature`(`inspect.signature` 实测,不凭记忆) |
|
||||||
|
| **Emitter 实参键集合 == `schema.COLUMNS`** | `TestEmitterRecorderContract` 三入口逐个断言 `set(rows[0]) == set(COLUMNS)` |
|
||||||
|
| 1.2.1 冻结 INSERT 未被改写 | 按 `_PRE_135_COLUMNS`(26 列)重现原文;全量 36 列另按占位符个数断言 |
|
||||||
|
|
||||||
|
装配闸(C3):`_assert_recorder_shape` 在 `TelemetryEmitter.__init__` 做一次 `signature.bind`,
|
||||||
|
参数名**从协议签名派生**而非手抄第四份清单——`test_gate_derives_parameter_names_from_the_protocol`
|
||||||
|
用 monkeypatch 换掉协议后闸自动跟随,证明没有硬编码。旧签名 recorder / 无该方法 / 不可 inspect
|
||||||
|
一律装配期 `ValueError`(不是 warning:降级铁律管的是运行期写失败,不是配置错误)。
|
||||||
|
|
||||||
|
### 2.2 三个 client 的**真实链路**失败终态
|
||||||
|
|
||||||
|
不是只测 Emitter,而是驱动真实 client + MockTransport 到落库。
|
||||||
|
|
||||||
|
| 链路 | 用例 | 断言 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| chat 重试耗尽 | `TestChatTerminalFailureRows::test_retry_exhaustion_writes_exactly_one_terminal_row` | 3 条 attempt + **恰 1 条**终态;终态 `attempts == 3` |
|
||||||
|
| chat 结构化耗尽 | `test_structured_exhaustion_writes_the_only_failure_row` | attempt 行**全是成功行**,终态是唯一失败记录;error 含有界 `validation=`/`repair=` 且 < 1200 字符、不含 raw_text |
|
||||||
|
| chat 400 直拒 | `test_request_rejected_now_has_both_an_attempt_and_a_terminal_row` | 尝试行 + 终态行**各 1**(已批准的行数翻倍);两行共享同一 `logical_call_id` |
|
||||||
|
| embedding | `test_embedding.py::TestReasonlessTelemetryContract::test_failed_attempts_still_have_null_effort` | attempt 数 == 脚本长度,终态恰 1 |
|
||||||
|
| OCR 两方法 | `test_ocr_client.py` 同名用例(参数化 `recognize_text`/`parse_layout`) | 终态恰 1,且 `operation` 为**公开方法名** |
|
||||||
|
|
||||||
|
去重:`test_terminal_row_is_written_once_per_logical_call` 连调出口 3 次,SQL 可见仍 1 条(`claim_terminal`)。
|
||||||
|
非领域异常:`test_non_domain_exception_writes_no_terminal_row` —— `KeyError` 原样传播、**0 条**终态、分类不被改写。
|
||||||
|
|
||||||
|
**双写已消除**:`TelemetryMW` 的两个终态分支删除,改由三个 client 的公开边界经
|
||||||
|
`emit_terminal_once` 统一写出;`TestTelemetryMW::test_scope_level_failure_is_not_written_here_anymore`
|
||||||
|
与 `test_cancellation_is_not_written_here_anymore` 锁死"本层不再写终态",防回归双计。
|
||||||
|
|
||||||
|
### 2.3 取消口径
|
||||||
|
|
||||||
|
- chat 取消:`test_cancellation_writes_at_most_one_terminal_row` —— 恰 1 条,`error == "cancelled"`,
|
||||||
|
且 `error_type` 为 NULL(**字符串不解析猜诊断**)。
|
||||||
|
- 三链路同策略"尽力写一条、允许 0";`emit_terminal_once` **不 shield、不开后台任务**,
|
||||||
|
快照冻结是同步动作;写入 await 上再被取消则 `CancelledError` 原样传播(与 TelemetryMW 历史行为同款)。
|
||||||
|
|
||||||
|
### 2.4 SQL 不双计(按真实 SQLite 落库断言)
|
||||||
|
|
||||||
|
`TestTerminalRowSqlSemantics` 用真实 `SQLiteRecorder` 驱动一次失败 chat 后直接查表:
|
||||||
|
|
||||||
|
| 迁移影响 | 断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| 失败计数判据 | `error IS NOT NULL` 得 **3**(2 尝试 + 1 终态),`event_kind='terminal_failure'` 得 **1** |
|
||||||
|
| 费用不双计 | 终态行 `cost IS NOT NULL` 计数为 **0**;且 `usage_source='unavailable'`、token 全 0 |
|
||||||
|
| 时延分组 | 终态 `latency_ms` ≥ 任何单次尝试(含退避),故看板必须按 `event_kind` 分组 |
|
||||||
|
| 双时钟微差 | 终态 `latency_ms == total_latency_ms`(同一份冻结快照) |
|
||||||
|
| 逻辑两列归属 | 尝试行 `attempts`/`total_latency_ms` 恒 NULL |
|
||||||
|
| §5 归因查询 | 同一 `logical_call_id` 同时给出整池 reason 文案与逐源 503 现场;终态 `http_status_code` 为 NULL(C1 不冒充) |
|
||||||
|
|
||||||
|
### 2.5 诊断保真与 operation 修正
|
||||||
|
|
||||||
|
- 中转把 529 改写成 503 → **记 503 不猜回 529**;直接 529 记 529。
|
||||||
|
- 空 `str()` 的 Connect/Read/Write/PoolTimeout → `cause_type` 落对应 httpx 类名,`error` 退回类名。
|
||||||
|
- 成功行五列全 NULL(**不统一填 200**)。
|
||||||
|
- `_status_to_error` 增 keyword `operation`:`embed()` 非 200 改传 `"embedding"`(修正历史误标),
|
||||||
|
流式与非流式 chat 两处仍 `"chat"`(按实施计划 §2 归属表,未按设计行号误标)。
|
||||||
|
- 新列 `operation` 恒为公开方法四值,**绝不读 `exc.operation`**:
|
||||||
|
`test_operation_is_given_by_the_call_site_not_the_exception` 用 `exc.operation="download_result"` 反证。
|
||||||
|
- OCR `"类名: msg"` 前缀由出口的显式策略参数 `class_prefixed_error` 承载,不再三处各拼一遍。
|
||||||
|
|
||||||
|
## 3. 有界 validation 说明的单一所有者
|
||||||
|
|
||||||
|
`structured.py` 的 `_MAX_FEEDBACK_ERRORS`/`_MAX_ERROR_CHARS`/`_format_errors` 改名为
|
||||||
|
公开的 `MAX_FEEDBACK_ERRORS`/`MAX_ERROR_CHARS`/`format_bounded_errors`,
|
||||||
|
由"重问反馈"与"终态结构化说明"两个消费者共同引用,**数值只有一份**。
|
||||||
|
行为逐字不变(`test_structured.py` 22 项全绿,含反馈文案用例)。
|
||||||
|
|
||||||
|
## 4. 未改动确认(防越界)
|
||||||
|
|
||||||
|
`errors.py`、`admission.py`、`ratelimit.py`、`breaker.py`、`sources.py`、`thinking.py`、
|
||||||
|
`providers.py`、`telemetry/sqlite.py`、`telemetry/postgres.py`、`transports/monkey_ocr.py`
|
||||||
|
一字未动——两个 recorder 靠 `**fields` + `schema.COLUMNS` 自动吃到新列。
|
||||||
|
缓存 key 公式、重试预算与退避、429 免预算、stall 算法、取消结算、推理能力表均未触碰。
|
||||||
|
唯一顺带修正:`RetryMW.__init__` 的 `emitter` 注解由 `object | None` 收紧为
|
||||||
|
`TelemetryEmitter | None`(TYPE_CHECKING 导入,同层不破分层契约),因本轮改了它的 `_emit`。
|
||||||
|
|
||||||
|
## 5. T4 验收(PG 存储兼容、变异矩阵、文档同步)
|
||||||
|
|
||||||
|
基线 HEAD `393f2bf`(1467 unit 全绿),全部命令在 `PolyGateway` conda 环境执行。
|
||||||
|
|
||||||
|
### 5.1 PG 存储兼容(真实实验室 Postgres,复用 `pg_sandbox`)
|
||||||
|
|
||||||
|
机械迁移:`_EXPECTED_COLUMNS` 27 → 37 列、`_record_minimal` 字段字典补 10 键(与单测同款)、
|
||||||
|
`_PRE_TENANT_COLUMNS` 的缺列集由4 列扩到 14 列。新十列一律由 `_CALL_OBSERVABILITY_COLUMNS` 派生,
|
||||||
|
两处 manual 档告警的逐字断言改成按 `COLUMNS` 序派生的 `_PRE_TENANT_MISSING_NOTICE`——
|
||||||
|
另抄一份列名必然漂移,而漂移的表现是“manual 档没补列”这条断言假绿。
|
||||||
|
|
||||||
|
| 阶段 | 命令 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 机械迁移前(已知必红) | `pytest tests/integration/test_postgres_telemetry.py -q -x` | **1 failed / 18 passed**:manual 档列序断言 `Right contains 10 more items, first extra item: 'scope'` |
|
||||||
|
| 机械迁移后 | 同上(无 `-x`) | **27 passed**,EXIT=0,`tests/outputs/135/pg-telemetry-after-mechanical.log` |
|
||||||
|
| 新增四项验收后 | 同上 | **30 passed**,EXIT=0,`tests/outputs/135/pg-telemetry-new-cases.log` |
|
||||||
|
|
||||||
|
新增 `TestCallObservabilityColumnsAcceptance`(对应计划 T4 的 PG 四项),共用一张 27 列的 1.3.4 形态旧表(`pre_135_schema`):
|
||||||
|
|
||||||
|
| 验收项 | 用例与关键断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| auto 追加 10 列 + 旧行 NULL | `test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null`:物理列 27 → 37 且列序 == `_EXPECTED_COLUMNS`;attempt 行与 terminal 行十列取值**整体比对**(不是逐条 in);历史行 `dict.fromkeys(...)` 十列全 NULL |
|
||||||
|
| manual 缺列裁剪 | `test_manual_trims_the_insert_on_a_pre_135_table`:表结构逐字不动(== `_PRE_135_COLUMNS`);裁剪后 26 列逐列等于提交值(防整体错位);恰 1 条告警且含可直接粘贴的首/末列 ALTER;无“写入失败”/“补列失败”,`degraded is False` |
|
||||||
|
| 新旧进程混写 | `test_old_and_new_writers_share_one_table`:新版建表写 36 列 → 旧版进程用 `insert_sql("postgres", 26 列集)` 写入 → 新版再写;三行共存、表结构不变、旧行新列全 NULL、无写入失败 warning |
|
||||||
|
| 下游口径验收 | 同上用例尾部:`WHERE event_kind = 'terminal_failure'` 计得 1;`event_kind IS NULL` 计得 1(混写期旧行既不误计成失败也不误计成成功) |
|
||||||
|
|
||||||
|
纪律:未引用 `assert_no_leftovers`(它是 `test_pg_sandbox.py` 的模块级 fixture,对本文件不可见,上提它要改 `conftest.py`);
|
||||||
|
未新建沙箱设施、未碰共享表 `llm_calls`、未读或打印 DSN。新增的只有一个 `_execute_args`(带参数单语句)与
|
||||||
|
`_minimal_fields`(从 `_record_minimal` 拆出的字段字典,供“旧进程”复用同一份取值)。
|
||||||
|
|
||||||
|
### 5.2 变异矩阵(仓库外副本,主工作区生产代码零改动)
|
||||||
|
|
||||||
|
驱动脚本 `/tmp/pgw-135-mut/run_mutations.py`;每项“还原副本 → 施加单点变异(断言替换命中)→ 跑指定节点 → 还原”。
|
||||||
|
完整日志 `tests/outputs/135/mutations.log`,脚本总退出码 **EXIT=0**。
|
||||||
|
|
||||||
|
| # | 变异 | exit | 被杀断言(节选) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| M1 | `register_attempt()` 挪到 transport 成功之后 | 1 | `test_failed_retries_are_counted`、`test_budget_free_429_still_counts_as_an_attempt`、`test_retry_exhausted_counts_every_attempt` |
|
||||||
|
| M2 | `ChatRequest` 每次 `replace` 复制出新上下文 | 1 | `TestLogicalCallStats` 4 项 + `test_retry_exhaustion_writes_exactly_one_terminal_row` |
|
||||||
|
| M3 | `_rehydrate` 去掉 `call_stats=None` 覆盖 | 1 | `test_historic_dict_never_impersonates_call_stats` |
|
||||||
|
| M4 | Emitter 入口提前 `str(exc)` 压平 | 1 | 529/503 保真、4 个空超时文案 `cause_type`、类名前缀策略、结构化有界说明等 9 项 |
|
||||||
|
| M5 | 终态行复制最后一次 attempt 的 token/cost | 1 | `test_terminal_rows_never_contribute_to_cost`、`test_terminal_row_costs_nothing` |
|
||||||
|
| M6 | 去掉 `claim_terminal` 去重 | 1 | `test_terminal_row_is_written_once_per_logical_call`、`test_claim_terminal_is_true_once` |
|
||||||
|
| M7 | 装配闸改为捕获 `TypeError` 后 warning | 1 | `test_old_signature_recorder_is_refused_at_assembly`、`test_uninspectable_recorder_is_a_configuration_error` |
|
||||||
|
|
||||||
|
还原校验:副本 `.py` 文件集合散列与纯净态**逐字一致**(`66b58b9a…`),还原后同一批节点 **172 passed**。
|
||||||
|
|
||||||
|
> **方法论陷阱(影响本仓所有变异证据的有效性)**:只设 `PYTHONPATH=<副本>/src` 是**无效的**——
|
||||||
|
> `pyproject.toml` 的 `[tool.pytest.ini_options] pythonpath = ["src"]` 会把**仓库内**的 `src` 抢先塞进 `sys.path[0]`,
|
||||||
|
> 于是测的仍是原代码。首轮实跑七项变异**全部“存活”(exit=0)**就是这个坑;
|
||||||
|
> 改用 `-o pythonpath=<副本>` 覆盖后七项全部被杀。今后做变异必须先证“副本真的被导入”,
|
||||||
|
> 否则“变异存活”会被误读成“测试不够强”,而真相是变异根本没生效。
|
||||||
|
|
||||||
|
### 5.3 文档同步(字段数一律 `inspect` 实测,不凭记忆)
|
||||||
|
|
||||||
|
实测值:`len(inspect.signature(TelemetryRecorder.record_llm_call).parameters) - 1 == 36`,`len(COLUMNS) == 36`,物理列 37。
|
||||||
|
|
||||||
|
| 位置 | 改了什么 |
|
||||||
|
| --- | --- |
|
||||||
|
| `README.md` 能力表 | “必录 26 字段” → 36 字段 + 三类行与诊断列;新增“逻辑调用统计”一行 |
|
||||||
|
| `README.md` 新小节 | 《1.3.5 逻辑调用统计与失败诊断》:`call_stats` 读法、**SQL 迁移五项**、归因查询、存储侧升级 |
|
||||||
|
| `README.md` cap 覆盖面 | 补“`error_body` 沿用 `summarize_body` 上限、结构化说明自带限长,**两者都不在 cap 覆盖内**” |
|
||||||
|
| `ARCHITECTURE.md` §7.8 | 必录字段 26 → 36(十列逐个列出);新增“逻辑调用十列”段(列语义表 + 不变量 I3/I4 + `operation` vs `exc.operation` 两个语义 + 装配闸);cap 段补两处诊断文本不在覆盖面 |
|
||||||
|
| `CHANGELOG.md` | 新建《未发布》:公共面四项变更表 + 下游必须做的事(点名 `WHERE event_kind` 与装配期报错) |
|
||||||
|
| `.env.example` | `PGW_TELEMETRY_TEXT_CAP` 块补 1.3.5 覆盖面例外 |
|
||||||
|
| `schemas/llm-calls.md` | 标题 26 → 36 字段;十列逐行登记;新增《三类行与失败归因口径》(含归因 SQL 与迁移四条) |
|
||||||
|
| `metrics/call-telemetry-coverage.md` | 新增《1.3.5 三类行与逻辑调用覆盖》;live 基线列**不写伪百分比** |
|
||||||
|
|
||||||
|
版本号与发布步骤**未动**(任务边界);`pyproject.toml` / `__init__.py` 仍为 1.3.4。
|
||||||
|
|
||||||
|
### 5.4 本轮收口验证
|
||||||
|
|
||||||
|
| 检查 | 命令 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 静态 | `make check`(ruff format+lint + import-linter) | 见 §7 实跑记录 |
|
||||||
|
| 全量单测 | `pytest tests/unit -q` | 见 §7 |
|
||||||
|
| 目标集成 | `pytest tests/integration/test_postgres_telemetry.py -q` | **30 passed** |
|
||||||
|
|
||||||
|
## 6. 剩余缺口(不归本任务,不自称已关)
|
||||||
|
|
||||||
|
| 缺口 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 独立验证 | 未派全新上下文 verifier(合并前硬门,由父会话前台派) |
|
||||||
|
| slow / e2e | 未跑,属发布清单第 4 步(CLAUDE §4.4.1) |
|
||||||
|
| live 覆盖基线 | `metrics/call-telemetry-coverage.md` 的实际基线列仍待首次生产运行填入 |
|
||||||
|
| 下游自建 recorder | 装配闸只能证明形状可被接受,证不了函数体真的落这些列(已写进 README/CHANGELOG) |
|
||||||
|
|
||||||
|
## 7. 环境噪音记录(不是缺陷)
|
||||||
|
|
||||||
|
自动检查器用**系统解释器 Python 3.13.9**(无 `redis`/`pydantic`/`httpx`/`loguru`/`asyncpg` 等依赖),
|
||||||
|
持续报 `test_client.py` 2 项失败与大量 "Import could not be resolved"、`StrEnum is unknown import symbol`。
|
||||||
|
已核实为环境问题、非本轮引入:
|
||||||
|
|
||||||
|
- 失败根因是 `ModuleNotFoundError: No module named 'redis'`(optional extra),本轮 diff 对 redis 零改动;
|
||||||
|
- 在**未改动的 HEAD** 上用同一系统解释器复跑,同样 2 failed / 90 passed;
|
||||||
|
- 项目强制环境 `conda run -n PolyGateway`(Python 3.12.13)下:`test_client.py` 98 passed、全量 1467 passed。
|
||||||
|
|
||||||
|
判据以 CLAUDE.md §2 规定的 conda 环境与 `make check` 为准。
|
||||||
|
|
||||||
|
**T4 轮次同样现象(已逐项复现并关闭)**:检查器报 `tests/integration/test_postgres_telemetry.py` “2/3 failed”
|
||||||
|
与 6 处 `Import "asyncpg" could not be resolved`。根因与上同:`asyncpg>=0.29` 是 optional extra
|
||||||
|
(`pyproject.toml:25` 的 `postgres`),检查器解释器里没装。已做确定性复现:
|
||||||
|
|
||||||
|
- 那 3 条恰是本文件**仅有的不依赖真实 PG 的用例**,其中 2 条要造 `PostgresRecorder`;
|
||||||
|
- 用 `/home/iomgaa/miniconda3/bin/python`(无 asyncpg)跑这 3 条:**2 failed / 1 passed / 0.09s**,与检查器报告逐字吹合;
|
||||||
|
- 同 3 条在 `conda run -n PolyGateway` 下:**3 passed**;整文件 **30 passed**(真实 PG);
|
||||||
|
- 被标记的 6 行均为**本轮未触及的旧行**,本轮只新增 1 处同款函数内 `import asyncpg`。
|
||||||
|
|
||||||
|
未为此修改代码:给旧行加 type-ignore 属任务外改动(反 gold-plating),且并非真修复。
|
||||||
|
|
||||||
|
## 8. 1.3.5 发布准备(2026-09-09,唯一 writer 会话)
|
||||||
|
|
||||||
|
范围仅**发布准备文档与版本号**:README 安装下界、CHANGELOG 定版、两处版本、本节记录。生产代码与测试**零改动**(`git diff` 只有 `CHANGELOG.md` / `README.md` / `pyproject.toml` / `__init__.py` 各 1 行,外加本文件追加的这一节)。未 merge/push/tag/构建/上传。
|
||||||
|
|
||||||
|
### 8.1 先查远端占用(改文件之前)
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `git ls-remote --tags origin` | 最高 `refs/tags/v1.3.4`,**无 v1.3.5**;远端分支只有 `main`(`af57f93`)与 `docs/1.3.4-release-evidence` |
|
||||||
|
| 本地 tag | 同样止于 `v1.3.4` |
|
||||||
|
|
||||||
|
远端未被占用是**本次时点**的事实,父会话真正 push/tag 前仍须复查以防竞态。
|
||||||
|
|
||||||
|
### 8.2 版本与文档数字(数字一律实测)
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 两处版本 | `pyproject.toml` 与 `src/polygateway/__init__.py` 同为 `1.3.5`;由 `tests/unit/test_package.py` 机械断言一致(**6 passed**) |
|
||||||
|
| README 安装下界 | `>=1.3.4,<2` → `>=1.3.5,<2`(本版含装配期形状闸与 SQL 口径迁移,旧下界会让下游装到不含新列的包) |
|
||||||
|
| README 迁移醒目度 | 《1.3.5 逻辑调用统计与失败诊断》为顶层小节且带 `[!WARNING]`(点名"失败行变多、旧失败 SQL 会多数、自建 recorder 装配期报错"),能力表"逻辑调用统计"一行直链该锚点;沿用 1.3.4 的同款版式,未重排既有 1.3.4 迁移节 |
|
||||||
|
| CHANGELOG 定版 | `## 未发布` → `## 1.3.5(2026-09-09)`,日期取自本机 `date +%F` 实际值,正文未改 |
|
||||||
|
| 字段数复核 | `inspect.signature(TelemetryRecorder.record_llm_call)` 去 self **36 参**、`len(schema.COLUMNS) == 36`、探针实测物理列 **37**——README/CHANGELOG 的 36/37 与实测吻合,非凭记忆 |
|
||||||
|
|
||||||
|
README 静态数字未发现错误,故未做任何顺带修改。
|
||||||
|
|
||||||
|
### 8.3 复用既有证据的适用边界(不拿旧证据顶替本版统计)
|
||||||
|
|
||||||
|
| 证据 | 是否复用 | 边界 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 独立验证(全新上下文 verifier,父会话前台派) | 复用结论:**0 阻塞** | 审的是本分支 `b4812e1` 的 1.3.5 变更面;本会话未重复派子代理,也未把它当作 live 覆盖或发布后检查的替代 |
|
||||||
|
| 本版最终全套件 `make test` | **本版实跑,不复用** | `final-gates/make-test.log/.exit`:**1605 passed / 23 skipped / 108 deselected / 95%,exit 0** |
|
||||||
|
| 本版真实 PG 集成 | **本版实跑,不复用** | `final-gates/pg-telemetry-verbose.log/.exit`:**30 passed,exit 0**(含新增四项列兼容验收) |
|
||||||
|
| 本版 unit | **本版实跑** | 本轮版本号改动后复跑 `pytest tests/unit -q`:**1469 passed,exit 0**(`release/unit.log/.exit`) |
|
||||||
|
| 1.3.4 模型矩阵/逐型号推理证据 | **按适用条件复用** | 1.3.5 对 `thinking.py`、能力表、wire 片段、缓存 key 公式、重试/限流/熔断语义**一字未改**(§4 已逐文件确认),故 1.3.4 的型号级结论在其原有边界内继续成立——连同它的 FAIL/UNKNOWN/不可达/缺轮**一并继承**,不因本版而升格。它**不能**充当 1.3.5 新增 API/schema 的证据,也不提供本版的测试统计数字 |
|
||||||
|
| 1.3.4 的全模型矩阵重跑 | **不跑** | 本版未改推理路径;用户已批准不再补全模型矩阵。本会话不发全型号请求 |
|
||||||
|
|
||||||
|
本版**改变的公共面**(`CallStats` 与四类响应新字段、端口 10 新参、遥测 10 列与 `terminal_failure` 行)全部由新写的离线真链路用例(三个 client + MockTransport 直到落库)、真实 SQLite 断言、真实 PG 30 项承担,见 §2、§2.4、§5.1。
|
||||||
|
|
||||||
|
### 8.4 本轮受影响路径的真实现场(并发 1、超时不压、无全模型请求)
|
||||||
|
|
||||||
|
| 亲跑 | 命令与证据 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 真实网关冒烟(复用既有 e2e,不另造工具) | `pytest tests/e2e/test_smoke_gateway.py -m slow -v`,tmux `pgw135-smoke`,`release/smoke-gateway.log/.exit` | **4 passed,exit 0**(流式/非流式/结构化 json/结构化模型四节点,9.55s) |
|
||||||
|
| 有界现场探针(**恰 1 次**真实调用,全程走库) | `release/probe_call_stats.py` → `release/probe-call-stats.log/.exit`,tmux `pgw135-probe` | **exit 0**:`MiniMax-M3`/源 `minimax_1`,`call_stats.attempts=1`、`total_latency_ms=2581` ≥ 单次尝试 `2562`、`logical_call_id` 与落库行逐字一致;临时 SQLite 物理列 **37**、写入面 **36**、恰 1 条 `event_kind='attempt'` 行、`operation='chat'`、`scope='LLM'`、逻辑两列与四个诊断列全 NULL |
|
||||||
|
|
||||||
|
探针脚本只落在 `tests/outputs/135/release/`(该目录 gitignore,不入库),不新增生产代码、测试或平台设施,未发裸 HTTP,沿用 `.env` 现有超时与单源配置。
|
||||||
|
|
||||||
|
### 8.5 提交前收口门
|
||||||
|
|
||||||
|
| 门 | 证据 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `make check` | `release/check.log/.exit` | **exit 0**:94 文件格式通过、ruff 通过、import-linter **1 kept / 0 broken** |
|
||||||
|
| 包单测(两处版本一致) | `release/package.log/.exit` | **6 passed,exit 0** |
|
||||||
|
| 全量 unit | `release/unit.log/.exit` | **1469 passed,exit 0** |
|
||||||
|
|
||||||
|
23 项 skip 的理由已逐条留档(`final-gates/skip-reasons.log`):17 项 redis 时间语义由 integration 变体覆盖、6 项需实验室语料 `data/soak/chs_images`;**skip 不计通过**。conda 启动器既有 `RequestsDependencyWarning` 保留,不宣称零告警。
|
||||||
|
|
||||||
|
### 8.6 尚未执行(不得当成已完成)
|
||||||
|
|
||||||
|
merge、push、`git tag -a v1.3.5`、`python -m build`、`twine check`/`upload`、`pip download` 解包验证、Gitea Release 与包页面/仓库关联检查**全部未执行**;`pytest -m slow` 的其余 e2e(本轮只跑了 `test_smoke_gateway.py` 四节点)与 live 覆盖基线同样未跑。§6 的四项缺口除"独立验证"已由父会话关闭外,其余保持开启。
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "schema:llm-calls",
|
"id": "schema:llm-calls",
|
||||||
"label": "表结构: llm_calls(遥测 18 字段)",
|
"label": "表结构: llm_calls(遥测 25 字段)",
|
||||||
"type": "schema"
|
"type": "schema"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -190,6 +190,41 @@
|
|||||||
"id": "review:issue14-branch-review",
|
"id": "review:issue14-branch-review",
|
||||||
"label": "整分支审查: issue #14 熔断等待档",
|
"label": "整分支审查: issue #14 熔断等待档",
|
||||||
"type": "review"
|
"type": "review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:issue15-telemetry-pool-lifecycle",
|
||||||
|
"label": "issue #15: 遥测连接池的资源语义与生命周期",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:plan-issue15-telemetry-pool-lifecycle",
|
||||||
|
"label": "实现计划: 遥测连接池的资源语义与生命周期(issue #15)",
|
||||||
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:reasoning-effort",
|
||||||
|
"label": "推理档位一等化设计(issue #20 及其一般形式)",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:reasoning-effort",
|
||||||
|
"label": "实现计划: 推理档位一等化",
|
||||||
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:2026-09-09-134-thinking-contracts-design",
|
||||||
|
"label": "1.3.4 推理意图与测试证据设计",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:2026-09-09-134-thinking-contracts",
|
||||||
|
"label": "1.3.4 推理契约实施计划",
|
||||||
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "finding:2026-09-09-134-thinking-contracts-validation",
|
||||||
|
"label": "1.3.4 T0–T4 与 T7 确定性验证",
|
||||||
|
"type": "finding"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -353,6 +388,90 @@
|
|||||||
"relation": "informs",
|
"relation": "informs",
|
||||||
"evidence": "Important 项促使修正 CHANGELOG/README/设计 §4/计划 T5 对 wait 档失败 reason 的描述",
|
"evidence": "Important 项促使修正 CHANGELOG/README/设计 §4/计划 T5 对 wait 档失败 reason 的描述",
|
||||||
"added": "2026-08-20T05:01:16.206639+00:00"
|
"added": "2026-08-20T05:01:16.206639+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "design:issue15-telemetry-pool-lifecycle",
|
||||||
|
"target": "design:issue9-telemetry-ddl-probe",
|
||||||
|
"relation": "refines",
|
||||||
|
"evidence": "把 issue #9 的'确定写不进去'判据从'哪一步失败'改为'失败是什么性质': 建池失败不再一律判死",
|
||||||
|
"added": "2026-08-24T05:50:56.787791+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "design:issue15-telemetry-pool-lifecycle",
|
||||||
|
"target": "design:issue12-telemetry-retention",
|
||||||
|
"relation": "depends_on",
|
||||||
|
"evidence": "遥测作为审计证据的定位(决策 E-a)是否决'异步队列 + 后台 flush'备选的依据",
|
||||||
|
"added": "2026-08-24T05:50:57.954717+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:plan-issue15-telemetry-pool-lifecycle",
|
||||||
|
"target": "design:issue15-telemetry-pool-lifecycle",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "八任务实现四组改动(池语义/失败三分/状态可见/所有权纪律)",
|
||||||
|
"added": "2026-08-24T12:05:46.300738+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-08-25-thinking-observability-plan",
|
||||||
|
"target": "design:2026-08-25-thinking-observability-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "本计划 Task 1-10 实现该设计的全部落点与 §14 验收标准",
|
||||||
|
"added": "2026-08-26T04:49:16.312785+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "finding:2026-08-25-thinking-observability-regression",
|
||||||
|
"target": "design:2026-08-25-thinking-observability-design",
|
||||||
|
"relation": "supports",
|
||||||
|
"evidence": "裸 httpx 与库两层实测(M3 推理正常、MiniMax 停报 completion_tokens_details)是该设计三层根因与三态裁定的事实基础",
|
||||||
|
"added": "2026-08-26T04:49:17.481308+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "finding:2026-08-25-thinking-observability-regression",
|
||||||
|
"target": "design:2026-08-02-thinking-capability-design",
|
||||||
|
"relation": "refines",
|
||||||
|
"evidence": "复测确认 M3 can_disable 仍成立,并补记非流式不可观测、仅 reasoning_effort 有效两条限制",
|
||||||
|
"added": "2026-08-26T04:49:18.648857+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-08-26-issue18-pg-test-isolation",
|
||||||
|
"target": "design:2026-08-26-issue18-pg-test-isolation",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "9 个任务逐条实现设计 §4-§11",
|
||||||
|
"added": "2026-08-26T11:28:33.469681+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:reasoning-effort",
|
||||||
|
"target": "design:reasoning-effort",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定",
|
||||||
|
"added": "2026-09-05T04:07:17.723586+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-09-09-134-thinking-contracts",
|
||||||
|
"target": "design:2026-09-09-134-thinking-contracts-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "已批准设计;T0基线660 passed、make check通过",
|
||||||
|
"added": "2026-09-09T04:48:57.560089+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-09-09-134-thinking-contracts",
|
||||||
|
"target": "finding:2026-09-09-134-thinking-contracts-validation",
|
||||||
|
"relation": "tested_by",
|
||||||
|
"evidence": "T0–T4/T7:1241单测与11隔离变异;未覆盖live/集成/发布",
|
||||||
|
"added": "2026-09-09T05:39:12.170598+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "schema:llm-calls",
|
||||||
|
"target": "design:2026-09-09-134-thinking-contracts-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "复用既有26字段,四种行来源与无推理成败NULL;不增加生产数据面",
|
||||||
|
"added": "2026-09-09T06:32:06.527808+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-09-09-135-call-observability",
|
||||||
|
"target": "design:2026-09-09-135-call-observability-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计",
|
||||||
|
"added": "2026-09-09T13:42:00.298915+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+31
-5
@@ -1,8 +1,10 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-08-20 05:01 UTC
|
> 自动生成,更新时间:2026-09-09 16:49 UTC
|
||||||
|
|
||||||
## design (35)
|
## design (43)
|
||||||
|
|
||||||
|
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
|
||||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
- [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-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`
|
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||||
@@ -20,12 +22,17 @@
|
|||||||
- [2026-08-19-issue12-telemetry-retention-design](designs/2026-08-19-issue12-telemetry-retention-design.md) `design:2026-08-19-issue12-telemetry-retention-design`
|
- [2026-08-19-issue12-telemetry-retention-design](designs/2026-08-19-issue12-telemetry-retention-design.md) `design:2026-08-19-issue12-telemetry-retention-design`
|
||||||
- [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design`
|
- [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design`
|
||||||
- [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design`
|
- [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design`
|
||||||
|
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
|
||||||
|
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
|
||||||
|
- [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design`
|
||||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
- [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-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||||
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention`
|
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention`
|
||||||
- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
|
- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
|
||||||
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode`
|
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode`
|
||||||
|
- [issue #15: 遥测连接池的资源语义与生命周期](designs/issue15-telemetry-pool-lifecycle.md) `design:issue15-telemetry-pool-lifecycle`
|
||||||
|
- [issue #18: 隔离靠权限强制,目标靠显式声明](designs/2026-08-26-issue18-pg-test-isolation-design.md) `design:2026-08-26-issue18-pg-test-isolation`
|
||||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||||
@@ -34,17 +41,24 @@
|
|||||||
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
|
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
|
||||||
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||||
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
||||||
|
- [推理可观测性一等化(issue #16 + #17)](designs/2026-08-25-thinking-observability-design.md) `design:2026-08-25-thinking-observability-design`
|
||||||
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||||
|
- [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort`
|
||||||
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
||||||
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
||||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||||
|
|
||||||
## finding (12)
|
## finding (16)
|
||||||
|
|
||||||
|
- [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
|
||||||
|
- [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation`
|
||||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||||
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
||||||
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
||||||
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
||||||
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
||||||
|
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
|
||||||
|
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
|
||||||
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
||||||
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
||||||
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
||||||
@@ -53,7 +67,10 @@
|
|||||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
- [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`
|
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||||
|
|
||||||
## plan (30)
|
## plan (38)
|
||||||
|
|
||||||
|
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
|
||||||
|
- [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability`
|
||||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
- [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-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`
|
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||||
@@ -68,7 +85,10 @@
|
|||||||
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
|
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
|
||||||
- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention`
|
- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention`
|
||||||
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
|
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
|
||||||
|
- [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle`
|
||||||
|
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
|
||||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||||
|
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
|
||||||
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
||||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||||
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
||||||
@@ -80,17 +100,23 @@
|
|||||||
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||||
- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention`
|
- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention`
|
||||||
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
|
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
|
||||||
|
- [实现计划: 推理档位一等化](plans/reasoning-effort.md) `plan:reasoning-effort`
|
||||||
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
||||||
|
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
|
||||||
|
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
|
||||||
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
- [推理开关能力建模与 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 #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
||||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||||
|
|
||||||
## review (1)
|
## review (1)
|
||||||
|
|
||||||
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
||||||
|
|
||||||
## schema (1)
|
## schema (1)
|
||||||
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
|
||||||
|
- [表结构: llm_calls(遥测 36 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||||
|
|
||||||
## metric (2)
|
## metric (2)
|
||||||
|
|
||||||
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
||||||
- [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage`
|
- [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage`
|
||||||
|
|||||||
@@ -123,3 +123,39 @@
|
|||||||
- [2026-08-20 05:01 UTC] 重建索引: 80 篇页面
|
- [2026-08-20 05:01 UTC] 重建索引: 80 篇页面
|
||||||
- [2026-08-20 05:01 UTC] 新增 review: 整分支审查: issue #14 熔断等待档 (review:issue14-branch-review)
|
- [2026-08-20 05:01 UTC] 新增 review: 整分支审查: issue #14 熔断等待档 (review:issue14-branch-review)
|
||||||
- [2026-08-20 05:01 UTC] 重建索引: 81 篇页面
|
- [2026-08-20 05:01 UTC] 重建索引: 81 篇页面
|
||||||
|
- [2026-08-24 05:49 UTC] 新增 design: issue #15: 遥测连接池的资源语义与生命周期 (design:issue15-telemetry-pool-lifecycle)
|
||||||
|
- [2026-08-24 05:50 UTC] 重建索引: 83 篇页面
|
||||||
|
- [2026-08-24 05:50 UTC] 新增边: design:issue15-telemetry-pool-lifecycle --refines--> design:issue9-telemetry-ddl-probe
|
||||||
|
- [2026-08-24 05:50 UTC] 新增边: design:issue15-telemetry-pool-lifecycle --depends_on--> design:issue12-telemetry-retention
|
||||||
|
- [2026-08-24 05:50 UTC] 重建索引: 83 篇页面
|
||||||
|
- [2026-08-24 10:14 UTC] 重建索引: 83 篇页面
|
||||||
|
- [2026-08-24 10:15 UTC] design:issue15-telemetry-pool-lifecycle 经 Codex 审查: 3 阻断+3 应改+1 可选,核实后 6 采纳 1 改判,正文补 §9 审查留痕
|
||||||
|
- [2026-08-24 10:20 UTC] 最低 Python 提到 3.12(pyproject/ruff/README/CLAUDE.md 四处);design:issue15 §6 版本取舍消解,§7 补两项实施前置
|
||||||
|
- [2026-08-24 10:32 UTC] Python 3.12 迁移执行完毕: 环境重建 3.12.13、补装 build/twine、UP047 三处改 PEP 695;make check 绿、973 passed 覆盖率 94%
|
||||||
|
- [2026-08-24 12:05 UTC] 新增 plan: 实现计划: 遥测连接池的资源语义与生命周期(issue #15) (plan:plan-issue15-telemetry-pool-lifecycle)
|
||||||
|
- [2026-08-24 12:05 UTC] 新增边: plan:plan-issue15-telemetry-pool-lifecycle --implements--> design:issue15-telemetry-pool-lifecycle
|
||||||
|
- [2026-08-24 12:05 UTC] 重建索引: 85 篇页面
|
||||||
|
- [2026-08-24 12:10 UTC] plan:issue15 经 Codex 审: 2 阻断已修(20 处构造点须同批改、application_name 改走 DSN 查询参数)、_failed 计数修正
|
||||||
|
- [2026-08-24 15:48 UTC] issue15 T1-T7 实施完成: 池按需建连(min_size=0)+失败三分与 60s 冷却+TelemetryStatus 快照+所有权纪律统一,1038 passed
|
||||||
|
- [2026-08-24 15:48 UTC] issue15 独立验证 5 问题处置: 日志级别决策收敛到 tracker(fatal=error)并补执法用例、is not None 所有权纪律补 falsy 用例、更正两处过时吞吐数字、acquire 预算措辞对齐代码、登记页状态与行数校正
|
||||||
|
- [2026-08-24 15:49 UTC] 重建索引: 85 篇页面
|
||||||
|
- [2026-08-24 15:51 UTC] 重建索引: 85 篇页面
|
||||||
|
- [2026-08-26 04:49 UTC] 新增边: plan:2026-08-25-thinking-observability-plan --implements--> design:2026-08-25-thinking-observability-design
|
||||||
|
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --supports--> design:2026-08-25-thinking-observability-design
|
||||||
|
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --refines--> design:2026-08-02-thinking-capability-design
|
||||||
|
- [2026-08-26 04:49 UTC] 重建索引: 88 篇页面
|
||||||
|
- [2026-08-26 11:28 UTC] 新增边: plan:2026-08-26-issue18-pg-test-isolation --implements--> design:2026-08-26-issue18-pg-test-isolation
|
||||||
|
- [2026-08-26 11:28 UTC] 重建索引: 91 篇页面
|
||||||
|
- [2026-09-05 04:06 UTC] 新增 design: 推理档位一等化设计(issue #20 及其一般形式) (design:reasoning-effort)
|
||||||
|
- [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort)
|
||||||
|
- [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort
|
||||||
|
- [2026-09-05 04:07 UTC] 重建索引: 95 篇页面
|
||||||
|
- [2026-09-09 04:48 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --implements--> design:2026-09-09-134-thinking-contracts-design
|
||||||
|
- [2026-09-09 04:48 UTC] 重建索引: 97 篇页面
|
||||||
|
- [2026-09-09 05:39 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --tested_by--> finding:2026-09-09-134-thinking-contracts-validation
|
||||||
|
- [2026-09-09 05:39 UTC] 重建索引: 98 篇页面
|
||||||
|
- [2026-09-09 06:32 UTC] 新增边: schema:llm-calls --implements--> design:2026-09-09-134-thinking-contracts-design
|
||||||
|
- [2026-09-09 06:32 UTC] 重建索引: 98 篇页面
|
||||||
|
- [2026-09-09 13:42 UTC] 新增边: plan:2026-09-09-135-call-observability --implements--> design:2026-09-09-135-call-observability-design
|
||||||
|
- [2026-09-09 13:42 UTC] 重建索引: 100 篇页面
|
||||||
|
- [2026-09-09 16:49 UTC] 重建索引: 101 篇页面
|
||||||
|
|||||||
@@ -7,3 +7,27 @@ date: 2026-07-20
|
|||||||
|
|
||||||
# 每次调用必录覆盖率(含缓存命中/失败/取消)
|
# 每次调用必录覆盖率(含缓存命中/失败/取消)
|
||||||
|
|
||||||
|
## 1.3.4 契约与基线
|
||||||
|
|
||||||
|
| 指标 | 确定性阈值/证据 | 实际 live 基线 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 三个无推理入口成败行档位 NULL | embed、recognize_text、parse_layout 真实 client→emitter→临时 SQLite,非空失败/成功计数与 NULL 全部成立,契约要求100% | 待首次实际运行,不填伪百分比 |
|
||||||
|
| chat 阳性 | 糖失败 auto、显式意图失败、nearest 成功实际档均精确匹配,要求100%;防恒 NULL 假绿 | 待首次实际运行 |
|
||||||
|
| 四种行来源 | 真实成功=applied;失败=effective;cache_hit/scope终态=本次请求级 | 排除缓存/失败后才可做实际档分析 |
|
||||||
|
| live 覆盖 | 逐轮 PASS/FAIL/UNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS |
|
||||||
|
|
||||||
|
复用 schema:llm-calls(无新字段/DDL),证据索引见 `findings/2026-09-09-134-thinking-contracts-validation.md`。独立错误取证只在 tests 内存,Markdown 只记录白名单安全摘要与布尔校验,不使用生产遥测旁路补失踪尝试。生产埋点仍是 TelemetryEmitter 单点出口。
|
||||||
|
|
||||||
|
## 1.3.5 三类行与逻辑调用覆盖
|
||||||
|
|
||||||
|
本版把覆盖度的计量单位从"一次尝试"改成"一次逻辑调用"——此前结构化耗尽、embedding/OCR 的无源与准入拒绝根本没有任何行,"每次调用必录"在这几条路径上是不成立的。
|
||||||
|
|
||||||
|
| 指标 | 确定性阈值/证据 | 实际 live 基线 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 领域失败的终态行覆盖 | chat 结构化耗尽、embedding/OCR 的 `no_sources`、准入拒绝、`retry_exhausted`、尝试外取消各恰有 **1** 条;单测以真实临时 SQLite 抓实 | 待首次实际运行,不填伪百分比 |
|
||||||
|
| 至多一条(I3) | 同一逻辑调用重复进出口不产生第二条(`claim_terminal`);recorder 写失败仅 warning,故 SQL 可见 ≤ 1 | 待首次实际运行 |
|
||||||
|
| 非领域异常 0 条(I4) | 编程错(如 `KeyError`)不写终态行、原样传播、分类不被改写 | 待首次实际运行 |
|
||||||
|
| 诊断列保真 | 中转把 529 改写成 503 则记 503(**不猜回**);空 `str()` 的 httpx 超时类落 `cause_type`;成功行五列 NULL | 待首次实际运行 |
|
||||||
|
| 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) |
|
||||||
|
|
||||||
|
本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# 实现计划: 遥测连接池的资源语义与生命周期(issue #15)
|
||||||
|
|
||||||
|
- **设计**: `research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md`(已过 Codex 审 + 人类审)
|
||||||
|
- **涉及技术**: Python 3.12(PEP 695 已就位)、asyncpg 0.31 连接池、`asyncio.timeout`、PG SQLSTATE、frozen dataclass、`@runtime_checkable` Protocol、pytest(含真实 PG 的 integration)
|
||||||
|
- **版号**: 1.3.0(人类已定;**本计划不 bump 版本号**,那是发布清单第 3 步的事)
|
||||||
|
- **状态**: **已实施**(2026-08-24)。T0–T7 全部提交完成,提交表见文末;合并前的三道门(`pytest -m slow`、独立 verifier、整分支审查)见「完成判据」)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让遥测池的资源占用与真实负载挂钩,把"建池失败 → 整进程永久失遥测"这条路彻底拆掉,并让任何降级都可恢复、可见、可编程。
|
||||||
|
|
||||||
|
## 方案概述
|
||||||
|
|
||||||
|
`min_size=0` 让建池变成零成本动作(实测不触库),连接失败自动落到 `acquire` 那条本来就正确的"丢一行、池自恢复"路径;判死判据从"哪一步失败"改为"失败是什么性质",永久档窄到只剩"DSN 不可解析",其余一律 60s 冷却重试;降级状态升格为共用的一等对象(节流日志 + 只读快照);顺带把"谁建的谁关"统一为全库纪律,让 ARCH §7.7 R5 的显式共享真正可用。
|
||||||
|
|
||||||
|
## 保真校验适用性
|
||||||
|
|
||||||
|
**不适用**。遥测后端无参考实现蓝本(ARCHITECTURE.md §7.8 明记"参考仓无先例: 三项目遥测全 SQLite"),本计划不涉及 `reference/` 迁移。但有两条**同等强度的既有承诺**不得被本次改动破坏,各任务已挂检查点:
|
||||||
|
|
||||||
|
1. issue #13 的"manual 档缺列时裁剪 INSERT 继续写、逐行 warning 暴露"(T5 的 `42703` 例外);
|
||||||
|
2. issue #9 的"表存在就绝不发 DDL"(`to_regclass` 先探测,T4/T5 不得碰这段控制流)。
|
||||||
|
|
||||||
|
## 起点状态(执行前必读)
|
||||||
|
|
||||||
|
- **工作区有未提交改动且在 `main` 上**: Python 3.12 迁移已执行完毕(`pyproject.toml` `requires-python`/`target-version`、`README.md` 两处、`CLAUDE.md` 技术栈、`client.py` 与 `streaming.py` 的 UP047 三处改 PEP 695),conda 环境已重建为 3.12.13 并补装 `build`/`twine`。**T0 的第一件事就是把它们落到分支上**。
|
||||||
|
- **建池路径今天零测试覆盖**: 全 `tests/` 目录对 `create_pool` 与 `_open_pool` 的引用数为 **0**(执行前可自行复核)。现有 PG 用例一律经 `pool=_FakePgPool(...)` 注入,走的是 `_external_pool=True` 分支,**从不经过建池**。这正是 `min_size=10` 潜伏至今的原因,也意味着 T3 要新建这一路的第一个用例。
|
||||||
|
|
||||||
|
## 提交门(每个提交点都受此约束)
|
||||||
|
|
||||||
|
`.claude/scripts/hooks/pre-commit-guard.sh` 在检测到 `git commit` 时**阻塞式**执行: `ruff check src/`(任何问题即阻塞)、`radon cc src -n C`(圈复杂度 ≥ C 即阻塞)、`pytest tests/ --tb=line -q`(任一红即阻塞)。文件 > 200 行只是 warning,不阻塞。
|
||||||
|
|
||||||
|
两条由此而来的硬约束:
|
||||||
|
|
||||||
|
- **不得留红态跨提交**——任务边界必须切在"全绿"处,不能把一个行为拆成"改实现"和"改测试"两次提交。
|
||||||
|
- **圈复杂度是真实风险**: `record_llm_call` 本次要同时接入硬预算、失败分类与 tracker。一旦逼近 C 就必须抽私有方法,**这不算计划外重构**,是提交门的硬要求。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 文件 | 动作 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/polygateway/types.py` | 改 | 新增 `TelemetryStatus` frozen dataclass(与 `SourceStats` 同一先例) |
|
||||||
|
| `src/polygateway/ports.py` | 改 | 新增**独立** `TelemetryStatusProvider` Protocol;`TelemetryRecorder` **一字不动** |
|
||||||
|
| `src/polygateway/telemetry/status.py` | **新建** | `TelemetryStatusTracker`: 降级状态机 + 节流日志 + 快照。两个 recorder 共用,不含任何后端知识 |
|
||||||
|
| `src/polygateway/telemetry/postgres.py` | 改 | 池语义、硬预算、失败三分、冷却降级、有界 `aclose`、接入 tracker |
|
||||||
|
| `src/polygateway/telemetry/sqlite.py` | 改 | **仅**接入 tracker(补上今天缺失的降级 warning);不做 lazy 化与冷却 |
|
||||||
|
| `src/polygateway/config.py` | 改 | 两个新键的加载与校验 |
|
||||||
|
| `src/polygateway/client.py` | 改 | 所有权纪律 + `aclose` helper + `telemetry_status` 出口 |
|
||||||
|
| `src/polygateway/embedding.py`、`ocr.py` | 改 | 同款所有权与出口(三处必须一致) |
|
||||||
|
| `src/polygateway/backends/redis_cache.py` | 改 | 补 `_owns_client` 纪律 |
|
||||||
|
| `tests/unit/test_telemetry.py` | 改 | `_FakePgPool` 改造 + 池语义/预算/分类/冷却/tracker 用例 |
|
||||||
|
| `tests/unit/test_client.py` | 改 | 所有权层用例(三个 client 各钉一次) |
|
||||||
|
| `tests/unit/test_config.py` | 改 | 两个新键的三条装配路 |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py` | 改 | 真实 PG: 连接数计数、降级恢复 |
|
||||||
|
| `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md` | 改 | 配置面、能力表、发布说明、架构决策成文 |
|
||||||
|
|
||||||
|
## 关键接口(跨任务消费,此处定死)
|
||||||
|
|
||||||
|
`types.py` 新增(T2 建立,T4/T5/T6 消费):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TelemetryStatus:
|
||||||
|
"""遥测后端的可写状态快照;degraded 期间下游可据此对账(issue #15)。"""
|
||||||
|
degraded: bool
|
||||||
|
fatal: bool # True = 本进程内不可恢复(仅 DSN 不可解析一类)
|
||||||
|
reason: str | None # 降级原因;未降级为 None
|
||||||
|
degraded_for_s: float | None # 已降级时长;未降级为 None
|
||||||
|
dropped_rows: int # 累计丢弃行数(进程生命周期内单调不减)
|
||||||
|
retry_after_s: float | None # 距下次重新准备;fatal 或未降级为 None
|
||||||
|
```
|
||||||
|
|
||||||
|
`ports.py` 新增(T2 建立)——**独立于 `TelemetryRecorder`**,理由见设计 §3.3:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@runtime_checkable
|
||||||
|
class TelemetryStatusProvider(Protocol):
|
||||||
|
"""可自述可写状态的遥测后端;与 TelemetryRecorder 分开是为了不破坏后者的
|
||||||
|
runtime_checkable 语义(加成员会让只实现 record_llm_call 的对象当场不满足协议)。"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_status(self) -> TelemetryStatus: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`telemetry/status.py` 新增(T2 建立,T4/T5 消费)。`now` 注入以便测试推进假时钟:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TelemetryStatusTracker:
|
||||||
|
def __init__(self, *, backend: str, now: Callable[[], float] = time.monotonic) -> None: ...
|
||||||
|
def enter_degraded(self, reason: str, *, fatal: bool, cooldown_s: float | None) -> None: ...
|
||||||
|
def recover(self) -> None: ...
|
||||||
|
def record_drop(self, reason: str) -> None: ...
|
||||||
|
def should_retry(self) -> bool: ... # fatal→False;冷却未到→False;到期→True
|
||||||
|
def snapshot(self) -> TelemetryStatus: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`PostgresRecorder.__init__` 新签名(T3 落地;`pool_max`/`write_timeout_s` keyword-only **必填**,与 `auto_migrate` 同一纪律——缺省只写在 config 一处):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool,
|
||||||
|
pool_max: int, write_timeout_s: float,
|
||||||
|
now: Callable[[], float] = time.monotonic) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`GatewaySettings` 新字段与 env 键(T3 落地):
|
||||||
|
|
||||||
|
| 字段 | env 键 | 缺省 | 校验(落 `_validate_telemetry`) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `telemetry_pg_pool_max: int` | `PGW_TELEMETRY_PG_POOL_MAX` | 4 | `>= 1`,否则 ValueError 点出字段名与键名 |
|
||||||
|
| `telemetry_pg_write_timeout_s: float` | `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | 5.0 | `> 0`,同上 |
|
||||||
|
|
||||||
|
失败三分(T5 落地,`postgres.py` 模块级私有函数,全库唯一一处 PG 失败分类):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_FATAL = "fatal" # 配置级致命 → 永久 no-op + 一条 error
|
||||||
|
_UNAVAILABLE = "unavailable" # 环境级 → 60s 冷却降级
|
||||||
|
_ROW = "row" # 行级 → 逐条 warning 丢弃
|
||||||
|
|
||||||
|
def _classify_failure(exc: BaseException) -> str: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
判据(设计 §3.2,两句): ①致命 = 原因完全在进程内部且不可变;②行级 vs 环境级看失败与**这一行的数据**有没有关系。落到具体码:
|
||||||
|
|
||||||
|
| 归档 | 覆盖 |
|
||||||
|
|---|---|
|
||||||
|
| `_FATAL` | `asyncpg.ClientConfigurationError`;`create_pool` 抛的 `ValueError`/`TypeError` |
|
||||||
|
| `_UNAVAILABLE` | SQLSTATE 前两位 ∈ {`08`,`53`,`57`,`28`,`3D`} + 具体码 `42501`、`42P01`;`OSError`/`ConnectionError`/`TimeoutError`/其余 `InterfaceError` |
|
||||||
|
| `_ROW` | 其余 `PostgresError`(`22`/`23` 等)+ **具名例外 `42703`**(缺列,由 issue #13 承诺定死) |
|
||||||
|
|
||||||
|
冷却期为模块级常量 `_DEGRADE_COOLDOWN_S = 60.0`(不暴露配置,设计 §3.5)。
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
### T0 — 分支与基线(把已完成的 3.12 迁移落盘)
|
||||||
|
|
||||||
|
- [x] 从 `main` 建分支 `feat/issue-15-telemetry-pool-lifecycle`
|
||||||
|
- [x] 把工作区现有改动分两次提交: ① `chore: 最低 Python 提到 3.12 并改用 PEP 695 泛型语法`(`pyproject.toml`/`README.md`/`CLAUDE.md`/`client.py`/`streaming.py`);② `docs: issue #15 设计文档与 wiki 登记`(`research-wiki/`)
|
||||||
|
- [x] 记录基线用例计数(执行时实测;2026-08-24 本机为 **973 passed / 23 skipped / 45 deselected**,覆盖率 94%)。该数只作**同环境**参照,不作硬验收——`addopts = "-m 'not slow'"` 与 Redis/PG 可达性都会改变它
|
||||||
|
|
||||||
|
**验证**: `make check` 全绿;`/home/iomgaa/miniconda3/envs/PolyGateway/bin/python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确。
|
||||||
|
|
||||||
|
> **不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check`。
|
||||||
|
> **不要用 `conda run ... pytest` 取统计数字**——实测其输出缓冲会把结尾的 `N passed` 与覆盖率整段吞掉,只剩 exit code(2026-08-24 踩过)。用环境解释器绝对路径直跑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T1 — D 组: 资源所有权纪律统一(独立回滚点)
|
||||||
|
|
||||||
|
**动**: `src/polygateway/client.py`、`embedding.py`、`ocr.py`、`backends/redis_cache.py`;测试 `tests/unit/test_client.py`。
|
||||||
|
|
||||||
|
**要实现的行为**: 全库唯一纪律 —— **谁建的谁关,注入的一律不碰**。分两层落:
|
||||||
|
|
||||||
|
1. **组件内部自建的连接**归组件自己: `RedisCache` 补 `_owns_client`(构造注入 → False;`from_url` → True),`aclose` 自查后再关。这是照抄 `backends/redis/limiter.py:185-191, 318-322` 的既有正确先例,`backends/redis/breaker.py:437-441` 同款。
|
||||||
|
2. **client 自建的整个组件**归 client: 三个 client 各持 `_owns_transport/_owns_telemetry/_owns_cache/_owns_limiter/_owns_breaker`,**默认全 False**(`__init__` 是全量注入路径,经它传入的一切都是外部的),只有三个工厂在真正自建时置 True。工厂里 `transport` 恒自建(三处工厂都没有 transport 注入参数),`limiter`/`breaker`/`cache`/`telemetry` 按 `xxx is None` 判定。
|
||||||
|
|
||||||
|
**执行留痕(T1)**: 工厂里既有的 `limiter or _build_limiter(...)` 一律改成了 `is not None` 判定。理由是注入一个 **falsy** 后端时 `or` 会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这不是风格偏好,是所有权判定能成立的**必要条件**,已回写设计 §3.4。
|
||||||
|
|
||||||
|
**同时修掉的现存泄漏**: `GatewayClient.__init__` 今天把 limiter/breaker 交给 `RetryMW` 构造(`client.py:156-176`)后自己不留引用(`self._transport`/`_telemetry`/`_cache` 都存了,唯独这两个没存,见 `client.py:203-206`),`aclose` 因此**触达不到**自建的 redis 客户端。三个 client 都要新持 `self._limiter`/`self._breaker` 引用(仅为关闭)。embedding/ocr 的自建点在 `embedding.py:497-498`、`ocr.py:510-511`。
|
||||||
|
|
||||||
|
**收敛**: 三处复制的 `getattr(..., "aclose")` 探测(`client.py:268-280`、`embedding.py:452-461`、`ocr.py:462-467`)收敛为**一个**内部 helper。SQLite recorder 只有同步 `close()`,helper 须同时探测 `aclose`/`close`(今天 `client.py:274-277` 已有这个分支,embedding/ocr 也有,收敛后行为不变)。内存后端无 `aclose`,探测后跳过。
|
||||||
|
|
||||||
|
**测试要求**(先失败后通过): 假 recorder/transport/limiter/breaker/cache 各记 close 次数。
|
||||||
|
- 注入的组件 `aclose` 后 close 次数 **0**;自建的为 **1**(工厂路径);
|
||||||
|
- 自建 redis limiter/breaker 被关(**泄漏钉子**,今天必红);
|
||||||
|
- 注入给 `RedisCache` 的客户端不被关;
|
||||||
|
- **三个 client 逐一覆盖**——收敛成 helper 之后仍须三处各钉一次,否则下次有人把逻辑复制回去无人发现;
|
||||||
|
- `aclose` 幂等(连调两次不重复关)。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py -q` → PASS;`make check` 绿;全套件绿。
|
||||||
|
|
||||||
|
- [x] 提交: `fix: 统一资源所有权纪律(谁建的谁关),修 aclose 越权与 redis 客户端泄漏`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T2 — C 组基础设施: 状态快照 + tracker + 出口
|
||||||
|
|
||||||
|
**动**: `src/polygateway/types.py`、`ports.py`、**新建** `telemetry/status.py`、`telemetry/postgres.py`、`telemetry/sqlite.py`、`client.py`、`embedding.py`、`ocr.py`;测试 `tests/unit/test_telemetry.py`、`test_ports.py`、`test_client.py`。
|
||||||
|
|
||||||
|
**为什么排在 A/B 组之前**: T3/T5 的所有降级点都要向 tracker 报告。先建 tracker 则那两步直接写成最终形态,反之要返工一遍日志代码。
|
||||||
|
|
||||||
|
**要实现的行为**:
|
||||||
|
1. `TelemetryStatus` 与 `TelemetryStatusProvider` 按上文"关键接口"定死。**`TelemetryRecorder` 一字不动**。
|
||||||
|
2. `TelemetryStatusTracker` 状态机: `enter_degraded` 打一条 warning(含原因与恢复条件: 冷却剩余秒数,或 fatal 时写明"需改配置并重启");降级期间 `record_drop` **节流复述**(按丢弃行数与时间双阈值,阈值为模块常量);`recover` 打一条 info 并报告"期间丢弃 N 行";`should_retry` 是纯查询(fatal → False,冷却未到 → False)。
|
||||||
|
3. 两个 recorder 各持一个 tracker,把**今天已有的**降级点接上去: PG 的建池失败与判死、SQLite 的初始化失败。**SQLite 侧同时补上今天缺失的那条 warning**——`sqlite.py:138-139` 初始化失败后写入直接 `return`,连一条日志都没有。
|
||||||
|
4. 出口 `telemetry_status` 属性加到三个 client,取值经**一处** `isinstance(self._telemetry, TelemetryStatusProvider)` 判定,不满足或无遥测则返回 `None`。
|
||||||
|
|
||||||
|
**本任务不改任何失败判据**: PG 侧仍是"建池失败即永久判死",只是这次判死会经 tracker 变得可见。判据在 T5 改。这样本任务的行为变更面收敛为"日志更可见 + 多一个只读出口"。
|
||||||
|
|
||||||
|
**过渡期状态并存(有意,且必须在 T5 收掉)**: 本任务结束时 PG 侧的 `_failed` 布尔与 tracker 的 fatal 状态**并存**——判死点两边都写。这是为了让 T2 能独立全绿提交,不是最终形态;T5 删除 `_failed`,状态收归 tracker 一处。两份状态只允许存活这一个任务的跨度,拖久了必然漂移。
|
||||||
|
|
||||||
|
**契约检查点**: `tests/unit/test_ports.py:137,141` 的 `isinstance(_DummyRecorder(), TelemetryRecorder)` 断言必须**保持绿**——它是"没把状态并进主 Protocol"这条决策的机械化执法点,新增用例不得替代它。
|
||||||
|
|
||||||
|
**测试要求**(先失败后通过):
|
||||||
|
- tracker 状态机六字段逐个钉: 未降级 → `degraded=False` 且三个可空字段为 None;进入降级 → `reason`/`retry_after_s` 正确;假时钟推进 → `degraded_for_s` 增长、`retry_after_s` 递减到 0;`recover` → 回到未降级且 `dropped_rows` **不清零**(进程生命周期内单调不减);
|
||||||
|
- 节流复述: 连续 N 次 `record_drop` 只产生 M 条 warning(loguru sink 捕获断言),且 N 与 M 的关系由常量决定而非硬编码数字;
|
||||||
|
- fatal 档: `should_retry()` 恒 False,`retry_after_s` 为 None;
|
||||||
|
- SQLite 初始化失败(指向不可写目录)→ 有 warning **且** `telemetry_status.degraded is True`(今天必红,连 warning 都没有);
|
||||||
|
- 三个 client 的 `telemetry_status`: 无遥测 → None;注入不实现该 Protocol 的假 recorder → None(不得抛 AttributeError);内置 recorder → 返回快照。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_client.py -q` → PASS;`lint-imports` 绿(新文件 `telemetry/status.py` 在实现层,只许依赖 `types`/`ports`/标准库,**不得**被 `transports`/`backends` import);全套件绿。
|
||||||
|
|
||||||
|
- [x] 提交: `feat: 遥测降级升格为一等状态(共用 tracker + 只读快照 + 节流日志)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T3 — A 组: 池语义与两个新配置键
|
||||||
|
|
||||||
|
**动**: `src/polygateway/config.py`、`client.py`(`_build_telemetry`)、`telemetry/postgres.py`;测试 `tests/unit/test_config.py`、`test_telemetry.py`、**`tests/integration/test_postgres_telemetry.py`**。
|
||||||
|
|
||||||
|
> **本任务必须一次改完全部 20 处 `PostgresRecorder(` 构造点**(Codex 审查,已实测复核): `src/polygateway/client.py` 1 处 + `tests/unit/test_telemetry.py` 3 处 + **`tests/integration/test_postgres_telemetry.py` 16 处**。新签名的 `pool_max`/`write_timeout_s` 是 keyword-only **必填**,漏一处就 `TypeError`,而提交门跑的是**全套件**——集成测试那 16 处不能拖到 T6,否则 T3 根本提交不了。这是 `auto_migrate` 当初(issue #13)踩过的同一形态: 必填 keyword-only 的代价就是所有构造点同批改。
|
||||||
|
|
||||||
|
**要实现的行为**:
|
||||||
|
1. 两个新配置键按"关键接口"那张表落地: `_load_pgw` 里读取(模板照 `config.py:524-543` 的 `_load_text_cap`),值域校验落 `_validate_telemetry`(与 `telemetry_text_cap` 同一先例,**一次覆盖直接构造 / `dataclasses.replace` / env 三条路**),报错文本同时点字段名与 env 键名。`_build_telemetry`(`client.py:405-420`)把两个值透传给 recorder。
|
||||||
|
2. 建池改为 `create_pool(dsn, min_size=0, max_size=pool_max, timeout=write_timeout_s, command_timeout=write_timeout_s)`。
|
||||||
|
3. **两处** `acquire` 都改为**显式** acquire/release,**不得**用 `async with pool.acquire(...)`——`_prepare_schema`(`postgres.py:114`)与 `record_llm_call`(`postgres.py:238`)。准备期同样在预算内、同样吃 shielded release 那一刀,只改一处等于留了半个坑:
|
||||||
|
- `con = await pool.acquire(timeout=write_timeout_s)`(传**完整**预算: 真正的上界是外层 `asyncio.timeout`,内层再算一次剩余量等于把同一个上界写两遍);
|
||||||
|
- `finally: await pool.release(con, timeout=<小的独立上限>)`,释放超时则 `con.terminate()`;
|
||||||
|
- 整次写入(准备 + acquire + execute)由 `asyncio.timeout(write_timeout_s)` 包一层。
|
||||||
|
|
||||||
|
**理由(设计 §3.1,已核实)**: `Pool.release()` 是 `await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`)。外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行 `__aexit__`,此时没有新的 cancel 投递,那个 shielded release 会**正常等到完成**——用 `async with` 的真实上界是 ≈ 2 × 预算。
|
||||||
|
|
||||||
|
**必须同步改造 `_FakePgPool`**(`tests/unit/test_telemetry.py:751`): 它今天的 `acquire()` **无参**且只返回一个 `_Ctx` 异步上下文管理器,没有 `release`。改造为接受 `timeout=` 并提供 `release(con, timeout=)`,同时记录 acquire/release 的配对次数(T3 与 T5 的用例都要用)。不改造则全部 PG 用例当场红。
|
||||||
|
|
||||||
|
**取消穿透的实现纪律**(铁律): 降级路径(节流日志、tracker 更新、release 收尾)一律不得 `except CancelledError` 而不 re-raise;`except TimeoutError` 必须排在 `except Exception` 之前;严禁裸 `except BaseException`。既有 `postgres.py:101-102` 的 `except asyncio.CancelledError: raise` 写法是对的,延续它。
|
||||||
|
|
||||||
|
**测试要求**(先失败后通过。注意: 建池路径**今天零覆盖**,这里要建立第一个用例):
|
||||||
|
- **主回归钉子**: monkeypatch `asyncpg.create_pool`,断言实参 `min_size == 0` 且 `max_size == 配置值`。这一条防的是回归到继承第三方默认值,是本 issue 的核心;
|
||||||
|
- 配置键三条装配路: env 路读取正确、缺省为 4 / 5.0、直接构造与 `replace` 同样被校验拦住(`pool_max=0`、`write_timeout_s=0` 各一条,断言报错文本含字段名与键名);
|
||||||
|
- 硬预算: 假 pool 的 acquire 挂住 → 丢一行且耗时 ≤ 预算(用假时钟或极小预算,**不要**在用例里真睡 5 秒);
|
||||||
|
- **release 不泄漏**(Codex 审查钉子): `execute` 被预算取消后,断言 `_FakePgPool` 记录的 acquire/release 次数**配对**;
|
||||||
|
- 外部 `CancelledError` 在预算内**不**被吞成 `TimeoutError`(直接钉铁律)。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_config.py tests/unit/test_telemetry.py -q` → PASS;`make check` 绿;全套件绿。
|
||||||
|
|
||||||
|
- [x] 提交: `feat: 遥测池显式声明资源占用(min_size=0/max_size 可配)并给写入硬预算`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T4 — B 组之一: 有界关闭
|
||||||
|
|
||||||
|
**动**: `src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`。
|
||||||
|
|
||||||
|
**为什么单列一个任务**: 它与 T5 的失败判据无关,但同属"收尾路径的隐性无界等待",且能独立验证。合进 T5 会让那次提交同时动判据与关闭两件事,回滚粒度变粗。
|
||||||
|
|
||||||
|
**要实现的行为**: `aclose()` 语义钉死为"关了就是关了"——置 `_closed`,此后写入短路且**不复活**(取消今天"关完还能自己重建池"的灰色状态);关闭动作本身走 `asyncio.wait_for(pool.close(), timeout=...)`,超时后 `pool.terminate()`,外部取消照常穿透。
|
||||||
|
|
||||||
|
**理由(已核实)**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时**无限等**,60 秒只发一条 warning(asyncpg `pool.py:939-948, 961-972`);asyncpg 自己的 docstring 就写着 "advisable to use `asyncio.wait_for` to set a timeout"。
|
||||||
|
|
||||||
|
**测试要求**(先失败后通过):
|
||||||
|
- 假 holder 永不 release → `aclose()` 在超时后走 `terminate()` 返回,**不无限挂**(今天必红/挂死,用例须自带超时保护);
|
||||||
|
- `aclose` 后再 `record_llm_call` → 直接短路,**不重建池**(断言 `create_pool` 未被再次调用);
|
||||||
|
- `aclose` 幂等;注入的外部池仍**不**被关(`_external_pool` 既有纪律不得破)。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;全套件绿。
|
||||||
|
|
||||||
|
- [x] 提交: `fix: 遥测池关闭有界化(wait_for + terminate),关闭后不再复活`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T5 — B 组之二: 失败三分与冷却降级(本 issue 的核心)
|
||||||
|
|
||||||
|
**动**: `src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`。
|
||||||
|
|
||||||
|
**要实现的行为**:
|
||||||
|
1. 新增模块级 `_classify_failure`(按"关键接口"的三档表),全库唯一一处 PG 失败分类。
|
||||||
|
2. 三个降级点改为按分类处置: `_open_pool`、`_prepare_schema`/`_prepare_table`、`record_llm_call`。
|
||||||
|
- `_FATAL` → 永久 no-op + 一条 **error**(不是 warning: 这是人配错了),经 tracker 置 `fatal=True`;
|
||||||
|
- `_UNAVAILABLE` → `tracker.enter_degraded(cooldown_s=_DEGRADE_COOLDOWN_S)`,此后 `_ensure_ready` 开头零成本短路(只比较时间戳,不触库),到期 `should_retry()` 放行**一次**重新准备,成功即 `tracker.recover()`;
|
||||||
|
- `_ROW` → 逐条 warning 丢弃 + `tracker.record_drop()`,不降级。
|
||||||
|
3. **删除 `_failed` 这个布尔**,状态收归 tracker 一处(否则两份状态必然漂移)。实测引用分布(执行时可自行复核): `src/polygateway/telemetry/postgres.py` **7 处**(74/79/84/106/124 是代码,209/211 在 `_backfill_columns` 的 docstring 里——**文档也要改**,否则留下指向已删字段的说明)、`tests/unit/test_telemetry.py` **6 处**、`tests/integration/test_postgres_telemetry.py` **6 处**,测试侧一并改为读 `telemetry_status` 快照。
|
||||||
|
4. 判据的两条既有承诺不得破:
|
||||||
|
- **`42703` 仍走 `_ROW`**(issue #13: manual 档缺列时裁剪 INSERT 继续写、逐行暴露)。这是判据的**唯一具名例外**,代码里必须有注释写明它是例外及理由;
|
||||||
|
- **`_prepare_table` 的 `to_regclass` 先探测、表在就不发 DDL** 这段控制流(`postgres.py:147-157`)一行不动(issue #9)。
|
||||||
|
|
||||||
|
**圈复杂度检查点**: 本任务是三个降级点同时改,`record_llm_call` 与 `_ensure_ready` 最容易触到 radon 的 C 档而被提交门阻塞。逼近就抽私有方法(如 `_handle_failure(exc, *, stage)` 收敛三处处置)——这是提交门的硬要求,不算计划外重构。
|
||||||
|
|
||||||
|
**测试要求**(先失败后通过,分档逐个钉):
|
||||||
|
- **issue 场景直接回归**: 建池阶段抛 `TooManyConnectionsError`(53300)→ **不** fatal、进冷却降级 → 假时钟推进 60s → 下次调用自动恢复并成功写入。今天这一条必红(现状是永久判死);
|
||||||
|
- `ClientConfigurationError` → fatal + 一条 error + 此后零成本短路(断言不再调 `acquire`);
|
||||||
|
- **分档边界两侧各钉一次**: `42501`/`42P01` → 进冷却降级;`42703` → 行级丢弃且**不**进降级;
|
||||||
|
- `_prepare_table` 建表失败(表确定不存在)→ 冷却降级(不再是永久判死),DBA 建表后自动恢复;
|
||||||
|
- 探测失败(既有 `probe_errors` 路径)仍只跳过本次、下次重试,**不**降级(issue #9 既有行为不得回归);
|
||||||
|
- 全部现有 PG 用例保持绿(它们钉的是 issue #3/#9/#13 的承诺)。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;`radon cc src/polygateway/telemetry/postgres.py -n C -s` → 无输出;全套件绿。
|
||||||
|
|
||||||
|
- [x] 提交: `fix: 遥测失败按性质三分,永久判死收窄到 DSN 不可解析,其余带冷却自愈`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T6 — 真实 PG 集成验证
|
||||||
|
|
||||||
|
**动**: `tests/integration/test_postgres_telemetry.py`。
|
||||||
|
|
||||||
|
**纪律(该文件既有,不得破)**: `llm_calls` 是与真实批跑共享的表,**严禁 DROP/TRUNCATE**;以 run 级 `call_id` 前缀隔离,teardown 只删自己的行;DSN 缺失则 skip;不标 `slow`(与该文件既有用例一致)。
|
||||||
|
|
||||||
|
**要实现的行为(用例)**:
|
||||||
|
1. **issue 的直接回归钉子**: 建 recorder 后本池连接数为 **0**,一次写入后 **≤1**,稳态 ≤ `pool_max`。
|
||||||
|
2. 降级与恢复走**不可达 DSN** 的 recorder 验证(连接被拒 → 降级 → 假时钟/短冷却后重试),**不去动共享实例的 `max_connections`**。
|
||||||
|
|
||||||
|
**计数必须按唯一 `application_name` 过滤**,该实例被多项目共用,按库名或用户名计数会被别人的连接污染——那样的用例是**设计上就会间歇红**的信号污染源(CLAUDE.md §4.6)。
|
||||||
|
|
||||||
|
**怎么设这个 tag(Codex 指出原稿这里无法执行,已实测给出解法)**: recorder 的构造签名**没有** `server_settings`/`connect_kwargs` 入口,原稿那句"经 `server_settings=` 建池"落不了地。解法是走 **DSN 查询参数**——给 recorder 一个 `f"{dsn}?application_name={run级唯一值}"`,其余一切不变。
|
||||||
|
|
||||||
|
- 已实测(2026-08-24,真实实验室 PG): `create_pool(dsn + "?application_name=pgwtest-abc123", min_size=0, ...)` 后 `SHOW application_name` 返回该值,`pg_stat_activity` 按它过滤得连接数 1,`pool.close()` 后归零。
|
||||||
|
- **不要**改用"测试自建池后以 `pool=` 注入": 那会走 `_external_pool=True` 分支、**完全绕过被测的建池路径**,而本任务要验的恰恰是自建池不预连接。
|
||||||
|
- **不要**为此给 recorder 加 `server_settings` 入口: 纯测试便利不值得扩公共 API(P1)。
|
||||||
|
- 注意 `config.py` 的 `_strip_dsn_driver` 只动 scheme 的 `+driver` 后缀,不碰查询参数;且集成测试直接构造 recorder、不经 config,两条路都不受影响。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/integration/test_postgres_telemetry.py -q` → PASS(或无 DSN 时全 skip);全套件绿。
|
||||||
|
|
||||||
|
- [x] 提交: `test: 真实 PG 验证遥测池不预连接与降级自愈`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T7 — 文档、配置面与发布说明
|
||||||
|
|
||||||
|
**动**: `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`。
|
||||||
|
|
||||||
|
**要实现的行为**:
|
||||||
|
1. `.env.example`: 两个新键写在 `PGW_TELEMETRY_PG_DSN` 之后,沿用该文件既有的"键 + 缩进注释块讲清为什么"风格。`pool_max` 必须给**调参口径**: 写**实测值**而非 `pool_max / RTT`(T3 实测该公式乐观一倍,见设计 §10 修订 #1)——跨内网 RTT ≈ 123ms 上 `pool_max=4` 约 **15.6 行/秒**(50 行并发批 3.2s),并写明"共享一个 recorder 给多 client 时并发汇聚,应相应放大"。
|
||||||
|
2. `README.md`: 配置表加两键;能力表反映"遥测降级可恢复 + 可查询状态";**核对安装命令里的版本约束**(发布清单第 1 步的老账: `==1.2.*` 这类极易漏改)。
|
||||||
|
3. `ARCHITECTURE.md` §7.8 增补三条: 遥测池的资源语义(为何 `min_size=0`、为何不暴露 `min_size`)、失败三分的**两句判据**、**资源所有权纪律**(后者应作为跨子系统的通用纪律成文,而非遥测局部约定);§9 登记两个新键。
|
||||||
|
4. `CHANGELOG.md`: 记在"未发布"下,三处"请先读这一条": ①最低 Python 提到 3.12(**唯一会让下游装不上**的变更);②遥测常驻连接从 `10 × client 数` 变按需(监控曲线会突变);③`aclose` 不再关闭注入的组件。
|
||||||
|
|
||||||
|
**验证**: `make check` 绿;人工通读 `.env.example` 两键注释,确认调参口径可执行。
|
||||||
|
|
||||||
|
- [x] 提交: `docs: 遥测池资源语义、失败判据与所有权纪律成文`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成判据(合并前)
|
||||||
|
|
||||||
|
- [x] T0-T7 全部提交完成,每次提交都过了提交门(ruff + radon + 全套件)
|
||||||
|
- [ ] `pytest -m slow` 单独跑过一次(发布清单第 4 步;本次改动触及遥测写入路径,e2e 与 Redis 时间语义变体必须实测)
|
||||||
|
- [ ] 派**全新上下文**的 verifier subagent 独立验证(`verification-before-completion`,里程碑级/合并前 MANDATORY)
|
||||||
|
- [ ] 整分支审查(`requesting-code-review`,合并前 MANDATORY)
|
||||||
|
- [ ] 设计文档 §5 的每一条测试要求都能指到一个具体用例(逐条对照,不是"大致覆盖")
|
||||||
|
|
||||||
|
## 审查留痕(Codex,2026-08-24)
|
||||||
|
|
||||||
|
**Status: Issues Found → 2 条阻断级均已修订,2 条 Recommendation 采纳 1 条。**
|
||||||
|
|
||||||
|
| # | 结论 | 落点 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **采纳(阻断)**。新签名的 `pool_max`/`write_timeout_s` 是必填 keyword-only,而 `PostgresRecorder(` 共 **20 处**构造点,其中 **16 处在集成测试**。原稿 T3 只列了两个单元测试文件,漏掉的那 16 处会让 T3 的提交门(跑全套件)当场红 | T3 "动"一节 |
|
||||||
|
| 2 | **采纳(阻断),并给出比建议更好的解法**。原稿 T6 写"经 `server_settings=` 建池"设唯一 `application_name`,但 recorder 签名根本没有这个入口,零上下文执行者会卡死。Codex 给的两条出路(注入外部池 / 加 recorder 入口)都有代价——前者绕过被测的建池路径,后者为测试便利扩公共 API。**实测发现第三条**: `?application_name=<tag>` 走 DSN 查询参数,asyncpg 认、PG 侧生效、关池后计数归零,**零 API 改动且真实覆盖建池路径** | T6 计数一节 |
|
||||||
|
| 3 | **采纳(建议)**。`_failed` 计数原稿写"测试 13 处"不准。实测: 源码 7 处(**含 2 处在 docstring 里**,文档也要改)、unit 6 处、integration 6 处 | T5 第 3 点 |
|
||||||
|
| 4 | 无需动作。Codex 复核确认了计划的两条硬断言: 建池路径零覆盖(`rg create_pool\|_open_pool tests` 无匹配)、`_FakePgPool` 定义于 `:751-765` 且只经三个 helper 注入(故改造类本身即可覆盖既有假池用例) | — |
|
||||||
|
|
||||||
|
Codex 给的 `_failed` 分布数字(源码 5 处 / 测试断言 8 处)与本地实测(源码 7 / unit 6 / integration 6)不一致,以实测为准——它漏了 docstring 里那两处,而那两处恰恰是**必须改**的(留着就是指向已删字段的说明)。
|
||||||
|
|
||||||
|
## 实际提交(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`)
|
||||||
|
|
||||||
|
| 任务 | hash | message 首行 |
|
||||||
|
|---|---|---|
|
||||||
|
| T0 ① | `157a27f` | `chore: require python 3.12 and adopt PEP 695 type parameters` |
|
||||||
|
| T0 ② | `e7caa50` | `docs: plan the telemetry pool lifecycle rework for issue 15` |
|
||||||
|
| T1 | `e69ca4c` | `fix: make every client close what it built and nothing else` |
|
||||||
|
| T2 | `f958138` | `feat: make telemetry degradation a first-class state` |
|
||||||
|
| T3 | `84c2cc1` | `feat: make the telemetry pool declare what it costs` |
|
||||||
|
| T4 | `bc071c6` | `fix: make closing the telemetry pool bounded and final` |
|
||||||
|
| T5 | `eef2fdc` | `fix: judge telemetry failures by nature, not by step` |
|
||||||
|
| T6 | `bfeda5b` | `test: prove on real PG that the pool never preconnects` |
|
||||||
|
| T7 ⓪ | `69a5b5f` | `test: pin the cooldown assertion to a fake clock`(T5 留下的一处间歇红: 快照里的 `retry_after_s` 是时间差,却用真实时钟断言 60.0) |
|
||||||
|
| T7 ① | `7834d75` | `feat: export TelemetryStatus from the package root` |
|
||||||
|
| T7 ② | `4e1f09d` | `docs: record the telemetry pool semantics and ownership rule`(本表的 hash 由紧随其后的一次 bookkeeping 提交补齐) |
|
||||||
|
| T8 ① | `f90f7b0` | `test: give the log level and ownership rules real enforcement` |
|
||||||
|
| T8 ② | `6d6b3cf` | `docs: correct the stale throughput numbers and wiki state`(本行 hash 由紧随其后的 bookkeeping 提交补齐) |
|
||||||
|
|
||||||
|
**T8 不在原计划内**: 它是合并前独立验证(全新上下文 verifier)报出的 5 个问题的处置——2 条"确证的假绿"(日志级别与所有权判定各自没有执法点)+ 2 处过时数字/措辞 + 1 处 wiki 状态漂移。详见设计 §10 修订 #4/#5。
|
||||||
|
|
||||||
|
T7 分两次提交是因为它含一处**公共 API 面**改动(`TelemetryStatus` 进顶层 `__all__`,决策见下),与纯文档的回滚粒度不同。
|
||||||
|
|
||||||
|
**T7 执行期追加的决策与发现**(计划原稿只列了四项文档任务):
|
||||||
|
|
||||||
|
| # | 内容 | 落点 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `TelemetryStatus` 进 `polygateway.__all__`。issue #15 的核心诉求之一是下游能**编程对账**,而 `client.telemetry_status` 的返回类型若不能从顶层 import,下游做类型标注就得深入 `polygateway.types`——与"顶层导出即公共 API 面"的约定冲突。T2 参照的 `SourceStats` 先例**不适用**: 那是端口内部快照、下游不消费。端口 `TelemetryStatusProvider` 仍不导出 | `__init__.py`、`tests/unit/test_package.py`、ARCH §7.8 |
|
||||||
|
| 2 | 吞吐算术更正为实测值(15.6 行/秒),`.env.example` / README 的调参口径按实测写 | 设计 §3.5/§6/§10 |
|
||||||
|
| 3 | "重试建池已零成本"这条红利与"关闭后偷偷复活"这个 bug 分别补进设计 §3.2 / §1.5 | 设计 §10 |
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-08-25-thinking-observability-plan
|
||||||
|
title: "推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)"
|
||||||
|
date: 2026-08-25
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)
|
||||||
|
|
||||||
|
> 类型:plan|日期:2026-08-25|实现设计:`designs/2026-08-25-thinking-observability-design.md`(已经人类批准)
|
||||||
|
> 事实基础:`findings/2026-08-25-thinking-observability-regression.md`
|
||||||
|
> **保真校验不适用**:本计划不涉及 `reference/` 三项目的迁移,推理开关是库自有子系统,不在 ARCHITECTURE.md §1.4 关键资产索引的移植蓝本内。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让"这次推理到底发生没发生"成为库的一等返回值,由多信号裁定,判不出来时如实说 UNKNOWN,并与能力表持续对账。
|
||||||
|
|
||||||
|
## 方案概述
|
||||||
|
|
||||||
|
新增 `ThinkingObservation` 三态枚举(定义在最内层 `types.py`)与裁定纯函数 `observe_thinking`(决策层 `thinking.py`),由 transport 在组装结果时裁定并与请求方向对账,结果随 `LLMResponse` 返回、随遥测落库。同时把推理决策从 `providers.py` 拆进新模块 `thinking.py`,并把公共符号提升到包根导出。
|
||||||
|
|
||||||
|
涉及技术:Python 3.12 `StrEnum`、frozen dataclass、`inspect.signature` 冻结测试、import-linter 分层契约、SQLite/PG schema backfill。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
**新建**
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `src/polygateway/thinking.py` | 推理这件事的全部**决策**:能力表、`resolve_thinking`(请求侧注入)、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义** |
|
||||||
|
| `tests/unit/test_thinking.py` | 裁定与对账的单元测试 |
|
||||||
|
|
||||||
|
**修改**
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|---|---|
|
||||||
|
| `src/polygateway/types.py` | 新增 `ThinkingObservation`;`LLMResponse` / `TransportResult` 各增一字段 |
|
||||||
|
| `src/polygateway/providers.py` | 收缩为纯注册表 |
|
||||||
|
| `src/polygateway/ports.py` | `record_llm_call` 24 参 → 25 参 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | 裁定 + 对账 |
|
||||||
|
| `src/polygateway/middleware/retry.py` | 透传 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage` + 三个 `emit_*` + `_record` |
|
||||||
|
| `src/polygateway/middleware/cache.py` | `_rehydrate` 枚举复活 |
|
||||||
|
| `src/polygateway/telemetry/schema.py` | 新列 + 两端 DDL + 两份 backfill |
|
||||||
|
| `src/polygateway/telemetry/sqlite.py`、`postgres.py` | 实现新参 |
|
||||||
|
| `src/polygateway/client.py` | import 路径 |
|
||||||
|
| `src/polygateway/__init__.py` | 包根导出 + 版本号 |
|
||||||
|
| `pyproject.toml` | import-linter 契约加层 + 版本号 |
|
||||||
|
| 测试 9 个、文档 5 个 | 见各任务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1:`ThinkingObservation` 与裁定纯函数
|
||||||
|
|
||||||
|
**文件**:创建 `src/polygateway/thinking.py`、`tests/unit/test_thinking.py`;修改 `src/polygateway/types.py`、`pyproject.toml`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
在 `types.py` 新增(放在 `LLMResponse` 定义**之前**,因为它是其字段类型):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ThinkingObservation(StrEnum):
|
||||||
|
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
|
||||||
|
|
||||||
|
三态不可折叠为布尔: `UNKNOWN` 是"本次无任何信号,判不出来",与
|
||||||
|
`ABSENT`("上游明确上报未推理")语义不同。把前者折叠进后者,正是
|
||||||
|
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",
|
||||||
|
而实际可能推理了且已计费(MiniMax-M3 非流式实测)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
OBSERVED = "observed"
|
||||||
|
ABSENT = "absent"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
```
|
||||||
|
|
||||||
|
在新建的 `thinking.py` 实现(本任务只放这一个函数,搬迁留给 Task 2):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def observe_thinking(
|
||||||
|
*, thinking: str, reasoning_tokens: int | None
|
||||||
|
) -> ThinkingObservation:
|
||||||
|
"""由多信号裁定推理是否发生;判据按证据硬度排序。
|
||||||
|
|
||||||
|
推理正文是事实本身,token 计数是对事实的转述——转述缺失时事实仍然作数。
|
||||||
|
"""
|
||||||
|
if thinking.strip():
|
||||||
|
return ThinkingObservation.OBSERVED
|
||||||
|
if reasoning_tokens is None:
|
||||||
|
return ThinkingObservation.UNKNOWN
|
||||||
|
return (
|
||||||
|
ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`pyproject.toml` 的 import-linter 契约 `layers` 插入一层,位置在实现层与 `providers` 之间:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
layers = [
|
||||||
|
"polygateway.client",
|
||||||
|
"polygateway.config",
|
||||||
|
"polygateway.middleware",
|
||||||
|
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
||||||
|
"polygateway.thinking",
|
||||||
|
"polygateway.providers : polygateway.sources",
|
||||||
|
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
层序理由:`thinking.py` 要 import `providers.py` 的 `ProviderProfile`(故在其上),被 `transports/` 与 `client.py` import(故在其下)。**枚举放 `types.py` 而非 `thinking.py`,正是为了让最内层不反向依赖决策层**——这是本任务最容易做错的一步,写反了 import-linter 会判红。
|
||||||
|
|
||||||
|
### 测试要求(先失败后通过)
|
||||||
|
|
||||||
|
`tests/unit/test_thinking.py` 覆盖裁定五种输入:正文非空 → OBSERVED;**纯空白正文 + `reasoning_tokens=None` → UNKNOWN**(不得因 truthy 判成 OBSERVED);`reasoning_tokens=5` → OBSERVED;`reasoning_tokens=0` → ABSENT;`reasoning_tokens=None` 且正文空 → UNKNOWN。再加一条优先级用例:正文非空且 `reasoning_tokens=0` → OBSERVED(正文压倒转述)。
|
||||||
|
|
||||||
|
`tests/unit/test_types.py` 加一条:`ThinkingObservation` 定义在 `polygateway.types` 模块内(`ThinkingObservation.__module__ == "polygateway.types"`),防止后续任务把它挪回决策层。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_types.py -v
|
||||||
|
conda run -n PolyGateway lint-imports
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:新测试全 PASS;`lint-imports` 全部契约 KEPT。
|
||||||
|
|
||||||
|
- [ ] Task 1 提交:`feat: judge whether reasoning actually happened from multiple signals`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2:把推理决策从 `providers.py` 搬进 `thinking.py`
|
||||||
|
|
||||||
|
**文件**:修改 `src/polygateway/thinking.py`、`src/polygateway/providers.py`、`src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/__init__.py`、`tests/unit/test_providers.py`、`tests/unit/test_package.py`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
从 `providers.py` **原样移入** `thinking.py`(纯移动,不改逻辑):`ThinkingUnsupportedError`、`ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`、`_warn_unregistered`。
|
||||||
|
|
||||||
|
`providers.py` 保留:`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`、`register_provider`。其模块 docstring 改为只讲注册表职责;`thinking.py` 的模块 docstring 说明它承载推理的全部决策而枚举归 `types.py`。
|
||||||
|
|
||||||
|
更新 import:`client.py`(`from polygateway.providers import get_capability, get_provider, resolve_thinking` 拆成两行)、`transports/openai_compat.py`、`client.py` 的 `TYPE_CHECKING` 块里 `ThinkingCapability` 的来源。
|
||||||
|
|
||||||
|
`__init__.py` 新增包根导出并加进 `__all__`(该列表**不是严格字母序**——`DEFAULT_PROFILES` 现在就排在 `AllSourcesExhausted` 前面;沿用文件既有排列,把新符号插到同类符号附近即可):`ThinkingCapability`、`ThinkingObservation`、`ThinkingUnsupportedError`、`get_capability`、`register_capability`、`resolve_thinking`。
|
||||||
|
|
||||||
|
`tests/unit/test_providers.py` 里针对被搬走符号的测试,整体移入 `tests/unit/test_thinking.py`。
|
||||||
|
|
||||||
|
### 测试要求(先失败后通过)
|
||||||
|
|
||||||
|
`tests/unit/test_package.py` 比照既有 `TelemetryStatus` 用例,加一条断言六个新符号可从包根 import 且在 `__all__` 内——该测试在导出落地前必然红。
|
||||||
|
|
||||||
|
搬迁本身的回归证据:搬迁前后 `pytest tests/unit -q` 通过数不减(搬迁是纯移动,任何行为差异都是 bug)。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit -q
|
||||||
|
conda run -n PolyGateway lint-imports
|
||||||
|
conda run -n PolyGateway python -c "from polygateway import ThinkingObservation, ThinkingCapability, resolve_thinking; print('ok')"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全 PASS;契约 KEPT;import 成功。
|
||||||
|
|
||||||
|
- [ ] Task 2 提交:`refactor: give reasoning decisions their own module`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3:字段落到响应类型并贯通调用链
|
||||||
|
|
||||||
|
**文件**:修改 `src/polygateway/types.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`;测试 `tests/unit/test_types.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
`TransportResult` 与 `LLMResponse` 各新增字段,**必须加在各自字段列表末尾且带默认值**(`LLMResponse` 是被三项目消费的公共类型,只增不删且不得改变既有位置参数顺序):
|
||||||
|
|
||||||
|
```python
|
||||||
|
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||||
|
```
|
||||||
|
|
||||||
|
`LLMResponse` 侧补 docstring:`UNKNOWN` = 本次无信号判不出,**不是**"没推理";非流式路径下部分模型推理已计费却不回传正文(M3 实测 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。
|
||||||
|
|
||||||
|
`transports/openai_compat.py` 的两条组装路径(流式 `_complete_stream` 的 463-475 行、非流式 `_complete_once` 的 548-560 行)在构造 `TransportResult` 时调 `observe_thinking(thinking=thinking, reasoning_tokens=...)` 填入。两条路径都要填——**只填一条正是 L5 要抓的那类分叉**。
|
||||||
|
|
||||||
|
`middleware/retry.py` 的 `_build_response`(372-393 行)透传 `thinking_observation=result.thinking_observation`。
|
||||||
|
|
||||||
|
### 测试要求(先失败后通过)
|
||||||
|
|
||||||
|
`tests/unit/test_types.py`:两个类型的默认值均为 `ThinkingObservation.UNKNOWN`;`LLMResponse` 既有位置构造方式不破(沿用文件内既有的构造用例形态)。
|
||||||
|
|
||||||
|
`tests/unit/test_openai_compat.py`:用既有的 SSE / JSON 响应装置,构造三种响应各断言一次——含 `reasoning_content` 增量 → `OBSERVED`;无推理信号 → `UNKNOWN`;`usage.completion_tokens_details.reasoning_tokens=0` → `ABSENT`。流式与非流式各一组。
|
||||||
|
|
||||||
|
`tests/unit/test_retry.py`:比照既有透传测试,断言 transport 返回的 `thinking_observation` 原样出现在 `LLMResponse` 上。
|
||||||
|
|
||||||
|
以上在字段落地前全部红(属性不存在)。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全 PASS。
|
||||||
|
|
||||||
|
- [ ] Task 3 提交:`feat: carry the reasoning verdict through to LLMResponse`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4:对账告警(声明 × 观测)
|
||||||
|
|
||||||
|
**文件**:修改 `src/polygateway/thinking.py`、`src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_thinking.py`、`tests/unit/test_openai_compat.py`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
`thinking.py` 新增对账纯函数,返回告警文案或 `None`(**判定与日志分离**,这样告警内容可被单测直接断言,不必去解析日志):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def reconcile_thinking(
|
||||||
|
*,
|
||||||
|
enable_thinking: bool | None,
|
||||||
|
observation: ThinkingObservation,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
model: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
|
||||||
|
|
||||||
|
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
|
||||||
|
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
判定矩阵(设计 §5):
|
||||||
|
|
||||||
|
| `enable_thinking` | observation | capability | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `False` | OBSERVED | 已登记 | 能力表漂移:声明可关闭,实测推理了。附 `capability.evidence` 与 `register_capability` 指路 |
|
||||||
|
| `False` | OBSERVED | `None` | 关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
|
||||||
|
| `True` | ABSENT | 任意 | 注入了开启参数,上游明确上报未推理 |
|
||||||
|
| `True` | UNKNOWN | 任意 | 推理参数已注入但本路径观测不到,无法确认是否生效;若为非流式路径,推理内容可能已计费却不回传 |
|
||||||
|
| 其余组合(含 `False`×UNKNOWN、`None`×任意) | | | `None` |
|
||||||
|
|
||||||
|
`False`×UNKNOWN 返回 `None` 是刻意的:`UNKNOWN` 没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。
|
||||||
|
|
||||||
|
`transports/openai_compat.py` 在组装完 `TransportResult` 后调用它,非 `None` 则 `logger.warning`,并按 `(model, enable_thinking)` 节流——新增实例级 `set`,与既有 `_warned_models` 同款形态,**不可复用同一个 set**(那个 set 语义是"未登记能力已告警过",混用会互相压制)。
|
||||||
|
|
||||||
|
### 测试要求(先失败后通过)
|
||||||
|
|
||||||
|
`tests/unit/test_thinking.py`:矩阵四行各断言返回非 `None` 且文案含模型名;三种不表态组合(`False`×UNKNOWN、`None`×OBSERVED、`True`×OBSERVED)断言返回 `None`;已登记 vs 未登记两行的文案**必须不同**(不得对未登记模型说"能力表声称可关闭")。
|
||||||
|
|
||||||
|
`tests/unit/test_openai_compat.py`:断言同一 `(model, direction)` 连调两次只出现一条 warning;换 direction 后再出一条。**不能用 `caplog`**——本项目日志走 loguru,不经标准 `logging`,`caplog` 抓不到;复用 `tests/unit/test_thinking.py` 的 `_warnings()`(`logger.add` 收集)。
|
||||||
|
|
||||||
|
> `reconcile_thinking` 必须定义在 `ThinkingCapability` **之后**:本模块没有 `from __future__ import annotations`,注解在 `def` 时求值,放在文件上部会 `NameError`。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_openai_compat.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全 PASS。
|
||||||
|
|
||||||
|
- [ ] Task 4 提交:`feat: warn when the capability table and reality disagree`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5:缓存回放复活枚举
|
||||||
|
|
||||||
|
**文件**:修改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
`_rehydrate` 走 `LLMResponse(**fields)`,JSON 里的 `"observed"` 会复活成**裸 `str`** 而非枚举实例,类型与注解分叉。在 `fields.update(...)` 之前显式转换:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if "thinking_observation" in fields:
|
||||||
|
fields["thinking_observation"] = ThinkingObservation(
|
||||||
|
fields["thinking_observation"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
非法值(旧版本缓存、人为污染)会抛 `ValueError`,由既有的 `except Exception` 吞成"按未命中回源"并 warning——降级方向正确,不需额外处理。
|
||||||
|
|
||||||
|
`_serialize` 无需改动:`StrEnum` 是 `str` 子类,`dataclasses.asdict` + `json.dumps` 直接可序列化。
|
||||||
|
|
||||||
|
### 测试要求(先失败后通过)
|
||||||
|
|
||||||
|
`tests/unit/test_cache.py`:写入一条 `thinking_observation=OBSERVED` 的响应后命中回放,断言 `isinstance(resp.thinking_observation, ThinkingObservation)`(改动前必然红——回放出来的是 `str`);再造一条 `thinking_observation` 为 `"bogus"` 的缓存值,断言按未命中回源。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_cache.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全 PASS。
|
||||||
|
|
||||||
|
- [ ] Task 5 提交:`fix: revive the reasoning verdict as an enum, not a bare string`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6:遥测新增一列(端口 → schema → recorder → emitter)
|
||||||
|
|
||||||
|
**文件**:修改 `src/polygateway/ports.py`、`src/polygateway/telemetry/schema.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_ports.py`、`tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
**端口**:`TelemetryRecorder.record_llm_call` 增 `thinking_observation: str`,**不设默认值**(该 Protocol 的既有纪律,docstring 已写明理由:库外无第三方实现者,带默认值会让 emitter 漏传时静默落默认)。参数加在 `meta` 之后。docstring 的"24 字段冻结"改为 25。
|
||||||
|
|
||||||
|
**schema**:`SQLITE_DDL` / `PG_DDL` 末尾加 `thinking_observation TEXT`;`SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` 各加 `("thinking_observation", "TEXT")`;`COLUMNS` 末尾加同名项。**新列必须排在最末**——旧表只能 ALTER 追加到末尾,插在中间会让新建库与补列库的物理列序分叉(该纪律的注释就在这两个常量上方)。
|
||||||
|
|
||||||
|
**recorder**:两个 recorder 的 `record_llm_call` 都是 `(self, **fields: object)` 形态(**不是**显式参数列表),按 `COLUMNS` / `self._columns` 从 `fields` 取值——新列因此**不需要改签名**,只要 `COLUMNS` 里有、emitter 传了,取值就自动到位。要做的是核对两处:取值是否严格按列序、manual 档列裁剪路径是否覆盖新列。`sqlite.py:146` docstring 的"24 字段冻结签名"改 25。
|
||||||
|
|
||||||
|
> 端口 `ports.py` 的 Protocol 是**显式 25 参**,而实现是 `**fields`——这不矛盾:Protocol 声明的是调用契约(emitter 必须按名传全),实现选择用 kwargs 收。改端口签名仍然必要,它是 emitter 侧的编译期约束与冻结测试的锚点。
|
||||||
|
|
||||||
|
**emitter**:`_AttemptUsage` 增 `thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN`(**内部字段用枚举类型**,裸 `str` 归一化只发生在下沉 recorder 那一步),`of()` 从 response 取;三个 `emit_*` 各传一行(`emit_terminal_failure` 传 `ThinkingObservation.UNKNOWN`——无响应可言,默认值本身不撒谎);`_record` 签名增一参并下沉给 recorder。**所有新增字段只经 `_record` 这一个出口抵达 recorder,不新开调用点**(铁律:遥测调用点收敛为单一 helper,该出口已存在)。`middleware/telemetry.py:135` 的"组装 24 字段"改 25。
|
||||||
|
|
||||||
|
**recorder 收到的必须是裸 `str`,不是枚举实例**:`_AttemptUsage.thinking_observation` 内部用 `ThinkingObservation` 类型,但 `_record` 下沉给 recorder 时取 `.value`。`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只会被降级成一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一先例(`ports.py` docstring 明载该分工:recorder 只落库,不做语义判断)。
|
||||||
|
|
||||||
|
### 数字断言逐处更新(漏一处即红)
|
||||||
|
|
||||||
|
| 位置 | 现值 → 新值 |
|
||||||
|
|---|---|
|
||||||
|
| `tests/unit/test_telemetry.py:37` `_EXPECTED_COLUMNS` | 末尾加 `thinking_observation` |
|
||||||
|
| `tests/unit/test_telemetry.py:184` INSERT 占位符串 | 补到 `$25` |
|
||||||
|
| `tests/unit/test_telemetry.py:210` | `len(COLUMNS) == 24` → `25` |
|
||||||
|
| `tests/unit/test_telemetry.py:633` docstring | 物理列 `23 → 25` 改为 `24 → 26` |
|
||||||
|
| `tests/unit/test_telemetry.py:642` | `== 25` → `== 26` |
|
||||||
|
| `tests/unit/test_telemetry.py:645` docstring | `25 个物理列` → `26 个` |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py:764` 注释 | `22 → 24 个 recorder 字段(加 created_at 共 25 个物理列)` 改为 `24 → 25 个(共 26 个物理列)` |
|
||||||
|
|
||||||
|
> 上表**不完整**——实施时实测另有 6 处漏改会当场把测试跑红:`_FROZEN_SQLITE_INSERT`(计划只点了 PG 那条)、`:586` 的 `_EXPECTED_COLUMNS[:-2]` → `[:-3]`、`TestBackendColumnParity` 的 `COLUMNS[-2:]` 断言、两处 `_CURRENT` 假列表(稳态不发 ALTER 的断言)、`PG_BACKFILL[-1]` 末位断言,以及 integration 侧 `:608` 的 `_PRE_TENANT_COLUMNS` 派生式。另有四处注释/docstring 的字段数会过期。**结论: 不要照表逐条打勾就收工,以"全套件绿"为准**。
|
||||||
|
|
||||||
|
> **不要改 `tests/unit/test_telemetry.py:1787`**:那里的"共 24 字"是 OCR 占位串 `<ocr:text image_bytes=3>` 的**字符数**,与遥测列数无关。全局替换"24"会误伤它。
|
||||||
|
|
||||||
|
### 测试要求(先失败后通过)
|
||||||
|
|
||||||
|
`tests/unit/test_ports.py`:现有 `TestTelemetryRecorderSignature` **并不冻结完整参数列表**——它只 parametrize 了 `["tenant_id", "meta"]` 两项,断言其无默认值且为 KEYWORD_ONLY。把 `thinking_observation` 加进该 parametrize 列表,断言同样三条——改端口前必然红。
|
||||||
|
|
||||||
|
`tests/unit/test_telemetry.py`:列数与列序断言(上表);新增一条 round-trip——记录一条 `thinking_observation=OBSERVED` 的调用后从 SQLite 读回该列等于 `"observed"`。
|
||||||
|
|
||||||
|
`tests/integration/test_postgres_telemetry.py`:既有 backfill 用例覆盖旧表补列后新列存在且可写读。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v
|
||||||
|
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
|
||||||
|
conda run -n PolyGateway python -c "
|
||||||
|
import inspect
|
||||||
|
from polygateway.ports import TelemetryRecorder
|
||||||
|
p = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||||
|
print('recorder 参数数(不含 self):', len(p) - 1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全 PASS;最后一条打印 `25`(README 的字段数断言按此实测值填,见 Task 9)。
|
||||||
|
|
||||||
|
- [ ] Task 6 提交:`feat: record the reasoning verdict in telemetry`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7:e2e 判据重建
|
||||||
|
|
||||||
|
**文件**:修改 `tests/e2e/test_thinking_live.py`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
`_run_rounds` 的逐轮观测字典增加两个键:`"thinking_observation": resp.thinking_observation` 与 `"thinking_chars": len(resp.thinking)`(报告里要能看见证据本身,而不只是结论)。
|
||||||
|
|
||||||
|
判据函数改写:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _reasoning_on(obs: dict) -> bool:
|
||||||
|
"""开启方向: 观测到推理即为真。
|
||||||
|
|
||||||
|
判据从 `reasoning_tokens` 换成三态裁定,因为 MiniMax 这一路已不再上报
|
||||||
|
`completion_tokens_details`(2026-08-25 findings),而库在同一次调用里
|
||||||
|
拿得到 185 字符推理正文——旧判据看不见它,四条用例因此假红。
|
||||||
|
"""
|
||||||
|
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_off(obs: dict) -> bool:
|
||||||
|
"""关闭方向: 只要没观测到推理即算满足。
|
||||||
|
|
||||||
|
`UNKNOWN` 计入满足是有意的: 它没有证伪力(设计 §4.1),不能拿它判红。
|
||||||
|
本判据真正的证伪力在于——模型若偷偷推理了,可观测路径会翻成 OBSERVED。
|
||||||
|
"""
|
||||||
|
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
|
||||||
|
```
|
||||||
|
|
||||||
|
**删除 `_ON_MIN_COMPLETION` 常量及其全部引用**:两档 completion 分布实测重叠(关闭档最高 46、开启档最低 13),这个魔数退路从一开始就不成立。
|
||||||
|
|
||||||
|
**L5 重新定义**(当前实现断言"非流式开启档多数轮观测到推理",而 M3 非流式推理正文与 ctd 双缺,该断言永远不可能成立):改为断言两件真实成立的事——其一非流式下关闭档与开启档的 `prompt_tokens` 锚点仍然分开(证明参数确实到达模型,判据形态照抄 L2b);其二开启档观测为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。用例 docstring 写明:M3 非流式推理已计费却不回传正文,这是上游行为,库修不了但必须让它可见。
|
||||||
|
|
||||||
|
L3b 的 docstring 补一句不可移植性:minimax 对非法 `reasoning_effort` 返回 200 且照常推理,qwen 对同样的值返回 **HTTP 400**——该反证手法只对不校验值的 provider 成立。
|
||||||
|
|
||||||
|
模块顶部的判据纪律段与 `_write_report` 的报告表头同步改写为三态口径。
|
||||||
|
|
||||||
|
### 测试要求(先失败后通过)
|
||||||
|
|
||||||
|
本任务的证据是真跑:改前 `TestMiniMaxM3` 4 failed / 3 passed,改后全类 PASS。L5 的新断言在 Task 3 之前无法表达(字段不存在),是纯新增覆盖。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:`TestMiniMaxM3` 7 passed;报告落 `tests/outputs/e2e/`。耗时约 7 分钟、约 137 次真实调用。
|
||||||
|
|
||||||
|
- [ ] Task 7 提交:`test: judge reasoning by what the library actually observed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8:能力表 evidence 刷新
|
||||||
|
|
||||||
|
**文件**:修改 `src/polygateway/thinking.py`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
`DEFAULT_CAPABILITIES` 中 `MiniMax-M3` 的 `can_disable` **保持 `True`**(2026-08-25 复测:`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立)。`evidence` 追加复测日期与两条新限制:推理信号在非流式路径不可观测;`enable_thinking` / `thinking:{type:enabled}` 对该模型无效,仅 `reasoning_effort` 是真开关。
|
||||||
|
|
||||||
|
`minimax` profile 上方的注入形态注释同步补记复测日期。
|
||||||
|
|
||||||
|
### 测试要求
|
||||||
|
|
||||||
|
**先失败后通过不适用于本任务,理由须写进提交信息**:本任务只改 `evidence` 字符串与注释,`can_disable` 取值不变,**没有行为变更**,因而没有可先失败的行为断言(`test-driven-development` 的结果门约束的是行为变更)。声明依然成立这一事实,其证据是 2026-08-25 的复测与 Task 7 的 e2e 真跑,不是本任务能自造的单测。
|
||||||
|
|
||||||
|
`tests/unit/test_thinking.py` 既有的能力表用例(`evidence` 非空、`can_disable` 取值)须保持绿,作为回归证据。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_thinking.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Task 8 提交:`docs: refresh the M3 capability evidence with the 08-25 retest`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9:文档同步(构建前必须改完)
|
||||||
|
|
||||||
|
**文件**:修改 `README.md`、`research-wiki/ARCHITECTURE.md`、`research-wiki/schemas/llm-calls.md`、`research-wiki/index.md`、`CHANGELOG.md`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
**`README.md:21`**:`必录 24 字段` → `25 字段`。数字取 Task 6 验证步骤里 `inspect.signature` 的实测输出,**不凭记忆**(发布清单第 1 步点名的失败模式)。同时核对安装命令的版本约束是否需要跟进,以及能力表是否要提及推理裁定这一新行为。
|
||||||
|
|
||||||
|
**`README.md` 的 `<!-- pg-template:table -->` 生产部署 DDL 模板**——**本条计划原文是错的,已订正**。
|
||||||
|
|
||||||
|
原文断言该模板是"独立于 `schema.py` 手写的另一份 SQL",要求补上 `thinking_observation TEXT`。**事实相反**:该模板不含任何列定义,它是 `CREATE TABLE llm_calls (LIKE llm_calls_seed INCLUDING DEFAULTS, PRIMARY KEY (call_id, created_at)) PARTITION BY RANGE (created_at)`,列全部从上一步 `telemetry_schema_sql('postgres')` 建出的 seed 表派生,README 正文原本就写着"列不在这里重抄一份——抄了就会漂移"。照原文补列会让 PG 报列重复、`TestProductionTemplate` 全红、下游部署直接失败。
|
||||||
|
|
||||||
|
(这条错误的来路值得记下来: 它出自另一个任务的实施报告,写进计划时**没有自己打开 README 核实**。跨任务转述的"发现"必须当作待验证的线索,不是事实。)
|
||||||
|
|
||||||
|
正确的做法是加一条**形态断言**: 模板必须靠 `LIKE` 派生,且不得内联任何 `COLUMNS` 里的列名。它钉住的是"日后有人把列抄进模板"这个真实风险——比原计划想堵的缺口更贴合实际。断言落在 `tests/integration/test_postgres_telemetry.py` 的 `TestProductionTemplate`(**不在** `tests/unit/test_telemetry.py`,计划原文也指错了文件)。
|
||||||
|
|
||||||
|
**`research-wiki/ARCHITECTURE.md`**:§8 模块结构树补 `thinking.py` 一行并说明职责;§8 依赖纪律段补 `thinking.py` 的层位;D11 段说明推理决策已从 `providers.py` 拆出;§5.1 响应字段表补 `thinking_observation`;§7.8 遥测字段补新列。
|
||||||
|
|
||||||
|
**`research-wiki/schemas/llm-calls.md`**:标题与正文的"遥测 22 字段"已过期两轮,订正为 25;补 `thinking_observation` 的列定义与查询口径(示例:按模型统计各观测态占比,用于发现某模型何时开始观测不到推理)。
|
||||||
|
|
||||||
|
**`research-wiki/index.md`**:登记本 plan、design 与 finding。
|
||||||
|
|
||||||
|
**先失败后通过不适用于本任务**:纯文档同步,无行为变更。其验收是下方 grep 的可见输出——数字与模块名对不上就是没改完。
|
||||||
|
|
||||||
|
**`CHANGELOG.md`**:新增 1.3.1 条目。**断裂项置于条目最前**,沿用 1.3.0"请先读这一条"体例(设计 §13:版号既然不承担预警职责,预警由 CHANGELOG 独立扛)。三条必须显式列出——① `polygateway.providers` 的深路径 import 断裂(`ThinkingCapability` / `resolve_thinking` / `get_capability` / `register_capability` / `DEFAULT_CAPABILITIES` / `ThinkingUnsupportedError` 移入 `polygateway.thinking`,同时提升到包根,**推荐改用包根 import**);② `TelemetryRecorder.record_llm_call` 端口签名 24 参 → 25 参,自定义 recorder 实现须同步;③ M3 非流式开启推理时推理内容已计费却不回传,该档观测为 `UNKNOWN`,库现在会告警一次。
|
||||||
|
|
||||||
|
### Wiki 注册
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id 2026-08-25-thinking-observability-plan --title "推理可观测性一等化实现计划"
|
||||||
|
.claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:2026-08-25-thinking-observability-plan" --to "design:2026-08-25-thinking-observability-design" --type implements --evidence "本计划实现该设计的全部落点"
|
||||||
|
.claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n '25 字段' README.md
|
||||||
|
grep -n 'thinking.py' research-wiki/ARCHITECTURE.md
|
||||||
|
grep -rn '22 字段' research-wiki/schemas/llm-calls.md # 预期无输出
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Task 9 提交:`docs: sync the field counts and module map to 1.3.1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10:合并前独立验证与发布 1.3.1
|
||||||
|
|
||||||
|
**文件**:修改 `pyproject.toml`、`src/polygateway/__init__.py`
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
版本号两处改 `1.3.1`(`pyproject.toml` 与 `__init__.py.__version__` 必须一致);`CHANGELOG.md` 的"未发布"定版为 `## 1.3.1(2026-08-25)`。
|
||||||
|
|
||||||
|
本任务分两段,**中间是一道人类确认门**。
|
||||||
|
|
||||||
|
**第一段:分支内可自主完成的验证**——CHANGELOG 定版为 `## 1.3.1(2026-08-25)`;版本号两处改 `1.3.1`;`verification-before-completion` 派**全新上下文** verifier subagent 独立验证(跨 20+ 文件,属强制档);`requesting-code-review` 整分支审查;在分支上跑 `make ci` 与 `pytest -m slow`(约 20-40 分钟——四个 e2e 文件与 Redis 时间语义变体默认被 `-m 'not slow'` 排除,不显式跑等于没跑)。
|
||||||
|
|
||||||
|
**Gitea Wiki 文档站同步**(计划原本漏了,Task 9 实施时发现):`research-wiki/docs-convention.md` §2 明写"新公共 API / 新能力 → 对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG"、"发版(任何版本号) → `Home.md` 版本号与安装命令",且该文件第 26 行是一道门——**版本 bump 的提交不允许单独存在**。本版有 6 个新包根导出、1 个新公共字段、1 个端口签名变更,wiki 必须同步。wiki 是**独立 git 仓库**(需 clone),故拆成两半:**内容在第一段写好待推**,`git push` 归第二段(外发动作)。
|
||||||
|
|
||||||
|
**人类确认门**:以上全绿后停下,把验证结果交给人类,**取得明确同意后**才执行第二段。
|
||||||
|
|
||||||
|
**第二段:外发且难以撤销的动作,一律等确认**——合并 main(`--no-ff`)+ push → 打 tag 并 push → 构建 → 上传 registry → `pip download` 验证并解包确认新代码在内 → 建 Release + 挂仓库 + 核对包页面 → 关闭 issue #16 / #17 并附修复说明(诊断纠正 + 三层根因 + 落地形态)。顺序按 CLAUDE.md §4.4.1**不得跳步**:包上传与 tag 一旦推出去就收不回,registry 里的版本号也不能复用。
|
||||||
|
|
||||||
|
合并到 main 后须在 main 上**重跑** `make lint` 与全套件外加 `pytest -m slow`——分支上跑过不算,合并本身可能引入差异。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway make ci
|
||||||
|
conda run -n PolyGateway pytest -m slow
|
||||||
|
python -c "import tomllib,pathlib,re
|
||||||
|
v=tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
|
||||||
|
i=re.search(r'__version__ = \"(.+?)\"', pathlib.Path('src/polygateway/__init__.py').read_text()).group(1)
|
||||||
|
assert v == i == '1.3.1', (v, i); print('版本号一致:', v)"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:`make ci` 绿;slow 全绿;版本号一致性检查通过。
|
||||||
|
|
||||||
|
- [ ] Task 10 提交:`chore: cut 1.3.1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务依赖
|
||||||
|
|
||||||
|
Task 1 → 2 → 3 是硬序(枚举 → 模块就位 → 字段贯通)。Task 4、5、6 都依赖 3,彼此独立可并行。Task 7 依赖 3(需要字段)。Task 8 依赖 2(能力表已搬)。Task 9 依赖 6(字段数实测值)。Task 10 最后。
|
||||||
|
|
||||||
|
## 全局纪律
|
||||||
|
|
||||||
|
不做计划外的重构与抽象——尤其**不重构遥测组装路径**:`TelemetryEmitter._record` 已经是铁律要求的单一出口,三个 `emit_*` 是三个语义不同的入口,各自组装参数是职责所在(设计 §12)。
|
||||||
|
|
||||||
|
每个任务独立提交,提交前跑该任务的验证命令。任何一步的完成声明必须对应本会话内的工具输出。
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-08-26-issue18-pg-test-isolation
|
||||||
|
title: "issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标"
|
||||||
|
date: 2026-08-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# issue #18 实现计划
|
||||||
|
|
||||||
|
> 类型:plan|日期:2026-08-26|分支 `fix/issue-18-pg-test-isolation`
|
||||||
|
> 实现设计 `designs/2026-08-26-issue18-pg-test-isolation-design.md`(已过人类门)。设计的节号在下文直接引用;本计划只负责"动哪些文件、按什么顺序、怎么拿到证据"。
|
||||||
|
> **本计划不涉及参考实现迁移,保真校验不适用。**
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **执行期唯一的不可逆风险,写在最前面。** 设计 §5.3 的"最坏情况"用例故意让脚本以裸 `search_path` 跑到共享表上。它**只有在沙箱角色就位之后才可以跑**——若在角色化之前用 `.env` 的 `app`(实测 superuser)跑它,`--older-than-days 7 --apply` 会真的删掉共享表里的过期行(实测那 11 行 2026-07-22 的数据全部早于任何截止线)。
|
||||||
|
> 这条风险决定了下面的任务顺序:**沙箱工厂(Task 1)→ retention 全面角色化(Task 2)→ 才写这条用例**。它没有常规意义上的"先红"路径,见 Task 2 的说明。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让 `tests/integration` 不再依赖也不再污染共享表 `llm_calls`,并把"清理脚本删错表"从事后可观测改成物理上做不到,随后发布 1.3.2。
|
||||||
|
|
||||||
|
## 方案概述
|
||||||
|
|
||||||
|
先建 `tests/integration/conftest.py` 的一次性沙箱工厂(独立 schema + 可选独占登录角色),把 retention 测试全面切到对真表无任何权限的角色上并删除行数快照;再给 `telemetry_retention.py` 加 `--table SCHEMA.llm_calls`(目标由参数精确解析、绕开 `search_path`,表名段锁死);随后把 `test_postgres_telemetry.py` 的 7 条用例迁出真表、拆分 `_RUN_PREFIX` 的两个职责;最后加一道 lint 门防字面量回归,发布 1.3.2。
|
||||||
|
|
||||||
|
## 涉及技术
|
||||||
|
|
||||||
|
Python 3.12 / pytest + pytest-asyncio(auto) / asyncpg / PostgreSQL 16 权限与 `search_path` 语义 / argparse。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 文件 | 动作 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tests/integration/conftest.py` | **新建** | `PgSandbox` 与 `pg_sandbox` 工厂;admin DSN 私有化 |
|
||||||
|
| `tests/integration/test_pg_sandbox.py` | **新建** | 工厂自身的行为测试(含 setup 中途失败不留残留) |
|
||||||
|
| `tests/integration/test_retention_tool_pg.py` | 修改 | 全部用例角色化;删行数快照;补 `--table` 与最坏情况用例 |
|
||||||
|
| `tools/telemetry_retention.py` | 修改 | 新增 `--table`;PG 分支目标解析改为"显式限定名优先" |
|
||||||
|
| `tests/unit/test_retention_tool.py` | 修改 | `--table` 的参数分类用例(不连库) |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py` | 修改 | 7 条用例迁出真表;`_RUN_PREFIX` 双职责拆分;其余 fixture 收敛到工厂 |
|
||||||
|
| `Makefile` | 修改 | `lint` / `check` 各加一道字面量门 |
|
||||||
|
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` | 修改 | `--table` 用法与 1.3.2 定版 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 跨任务共享接口(Task 1 产出,Task 2/4/5 消费)
|
||||||
|
|
||||||
|
`tests/integration/conftest.py` 对外只有一个 fixture 与一个返回类型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PgSandbox:
|
||||||
|
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
|
||||||
|
|
||||||
|
schema: str
|
||||||
|
role: str | None
|
||||||
|
dsn: str # 已挂 options=-csearch_path=<schema>
|
||||||
|
bare_dsn: str | None # 同角色但不挂 search_path;role is None 时为 None
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def pg_sandbox(
|
||||||
|
*,
|
||||||
|
ddl: str | None = None,
|
||||||
|
extra: Sequence[str] = (),
|
||||||
|
role: Literal["none", "owner", "grantee"] = "none",
|
||||||
|
grants: Sequence[str] = ("SELECT", "INSERT"),
|
||||||
|
) -> PgSandbox: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 三种 `role` 的语义
|
||||||
|
|
||||||
|
覆盖现有全部六个 fixture 的需求,**不得再加第四种**:
|
||||||
|
|
||||||
|
| `role` | schema 属主 | `ddl`/`extra` 由谁执行 | 返回 DSN 的身份 | 对应今天的 fixture |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `"none"` | admin | admin | admin | `fresh_schema` / `legacy_schema` / `pre_tenant_schema` / `partitioned_schema` |
|
||||||
|
| `"owner"` | 临时角色 | **临时角色自己**(故表属主 = 该角色) | 临时角色 | 无(本次新增,retention 全部用例用) |
|
||||||
|
| `"grantee"` | admin | **admin**(故表属主 = admin,与最小权限现场一致) | 临时角色(只被 `GRANT USAGE ON SCHEMA` + 表级 `grants`,**绝不 GRANT CREATE**) | `least_privilege_dsn` / `least_privilege_pre_tenant_dsn` |
|
||||||
|
|
||||||
|
### `ddl` / `extra` 的执行契约
|
||||||
|
|
||||||
|
1. **调用方传的 DDL 一律不带 schema 限定**(`CREATE TABLE llm_calls (...)`,不是 `CREATE TABLE {schema}.llm_calls`)。工厂在执行前对该连接 `SET search_path = <schema>`,由 search_path 定位。这条统一了两种今天并存的写法——`PG_DDL` 本就是裸表名,而 `_LEGACY_DDL` / `_PRE_TENANT_DDL` 今天带 `{schema}` 占位,**Task 5 要把这两个常量的 `{schema}.` 前缀去掉**。
|
||||||
|
2. `extra` 在**同一连接、同一 search_path** 下按给定顺序逐条执行,不包事务(分区子表这类 DDL 各自提交即可)。
|
||||||
|
3. `ddl is None` 时只建空 schema,不执行任何建表语句。
|
||||||
|
|
||||||
|
### 临时角色的 DSN 构造
|
||||||
|
|
||||||
|
- 密码:模块级常量(测试专用,非机密),沿用今天 `_PROBE_PASSWORD` 的做法。
|
||||||
|
- `bare_dsn`:把 admin DSN 里的 `//user:pass@` 段整体替换为 `//<role>:<密码>@`(`re.sub(r"//[^@/]+@", ...)`,`count=1`),**不追加任何 `options` 参数**——它的用途就是让 `search_path` 回落到 `"$user", public`。
|
||||||
|
- `dsn`:在 `bare_dsn` 基础上追加 `options=-csearch_path%3D<schema>`,分隔符按 DSN 里是否已有 `?` 选 `?` 或 `&`。
|
||||||
|
- `role="none"` 时 `dsn` 用 admin 身份加同样的 options,`bare_dsn` 为 `None`——admin 的裸 DSN 不对用例开放(设计 §7.1 约束 3)。
|
||||||
|
|
||||||
|
### 三条硬约束(设计 §5.1、§7.1,逐条都是验收点)
|
||||||
|
|
||||||
|
1. schema 名 `pgw_s_<12 位 hex>`、角色名 `pgw_r_<12 位 hex>`,**两者前缀有意不同**——同名会让 `"$user"` 遮蔽真表,最坏情况用例就测不到真现场。
|
||||||
|
2. 资源逐步登记:每建成一个对象就把它的清理动作入栈,`except BaseException` 时**逆序**执行并 re-raise;`yield` 之后的 teardown 走同一条清理路径。单个沙箱的清理顺序固定为 `DROP SCHEMA IF EXISTS <s> CASCADE` → `DROP OWNED BY <r>` → `DROP ROLE IF EXISTS <r>`(`DROP OWNED BY` 必须在 `DROP ROLE` 之前,否则角色仍持有对象无法删除)。一次用例内建多个沙箱时,沙箱之间也按 LIFO 清理。
|
||||||
|
3. `role != "none"` 时先查 `rolcreaterole OR rolsuper`,**在建任何对象之前** `pytest.skip`(`production_template` 的教训:`pytest.skip` 抛的是 `BaseException`,若在清理块内触发会去 DROP 从未建过的对象,把 skip 盖掉)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1:沙箱工厂
|
||||||
|
|
||||||
|
- [ ] **文件**:`tests/integration/conftest.py`(新建)、`tests/integration/test_pg_sandbox.py`(新建)
|
||||||
|
|
||||||
|
**行为**:实现上文《跨任务共享接口》全部内容。DSN 读取沿用今天两个文件里的做法(`dotenv_values(".env")` 合并 `os.environ`,剥掉 `+driver`,缺则 `skip`,库名不以 `/polygateway` 结尾则 `pytest.fail`)——这段逻辑今天重复两份,本任务收敛为一份私有函数。
|
||||||
|
|
||||||
|
**测试要求(先红后绿的路径明确)**:先写 `test_pg_sandbox.py` 再写 `conftest.py`——此时 `pg_sandbox` fixture 不存在,pytest 报 `fixture 'pg_sandbox' not found`,六条用例全红,这就是本任务的先失败证据。随后实现工厂使其转绿。
|
||||||
|
|
||||||
|
| 用例 | 断言 |
|
||||||
|
|---|---|
|
||||||
|
| `role="none"` 建表 | 表落在 `sandbox.schema` 下;`sandbox.bare_dsn is None` |
|
||||||
|
| `role="owner"` 建表 | 表属主 = `sandbox.role`;`sandbox.role != sandbox.schema` 且两者前缀不同 |
|
||||||
|
| `role="owner"` 的 `bare_dsn` | `SHOW search_path` 为 `"$user", public`;用它解析 `llm_calls` 得到的**不是**沙箱里那张表 |
|
||||||
|
| `role="grantee"` | 该角色 `CREATE TABLE` 被拒(`asyncpg.exceptions.InsufficientPrivilegeError`),`INSERT` 正常 |
|
||||||
|
| **setup 中途失败** | 传一段必然报错的 `ddl`(如 `CREATE TABLE llm_calls (bad_type NOT_A_TYPE)`),捕获异常后查 `pg_namespace` / `pg_roles`:本次 uuid 对应的 schema 与角色**都不存在** |
|
||||||
|
| teardown 后无残留 | 在用例内部记下 `sandbox.schema` / `sandbox.role`,用一个**更外层**的 fixture(在 `pg_sandbox` 之后销毁)回查两者均已消失 |
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
```
|
||||||
|
conda run -n PolyGateway pytest tests/integration/test_pg_sandbox.py -v
|
||||||
|
```
|
||||||
|
预期全绿;随后手工查实例:`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'` 与 `pg_roles` 同款查询均为空。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2:retention 测试角色化,删除行数快照
|
||||||
|
|
||||||
|
- [ ] **文件**:`tests/integration/test_retention_tool_pg.py`(修改)
|
||||||
|
|
||||||
|
**必须在 Task 3 之前完成**——见文首 CAUTION。
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
1. 删除 `_public_count`、`before_public` 与那条行数断言;删除本地的 `_make_schema` / `_drop_schema` / `_search_path_dsn` / `dsn` fixture,全部改用 `pg_sandbox`。
|
||||||
|
2. **凡启动脚本的用例一律 `role="owner"`**(设计 §5.1,无一例外,含 dry-run 与分区让路两条)。
|
||||||
|
3. 现有三条用例的其余断言逐条保留:`将删除行数: 5`、`'acme': 3`、批次 1/3 存在而批次 4 不存在、`已删除 5 行`、剩余 `fresh-1`/`fresh-2`、分区表退出 3 且含 `DROP PARTITION`/`DETACH`、缺 asyncpg 退出 2。
|
||||||
|
4. 新增设计 §5.3 的**最坏情况**用例:用 `sandbox.bare_dsn`、不给 `--table`、`--older-than-days 7 --apply`。断言退出 **2**、stderr 非空且含 `llm_calls`、沙箱表一行不少。**不断言 PG 的英文错误原文**(`lc_messages` 不由测试掌握),**测试代码里不得出现 `public.llm_calls` 字面量**。
|
||||||
|
|
||||||
|
**测试证据(这条用例没有常规先红路径,如实记录)**:让它变红的唯一方式是把角色换回 admin superuser——那会真删共享表的行,绝不执行。它的证伪由 `findings/2026-08-26-issue18-shared-pg-test-isolation.md` §7 的探针 6/7 提供:同款临时角色对真表的 `COUNT` 与 `DELETE` 均返回 `InsufficientPrivilegeError`。**提交说明里必须写明这一点**,不得含糊成"已验证"。
|
||||||
|
|
||||||
|
其余改动的先红路径正常:删掉 `_public_count` 之前,先把三条既有用例切到沙箱并跑通(此时它们仍带旧断言),再删断言——若沙箱切换有问题,旧断言会先报出来。
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
```
|
||||||
|
conda run -n PolyGateway pytest tests/integration/test_retention_tool_pg.py -v
|
||||||
|
```
|
||||||
|
预期全绿;连跑三次结果一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3:`--table` 参数与精确解析
|
||||||
|
|
||||||
|
- [ ] **文件**:`tools/telemetry_retention.py`(修改)、`tests/unit/test_retention_tool.py`(修改)、`tests/integration/test_retention_tool_pg.py`(追加用例)
|
||||||
|
|
||||||
|
**顺序**:**先写测试再改脚本**——四条集成用例与五条单测在脚本未改时全部先红(`--table` 未定义,argparse 直接以退出码 1 拒绝,而用例期望的是别的码/别的 stdout),实现后转绿。这就是本任务的先失败证据;Task 2 已先行完成,故这些用例从第一次运行起就跑在沙箱角色之下。
|
||||||
|
|
||||||
|
**脚本行为**(设计 §4):
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| 参数 | `--table SCHEMA.NAME`,仅 `--backend postgres` 接受 |
|
||||||
|
| 校验(全部退出 **1**) | sqlite 给了它;不是恰好两段;任一段为空;任一段含 `.` 或 `"`;**表名段不等于 `llm_calls`** |
|
||||||
|
| 解析 | 给了 `--table` 时用 `to_regclass($1)` 传 `"<schema>"."llm_calls"`(`_quote` 包裹),绕开 `search_path`;未给时维持今天的裸 `TABLE` 解析 |
|
||||||
|
| 解析不到 | 退出 **2**,消息点名显式指定的表,并附一句"PG 中未加引号建的标识符在 catalog 里是小写" |
|
||||||
|
| 无权限 | 后续 `COUNT` 抛 `PostgresError`,走既有 except → 退出 **2**(不新增分支) |
|
||||||
|
| 分区表 | 仍退出 **3**,逻辑不动 |
|
||||||
|
| 提示行 | `--apply` 且**未**给 `--table` 时,在"目标表: x.y"之后打印一行,指出目标由 `search_path` 推断、可用 `--table` 钉死;dry-run 不打 |
|
||||||
|
|
||||||
|
`--help` 的 epilog 补两句:本脚本只清理 `llm_calls`;含点或引号的复杂标识符不支持,此时退回不给 `--table` 的路径。
|
||||||
|
|
||||||
|
**单测**(`tests/unit/test_retention_tool.py`,不连库):`TestUsageErrors` 加五条,对应上表五种退出 1 的情形,逐条断言 stderr 含 `--table`;`TestHelp` 加一条断言 epilog 点明表名固定为 `llm_calls`。
|
||||||
|
|
||||||
|
**集成用例**(`test_retention_tool_pg.py`,全部 `role="owner"`):
|
||||||
|
|
||||||
|
| 用例 | 构造 | 预期 |
|
||||||
|
|---|---|---|
|
||||||
|
| 显式指定成功 | `--table <sandbox.schema>.llm_calls` + `--apply` | 退出 0,删除结果与不给 `--table` 时逐条一致 |
|
||||||
|
| 指向不存在的 schema | `--table pgw_s_nosuchxxxxxxxx.llm_calls` | 退出 **2**,stderr 点名该表;沙箱表一行不少 |
|
||||||
|
| 指向无权的表 | 建两个 `role="owner"` 沙箱,用 A 的 DSN 指 B 的表 | 退出 **2**;A、B 两张表都不变 |
|
||||||
|
| 指向分区表 | 分区沙箱 + `--table` | 仍退出 **3**,含 `DROP PARTITION` / `DETACH` 字样 |
|
||||||
|
| 提示行(设计验收 #2) | 沙箱 DSN + `--apply`,**不给** `--table` | stdout 含推断提示。设计原写"单测断言 stdout",但该行只在 PG 分支打印、不连库触发不到,故落在集成层;设计 §11 判据 2 已同步更正 |
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
```
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4:7 条用例迁出真表,`_RUN_PREFIX` 拆职责
|
||||||
|
|
||||||
|
- [ ] **文件**:`tests/integration/test_postgres_telemetry.py`(修改)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
1. 七条用例改用 `pg_sandbox(role="none")`:`TestObservabilityColumns::test_values_round_trip`、`TestSchema` 三条、`TestDegradation::test_row_failure_does_not_poison_later_rows` 与 `test_aclose_idempotent`、`TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max`。
|
||||||
|
2. `test_schema_has_frozen_columns_in_order` 的 `information_schema` 查询补 `table_schema = $1`(设计 §6.2;仓库注释已记载该隐患)。
|
||||||
|
3. `TestPoolFootprint` **保留唯一 `application_name`**,就地生成 uuid(设计 §6.1)——这是实例级资源,schema 隔离对它无效。
|
||||||
|
4. 删除 `_RUN_PREFIX` 的行隔离用途:`_cid()` 的 63 处调用机械替换为字面量(`_cid("c1")` → `"c1"`);5 处 `LIKE` 逐条处置——`dsn` fixture teardown 的 `DELETE` 整条删除,`test_concurrent_writes_all_land` 的计数改 `COUNT(*)`,其余三处(legacy / least_privilege / manual-lp)改为不带前缀的精确条件。
|
||||||
|
5. 删除已无引用的本地 `dsn` fixture 与其 teardown。
|
||||||
|
|
||||||
|
**测试证据**:判据 6c 有明确先红路径——先在库里手工留一个残留同名表(`CREATE SCHEMA pgw_s_leftover; CREATE TABLE pgw_s_leftover.llm_calls (call_id TEXT)`),此时 `test_schema_has_frozen_columns_in_order` 因少了 `table_schema` 过滤而红;补上过滤后转绿;用完删掉该残留 schema。其余六条属迁移,证据形式是迁移前后断言逐条对照(设计 §11 判据 6),差异只允许出现在"表在哪"与"查询是否带 schema 过滤"两处——**这是回归门不是先红门,提交说明里如实这么写**。
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
```
|
||||||
|
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
|
||||||
|
```
|
||||||
|
判据 6b 另做:两个 shell 同时跑 `TestPoolFootprint` 那一条,两边都绿。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5:其余 fixture 收敛到工厂
|
||||||
|
|
||||||
|
- [ ] **文件**:`tests/integration/test_postgres_telemetry.py`(修改)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
1. `legacy_schema`、`pre_tenant_schema`、`fresh_schema`、`partitioned_schema` 改为 `role="none"`;`least_privilege_dsn`、`least_privilege_pre_tenant_dsn` 改为 `role="grantee"`。
|
||||||
|
2. 按接口契约,`_LEGACY_DDL` 与 `_PRE_TENANT_DDL` 两个常量去掉 `{schema}.` 前缀与 `.format(schema=...)` 调用,改为裸表名由工厂的 search_path 定位。
|
||||||
|
3. `production_template` **不收敛**:它要建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的(设计 §7.1 末段与 Codex 意见 3)。工厂强行接管会把这些语义压扁。本任务只把它内部的 `_cid()` 调用一并处理掉。
|
||||||
|
|
||||||
|
**测试证据**:这些 fixture 的既有用例断言**一行不改**——它们是这次收敛的验收器,改了就失去验收意义。这是回归门。
|
||||||
|
|
||||||
|
**验证**:同 Task 4 的命令,预期全绿;在无 CREATEROLE 的账号下 `least_privilege` 系列仍能正确 skip。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6:lint 门与字面量清理
|
||||||
|
|
||||||
|
- [ ] **文件**:`Makefile`(修改)、`tests/integration/*.py`(注释措辞)
|
||||||
|
|
||||||
|
**行为**:`lint` 与 `check` 各加一步——`tests/` 下命中字面量 `public.llm_calls` 即 `exit 1` 并打印命中行。注释与 docstring **同样不豁免**,现有"共享的 public.llm_calls"改写为"共享表 `llm_calls`"。
|
||||||
|
|
||||||
|
Makefile 里这道门的注释必须写明它的定位(设计 §7.2):**烟雾报警器,不是隔离证明**——它拦不住 `f"{schema}.{table}"` 拼接与参数化查询,真正的隔离来自工厂不交出 admin DSN、脚本以无权角色运行。
|
||||||
|
|
||||||
|
**测试证据**:故意加一行含该字面量的注释 → `make lint` 失败并打印该行;移除后 → 通过。
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
```
|
||||||
|
make lint && make check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7:独立验证(合并前硬门)
|
||||||
|
|
||||||
|
- [ ] 派**全新上下文**的 verifier subagent(`verification-before-completion`),交给它设计 §11 的判据表逐条核对,重点:
|
||||||
|
- 判据 3(最坏情况删不掉任何行)是否真由权限拒绝达成,而非碰巧——它没有先红证据,须由 verifier 独立复核 findings §7 的探针与用例断言是否真的对应同一条防线
|
||||||
|
- 判据 4:整套 `tests/integration` 连跑三次,**其间由 verifier 手工改动真表行数**(插入若干行再删掉),全程应无任何用例受影响
|
||||||
|
- 判据 6:7 条用例迁移前后断言逐条对照
|
||||||
|
- `--table` 的五种退出 1 与三种退出 2/3 是否都有用例覆盖
|
||||||
|
- `tests/` 与实例上是否留下任何 `pgw_%` 残留
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8:文档与版本号
|
||||||
|
|
||||||
|
- [ ] **文件**:`README.md`、`CHANGELOG.md`、`pyproject.toml`、`src/polygateway/__init__.py`
|
||||||
|
|
||||||
|
- README:`--table` 用法落在两处——"存量兜底"表格行与 SQLite 侧段落之后的脚本说明段;写明表名固定为 `llm_calls`。安装约束是 `>=1.3.0,<2` 范围式,**本版无需改**(已核)。
|
||||||
|
- CHANGELOG:按设计 §10 如实写明 `tools/` 与 `tests/` 都不在 pip 包内,**1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同**,本版内容是运维脚本的契约扩展与测试确定性,不得包装成库能力更新。
|
||||||
|
- 版本号两处一致改 `1.3.2`。
|
||||||
|
- `make wiki-check WIKI=<路径>` 跑过(公共行为变更须同步用户文档站,`docs-convention.md` §2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9:发布 1.3.2
|
||||||
|
|
||||||
|
- [ ] 按 CLAUDE.md §4.4.1 九步执行,一步不跳:合并 main(`--no-ff`)→ 在 main 上重跑 `make lint` 与全套件 → **显式跑 `pytest -m slow`** → 打 tag 并 push → `rm -rf dist && python -m build && twine check` → 上传 registry(token 走 `TWINE_PASSWORD`,不进命令行)→ `pip download` 验证并解包确认 → 建 Release + 挂仓库 → 以下游视角打开包页面与 Releases 页核对。
|
||||||
|
- [ ] 关闭 issue #18,正文指向本计划与设计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审查留痕(Codex,2026-08-26)
|
||||||
|
|
||||||
|
报 5 项,**全部采纳**:
|
||||||
|
|
||||||
|
| # | 意见 | 处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `ddl`/`extra` 的执行身份、search_path、顺序、schema 占位、失败清理顺序都没写成契约 | 新增《`ddl`/`extra` 的执行契约》一节;并据此在 Task 5 追加"去掉两个 DDL 常量的 `{schema}` 占位"这一步 |
|
||||||
|
| 2 | 临时角色的密码来源与 DSN 构造规则缺失 | 新增《临时角色的 DSN 构造》一节 |
|
||||||
|
| 3 | **Task 1 先实现 `--table`、Task 3 才写集成用例,先红路径不可能成立** | 采纳,任务重排:沙箱工厂 → retention 角色化 → `--table`(测试先写)。重排同时让 `--table` 的集成用例从第一次运行起就在沙箱角色之下,与文首 CAUTION 一致 |
|
||||||
|
| 4 | 工厂测试缺"先写失败测试"的明确步骤 | Task 1 写明:先写 `test_pg_sandbox.py`,此时 `fixture 'pg_sandbox' not found` 全红 |
|
||||||
|
| 5 | 设计验收 #2 说"单测断言 stdout",计划却放在集成层 | 核实后确认是**设计写错了**——该提示行只在 PG 分支打印,不连库的单测触发不到。已就地更正设计 §11 判据 2,并在 Task 3 注明 |
|
||||||
|
|
||||||
|
另外据 Codex 对 Task 4/5 的观察,两处证据形式(回归门而非先红门)已在任务里如实标注,不含糊成"已验证"。
|
||||||
|
|
||||||
|
## Wiki 注册
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan \
|
||||||
|
--id 2026-08-26-issue18-pg-test-isolation --title "issue #18 实现计划"
|
||||||
|
.claude/tools/research_wiki.py add_edge research-wiki/ \
|
||||||
|
--from "plan:2026-08-26-issue18-pg-test-isolation" \
|
||||||
|
--to "design:2026-08-26-issue18-pg-test-isolation" --type implements
|
||||||
|
.claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||||
|
```
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
# 实现计划: 推理档位一等化
|
||||||
|
|
||||||
|
- **设计**: `research-wiki/designs/2026-09-04-reasoning-effort-design.md`(2026-09-04 人类已批准)
|
||||||
|
- **目标**: 把 `enable_thinking: bool | None` 升级为可表达厂商档位的 `Effort` 词汇,让「关不掉的模型」「打空的档位」从静默失效变成带出路的报错。
|
||||||
|
- **方案概述**: 新增八档封闭枚举 `Effort`(含 `auto`);能力表从 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`;provider 的两个固定片段改为 `ThinkingWire`(off / on_base / effort_key);档位入口取「源级默认 + 请求级覆盖」,进缓存 key 与遥测各一列。
|
||||||
|
- **涉及技术**: Python 3.12 `StrEnum`、frozen dataclass、pydantic-settings env 解析、SQLite/Postgres DDL 补列、pytest。
|
||||||
|
- **保真校验**: **不适用**。本计划实现的是库自研的推理决策(`thinking.py` 系 2026-08-25 新建),不属 ARCHITECTURE §1.4 的移植蓝本;且 `reference/` 三项目当前不在工作区(见设计 §12),无可比对源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 文件 | 动作 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/polygateway/types.py` | 修改 | 新增 `Effort` 枚举;`SourceConfig`/`ChatRequest` 各加档位字段 |
|
||||||
|
| `src/polygateway/thinking.py` | 修改 | `ThinkingCapability` 重构、`resolve_thinking` 五关、`reconcile_thinking` 判据、默认能力表重写 |
|
||||||
|
| `src/polygateway/providers.py` | 修改 | `ThinkingWire` 新类型替换两个片段;`DEFAULT_PROFILES` 扩到 8 段 |
|
||||||
|
| `src/polygateway/config.py` | 修改 | 两个新 env 键的解析与矛盾校验 |
|
||||||
|
| `src/polygateway/client.py` | 修改 | `chat()` 签名加档位;`_fingerprint_mark` 纳入源级档位 |
|
||||||
|
| `src/polygateway/middleware/cache.py` | 修改 | `build_cache_key` 纳入请求级档位 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 与三个 emit 入口传递生效档位 |
|
||||||
|
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 加一参(25 → 26 字段) |
|
||||||
|
| `src/polygateway/telemetry/schema.py` | 修改 | `COLUMNS`、两端 DDL、补列声明 |
|
||||||
|
| `src/polygateway/telemetry/{sqlite,postgres}.py` | 修改 | 落库新列 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | 修改 | 生效档位解析接线、告警节流键 |
|
||||||
|
| `src/polygateway/__init__.py` | 修改 | 导出 `Effort`、`ThinkingWire` |
|
||||||
|
| `.env.example` | 修改 | 两个新键的模板与注释 |
|
||||||
|
| `tests/unit/test_thinking.py` | 修改 | 位置参数构造迁移 + 五关用例 |
|
||||||
|
| `tests/unit/test_providers.py` | 修改 | `ThinkingWire` 用例 |
|
||||||
|
| `tests/unit/test_cache.py` | 修改 | 档位进 key 的用例 |
|
||||||
|
| `tests/unit/test_openai_compat.py` | 修改 | transport 接线与节流用例 |
|
||||||
|
| `tests/unit/test_telemetry.py`、`tests/integration/test_redis_cache.py` | 修改 | 列数断言与缓存 key 回归 |
|
||||||
|
| `tests/e2e/test_thinking_live.py` | 修改 | `can_disable` 读法迁移;新增逐模型档位实测(标 `slow`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键接口(跨任务消费,此处定稿)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# types.py
|
||||||
|
class Effort(StrEnum):
|
||||||
|
NONE = "none"; AUTO = "auto"; MINIMAL = "minimal"; LOW = "low"
|
||||||
|
MEDIUM = "medium"; HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
|
||||||
|
|
||||||
|
_ORDER = (Effort.NONE, Effort.MINIMAL, Effort.LOW, Effort.MEDIUM,
|
||||||
|
Effort.HIGH, Effort.XHIGH, Effort.MAX) # auto 不参与强弱序
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# providers.py
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingWire:
|
||||||
|
off: Mapping[str, Any] | None
|
||||||
|
on_base: Mapping[str, Any] | None
|
||||||
|
effort_key: str | None
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProviderProfile:
|
||||||
|
name: str
|
||||||
|
thinking: ThinkingWire
|
||||||
|
strip_think_tags: bool
|
||||||
|
supports_native_schema: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# thinking.py
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
supported_efforts: tuple[Effort, ...]
|
||||||
|
evidence: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts
|
||||||
|
@property
|
||||||
|
def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档
|
||||||
|
@property
|
||||||
|
def is_tiered(self) -> bool: ... # 除 NONE/AUTO 外仍有 ≥1 档
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingResolution:
|
||||||
|
"""注入片段 + **实际**生效档。
|
||||||
|
|
||||||
|
返回 dataclass 而非裸 Mapping(CLAUDE.md 4.3「返回类型用 frozen dataclass」):
|
||||||
|
`nearest` 映射后请求档与实际档不同,遥测必须记后者,否则 T10 的压测按档位
|
||||||
|
分组时,被映射过的行会挂在一个从未真正发出的档下(Codex 审查指出)。
|
||||||
|
"""
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
applied_effort: Effort | None # Phase 1(不表态)为 None
|
||||||
|
|
||||||
|
def resolve_thinking(
|
||||||
|
profile: ProviderProfile,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
effort: Effort | None,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
fallback: str = "error", # "error" | "nearest"
|
||||||
|
warn_unregistered: bool = True,
|
||||||
|
) -> ThinkingResolution: ...
|
||||||
|
|
||||||
|
def reconcile_thinking(
|
||||||
|
*,
|
||||||
|
effort: Effort | None,
|
||||||
|
observation: ThinkingObservation,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
model: str,
|
||||||
|
) -> str | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# types.py 字段追加(均追加在末尾,不扰动既有位置构造)
|
||||||
|
# SourceConfig: reasoning_effort: Effort | None = None
|
||||||
|
# effort_fallback: str = "error"
|
||||||
|
# ChatRequest: reasoning_effort: Effort | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — `Effort` 词汇与能力表重构
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_thinking.py`(改)、`tests/e2e/test_thinking_live.py`(改)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. `types.py` 新增 `Effort` 与 `_ORDER`(见上)。放 `types.py` 而非 `thinking.py`: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import(依赖铁律)。
|
||||||
|
2. `ThinkingCapability` 改为 `supported_efforts` + `evidence`,加两个 `@property` 派生量。构造期校验: `supported_efforts` 非空、元素唯一、全部属 `Effort`,违反即 `ValueError`。
|
||||||
|
3. `DEFAULT_CAPABILITIES` 按设计 §8 落库规则重写(见下表)。
|
||||||
|
4. 迁移三处既有读点: `thinking.py` 内部读 `capability.can_disable` 改为读派生属性(行为不变);`tests/unit/test_thinking.py` 的 `ThinkingCapability(True, "实测")` 位置参数构造改为关键字构造;`tests/e2e/test_thinking_live.py` 读 `can_disable` 处确认派生属性可用。
|
||||||
|
5. `__init__.py` 导出 `Effort`(包根导出是既有纪律: 深路径 import 正是模块重组会打断下游的原因,见 ARCH D11)。
|
||||||
|
|
||||||
|
**初始 `DEFAULT_CAPABILITIES`**(evidence 一律以 `2026-09-04 文档推定(来源),待经 new-api 实测` 开头):
|
||||||
|
|
||||||
|
| model | supported_efforts |
|
||||||
|
|---|---|
|
||||||
|
| `glm-5.3`, `glm-5.3-flash` | `(LOW, HIGH, MAX)` |
|
||||||
|
| `glm-5.2` | `(NONE, HIGH, MAX)` |
|
||||||
|
| `glm-5`, `glm-5.1`, `glm-4.6v` | `(NONE, AUTO)` |
|
||||||
|
| `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | `(NONE, HIGH, MAX)` |
|
||||||
|
| `gpt-5.4`, `gpt-5.5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH)` |
|
||||||
|
| `claude-opus-5`, `claude-sonnet-5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH, MAX)` |
|
||||||
|
| `gemini-3.1-pro` | `(LOW, MEDIUM, HIGH)` |
|
||||||
|
| `kimi-k3` | `(LOW, HIGH, MAX)` —— 保守登记,evidence 注明 OpenRouter 标可关但官方档位无 `none` |
|
||||||
|
| `MiniMax-M3` | `(NONE, AUTO)` |
|
||||||
|
| `MiniMax-M2.7`, `MiniMax-M2.5` | `(AUTO,)` |
|
||||||
|
| `qwen-plus-latest`, `qwen3.5-flash`, `qwen3.6-plus`, `qwen3.7-max`, `qwen3.7-plus` | `(NONE, AUTO)` |
|
||||||
|
|
||||||
|
`claude-haiku-5`、`gemini-3-flash`、`kimi-for-coding` **不登记**(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。
|
||||||
|
|
||||||
|
**验收**: `can_disable` 对 11 类模型的返回与上表一致;`cheapest_effort` 对 `(LOW, HIGH, MAX)` 返回 `LOW`、对 `(NONE, AUTO)` 返回 `AUTO`、对 `(AUTO,)` 返回 `AUTO`;`is_tiered` 对 `(LOW, HIGH, MAX)` 为真、对 `(NONE, AUTO)` 与 `(AUTO,)` 为假;空元组构造报 `ValueError`。
|
||||||
|
|
||||||
|
**测试**(先失败后通过): `tests/unit/test_thinking.py::test_capability_derives_can_disable`、`::test_cheapest_effort_skips_none`、`::test_is_tiered_excludes_none_and_auto`、`::test_empty_efforts_rejected`。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v` → PASS;`conda run -n PolyGateway make lint` → PASS(含 import-linter: `Effort` 落 `types.py` 不得产生反向依赖,设计 §13 第 6 条)
|
||||||
|
|
||||||
|
- [ ] 提交: `refactor: make capability a tier list, since "can it be off" is one entry in it`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — `ThinkingWire` 与 8 段 provider 表
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/providers.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_providers.py`(改)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. 新增 `ThinkingWire`(见关键接口)。`None` 的语义严格沿用 issue #5: `on_base is None` = **开启形态未知**(请求开启档时报错),`off is None` = 该 provider 无关闭形态,`effort_key is None` = 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。
|
||||||
|
2. `ProviderProfile.thinking_on`/`thinking_off` 两字段替换为 `thinking: ThinkingWire`。
|
||||||
|
3. `DEFAULT_PROFILES` 由 4 段扩到 8 段:
|
||||||
|
|
||||||
|
| provider | off | on_base | effort_key |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `qwen` | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None` |
|
||||||
|
| `deepseek` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
||||||
|
| `zhipu` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
||||||
|
| `moonshot` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
||||||
|
| `minimax` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
||||||
|
| `openai` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
||||||
|
| `anthropic` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
||||||
|
| `google` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
||||||
|
|
||||||
|
`__init__.py` 同步导出 `ThinkingWire`。`openai` 段的两档由 `None`(未知)改为 OpenAI 标准形态,是本任务唯一的语义变更,理由写进注释: gpt-5.x 的 `reasoning_effort` 是 OpenAI 官方字段而非厂商方言,兜底段发它不会打到不认识它的厂商;真正未知形态的 provider 仍应走 `register_provider`。
|
||||||
|
|
||||||
|
**验收**: `get_provider("zhipu").thinking.effort_key == "reasoning_effort"`;未注册名仍报错且错误文案列出全部 8 段;`register_provider` 仍返回新表不改共享状态。
|
||||||
|
|
||||||
|
**测试**(先失败后通过): `tests/unit/test_providers.py::test_all_eight_profiles_registered`、`::test_wire_none_semantics_distinct`(三种 `None` 各自的含义不混淆)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `feat: give zhipu, moonshot, anthropic and google a wire of their own`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — `resolve_thinking` 五道关卡与 nearest 映射
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/thinking.py`(改)、`tests/unit/test_thinking.py`(改)
|
||||||
|
|
||||||
|
**行为**: 按下表实现,**顺序不可调换**,每关的理由写进 docstring。
|
||||||
|
|
||||||
|
| Phase | 条件 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `effort is None` | 返回 `{}` |
|
||||||
|
| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
|
||||||
|
| 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** |
|
||||||
|
| 4 | `effort is NONE` 且 `not capability.can_disable` | `ThinkingUnsupportedError`,文案含 `cheapest_effort` 与 env 键名 |
|
||||||
|
| 5 | `effort not in supported_efforts` 且 `fallback == "error"` | `ThinkingUnsupportedError`;文案按 `capability.is_tiered` 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 `auto`/`none`」(设计 §3.2 第三个派生量的用途) |
|
||||||
|
|
||||||
|
Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。
|
||||||
|
|
||||||
|
**注入形态**:
|
||||||
|
- `effort is NONE` → `wire.off`;`wire.off is None` 时报错(该 provider 无关闭形态)。
|
||||||
|
- `effort is AUTO` → `wire.on_base`(不附档位)。这与旧 `thinking_on` 逐字节等价。
|
||||||
|
- 其余档 → `{**wire.on_base, wire.effort_key: effort.value}`;`effort_key is None` 时报错并说明该 provider 只有开关没有档位。
|
||||||
|
|
||||||
|
**nearest 映射**(`fallback == "nearest"`,人类 2026-09-04 复核确认实现): 按 `_ORDER` 在 `supported_efforts` 中取距请求档**位序最近**者,等距时**取弱侧**(省钱优先,不替下游涨价);`AUTO` 不参与距离计算,仅当它是唯一候选时才被选中;映射发生时 warning 记明「请求档 → 实际档 → 模型」。`effort is NONE` 且不可关时**不走映射**——那是 Phase 4 的领域,必须报错给出路,否则又变成静默降级。
|
||||||
|
|
||||||
|
**验收**: 五关各自触发与不触发;`medium` 在 `(LOW, HIGH, MAX)` 上 `nearest` 映射到 `LOW`(等距取弱);`minimal` 映射到 `LOW`;`xhigh` 映射到 **`HIGH`**(与 `MAX` 等距,按「等距取弱」规则走——初稿此处写 `MAX` 是笔误,规则优先于例子)。**候选剔除 `none`**: 否则 `(none, auto)` 模型上请求 `high` 会被映射成 `none`,把「想浅一点」变成「别想了」,方向反转即 issue #20 那类静默失效。**`auto` 不受 Phase 5 清单约束**: 它在请求体里是「不写 `effort_key`」而非某个取值,可满足性只取决于 `on_base` 在不在;否则 `enable_thinking=True → AUTO` 会让存量源当场报错(能力表里档位型模型都不含 `auto`)。
|
||||||
|
|
||||||
|
**测试**(先失败后通过,**五关各一条**,兑现设计 §13 第 1 条): `::test_phase1_absent_effort_injects_nothing`、`::test_phase2_unknown_wire_points_to_register`、`::test_phase3_unregistered_warns_then_injects`(并断言 `warn_unregistered=False` 时不喊)、`::test_phase4_before_phase5`(请求 `none` 打到 glm-5.3,断言文案**含** `cheapest_effort` 值与 `REASONING_EFFORT` 键名)、`::test_phase5_lists_tiers_for_tiered_model`、`::test_phase5_says_toggle_only_for_switch_model`。
|
||||||
|
另: `::test_nearest_ties_go_cheaper`、`::test_none_never_maps`、`::test_auto_injects_on_base_only`、`::test_effort_key_none_rejects_tier`、`::test_resolution_reports_applied_effort_after_mapping`(请求 `medium` → 断言 `applied_effort is Effort.LOW`)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py -v` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `feat: refuse an impossible tier with the cheapest one that model does have`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 — 源级配置入口
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/config.py`(改)、`.env.example`(改)、`tests/unit/test_config.py`(改)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. `SourceConfig` 末尾追加 `reasoning_effort: Effort | None = None` 与 `effort_fallback: str = "error"`。
|
||||||
|
2. `config.py` 的 `_SOURCE_FIELDS` 增两行: `"REASONING_EFFORT": ("reasoning_effort", "effort")`、`"EFFORT_FALLBACK": ("effort_fallback", "str")`。新增 `"effort"` 解析类型: 值必须属 `Effort` 取值域,否则报错并列出八档。
|
||||||
|
3. `effort_fallback` 值域 `{"error", "nearest"}`,越界即报错(与 `_SELECTORS`/`_QUOTA_FULL` 同款 frozenset 校验)。
|
||||||
|
4. **矛盾校验**(构造期): 同源同时给出 `enable_thinking` 与 `reasoning_effort` 且语义冲突时 `ValueError`。冲突定义: `enable_thinking is True` 且 `reasoning_effort is NONE`;或 `enable_thinking is False` 且 `reasoning_effort not in (None, Effort.NONE)`。二者一致(如 `False` + `none`)则放行。
|
||||||
|
5. `.env.example` 加两键模板,注释写明八档取值、与 `ENABLE_THINKING` 的等价关系及矛盾会报错。
|
||||||
|
|
||||||
|
**验收**: `LLM__ZHIPU__1__REASONING_EFFORT=low` 解析为 `Effort.LOW`;写 `lowest` 报错且文案列出八档;`ENABLE_THINKING=true` + `REASONING_EFFORT=none` 构造期报错。
|
||||||
|
|
||||||
|
**测试**(先失败后通过): `tests/unit/test_config.py::test_effort_key_parsed`、`::test_invalid_effort_lists_vocabulary`、`::test_contradictory_thinking_flags_rejected`、`::test_consistent_flags_allowed`。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py -v` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `feat: let a source name its reasoning tier, and say so when it contradicts itself`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 — 请求级入口与优先级
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改,`effective_effort` 定义处)、`src/polygateway/client.py`(改)、`tests/unit/test_client.py`(改)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. `ChatRequest` 末尾追加 `reasoning_effort: Effort | None = None`。
|
||||||
|
2. `GatewayClient.chat()` 增关键字参数 `reasoning_effort: Effort | None = None`,存入 `ChatRequest`。
|
||||||
|
3. 新增纯函数(放 `thinking.py`,与其余推理决策同处):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def effective_effort(
|
||||||
|
*, request_effort: Effort | None, source_effort: Effort | None,
|
||||||
|
enable_thinking: bool | None,
|
||||||
|
) -> Effort | None:
|
||||||
|
"""生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
语法糖映射: `True` → `Effort.AUTO`(注入 `on_base`,与旧行为逐字节等价,且不依赖能力表);`False` → `Effort.NONE`;`None` → 不表态。
|
||||||
|
|
||||||
|
**验收**: 三层优先级各自生效;请求级 `None` 不会覆盖源级已配的档;只配 `enable_thinking=True` 的存量源解析为 `AUTO` 且最终 payload 与升级前逐字节相同。
|
||||||
|
|
||||||
|
**测试**(先失败后通过): `::test_request_effort_wins_over_source`、`::test_none_request_does_not_clear_source`、`::test_enable_thinking_true_is_auto`、`::test_legacy_on_tier_matches_old_fragment`(回归门: **仅**对 `on_base` 完整表达「开」的 provider——qwen/deepseek/zhipu/moonshot——断言逐字节不变;minimax/openai/anthropic/google 的开档旧版硬编码 `medium`、新版不注入,是设计 §4.2 声明过的有意变更)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `feat: let one call ask for a different tier than its source defaults to`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5b — 让 transport 拿得到请求级档位(端口签名扩展)
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/ports.py`(改)、`src/polygateway/middleware/retry.py`(改)、`src/polygateway/transports/openai_compat.py`(改)、`tests/unit/test_retry.py`(改)、`tests/unit/test_backpressure.py`(改)、`tests/integration/test_redis_cross_connection.py`(改)、`tests/unit/test_ports.py`(改)
|
||||||
|
|
||||||
|
**为什么单列一步**(Codex 审查查出的阻断问题): T5 只把 `reasoning_effort` 放进 `ChatRequest`,但 `Transport` 协议收的是**拆开的**参数(`messages/source/stream/overlay/call_id`,`ports.py:39-49`),`RetryMW._attempt` 也只传这五个(`retry.py:282-288`)。不扩展协议,请求级档位根本到不了 `_build_payload`,设计 §4.2 的优先级落不了地。
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. `Transport.complete` 协议增关键字参数 `reasoning_effort: Effort | None`。**不设默认值**——与 `TelemetryRecorder` 同一既有约定: 库外无第三方实现者,完整签名成本为零,而给默认值会让漏传变成静默的「不表态」。
|
||||||
|
2. `RetryMW._attempt` 调用处传 `request.reasoning_effort`。该中间件此前只读 `request` 的五个字段,新增第六个,不改其他语义。
|
||||||
|
3. `OpenAICompatTransport.complete` 接收并透传给 `_build_payload`。
|
||||||
|
4. 三个测试 fake 同步扩签名(`tests/unit/test_retry.py:72`、`tests/unit/test_backpressure.py:213`、`tests/integration/test_redis_cross_connection.py:76`)——`@runtime_checkable` 只查方法名不查签名,漏改会在调用时 `TypeError`,且错误现场离根因很远。
|
||||||
|
|
||||||
|
**不动**: `EmbeddingTransport`、`OcrTransport` 两个协议——它们无推理语义(与 issue #4 给 embedding 加 `extra_body` 被否决同理: 装配期报错比静默无效更能指路)。
|
||||||
|
|
||||||
|
**验收**: 请求级档位能一路到达 `_build_payload`;三个 fake 与协议签名一致;`tests/unit/test_ports.py` 的 Protocol 断言更新。
|
||||||
|
|
||||||
|
**测试**(先失败后通过): `tests/unit/test_retry.py::test_request_tier_reaches_transport`(断言 fake 收到的 `reasoning_effort` 与 `ChatRequest` 一致)、`::test_embedding_transport_signature_unchanged`(回归: 未误改另两个协议)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_backpressure.py tests/unit/test_ports.py -v` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `feat: carry the per-call tier down to the transport that must send it`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 — 缓存 key
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/client.py`(改)、`src/polygateway/middleware/cache.py`(改)、`tests/unit/test_cache.py`(改)、`tests/integration/test_redis_cache.py`(改,该文件亦断言 key 形状)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. `_fingerprint_mark`: 源级 `reasoning_effort` **仅在非 `None` 时**追加,规则与 `enable_thinking` 完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。
|
||||||
|
2. `build_cache_key` 增关键字参数 `reasoning_effort: Effort | None = None`,**仅非 `None` 时**写入 `key_obj["reasoning_effort"]`。
|
||||||
|
3. `CacheMW.__call__` 传 `request.reasoning_effort`。
|
||||||
|
|
||||||
|
**为什么两处都要**(写进注释): `model_fingerprint` 是装配期算的**集合级**指纹,覆盖不到逐次调用变化的请求级档位;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。ARCH §7.5 记载的「集合级指纹仍可能返回另一源响应」这一既有取舍原样延续,本任务不扩大。
|
||||||
|
|
||||||
|
**验收**: 同 messages 不同请求级档位 → key 不同;两者皆不表态 → key 与升级前逐字相同(回归);源级档位变化 → fingerprint 变化。
|
||||||
|
|
||||||
|
**测试**(先失败后通过): `::test_request_tier_changes_key`、`::test_absent_tier_keeps_legacy_key`(断言具体 key 字符串不变)、`::test_source_tier_enters_fingerprint`。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit -k "cache or fingerprint" -v` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `fix: keep a low-tier answer out of the cache slot a max-tier one filled`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7 — 遥测新增 `reasoning_effort` 列
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/telemetry/schema.py`、`src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`(均改)、`tests/unit/test_telemetry.py`(改,含列数断言)、`tests/unit/test_ports.py`(改,Protocol 签名断言)、`tests/integration/test_postgres_telemetry.py`(改——该文件有 `_EXPECTED_COLUMNS` 完整**列序**断言与 pre-tenant 历史 DDL 的列子集推导,共 5 处,漏改则 PG 集成测试必红)、`src/polygateway/middleware/retry.py`(改,`emit_attempt` 调用点传新参)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. `schema.py`: `COLUMNS` 末尾加 `"reasoning_effort"`(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加 `reasoning_effort TEXT`(位置与 ALTER 追加一致);补列声明同步。**列数断言按物理列写**——两套口径混用是本模块最易错处(见其 docstring)。
|
||||||
|
2. `ports.py`: `record_llm_call` 加 `reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。
|
||||||
|
3. 两个 recorder 落库新列。
|
||||||
|
4. `middleware/telemetry.py`: `_record` 加参并传给 recorder(**唯一** `record_llm_call` 调用点,不复制参数列表);`emit_attempt` 增 `applied_effort` 关键字参数,由其三个调用方传值——`retry.py:411` 传实际档,`embedding.py:407` 与 `ocr.py:451` 传 `None`(无推理语义)。三个 emit 入口取值口径分列:
|
||||||
|
|
||||||
|
| 入口 | 取值 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| `emit_attempt` | 成功时 `response.applied_effort`(T8 送上来的实际档);**失败时**回落到 `effective_effort(...)` 的请求档 | **不是**请求档: `nearest` 映射后二者不同(请求 `medium` → 实际 `LOW`),记请求档会让 T10 的压测把行挂在从未发出的档下。失败尝试没有 response,实际档不可知,记请求档并接受这一含义差别——总好过 issue #19 抱怨的「失败行无归因」 |
|
||||||
|
| `emit_cache_hit` | `request.reasoning_effort` | 缓存命中没有选中源,源级档位无从谈起 |
|
||||||
|
| `emit_terminal_failure` | `request.reasoning_effort` | 同上(可能根本没选出源) |
|
||||||
|
|
||||||
|
与 `sampling` 列的现有做法同构(`emit_attempt` 合并源级,另两处只取请求级)。
|
||||||
|
5. 值为 `Effort` 时取 `.value` 落库,`None` 落 `NULL`——与 `thinking_observation` 同一先例(`StrEnum` 是 `str` 子类,asyncpg 对子类编码不保证接受,遥测写失败只降级 warning,PG 那一路会静默少列)。
|
||||||
|
|
||||||
|
**验收**: 两端建表列数断言更新且通过;三个入口各自落值正确;不表态时为 `NULL`;`telemetry_schema_sql` 打印的 SQL 与库实际执行的 DDL 同源。
|
||||||
|
|
||||||
|
**测试**(先失败后通过): 既有遥测列数断言用例更新;`::test_effort_column_records_effective_tier`、`::test_cache_hit_records_request_tier_only`、`::test_absent_tier_is_null`。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit tests/integration -k telemetry -v` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `feat: record which tier a call actually ran at`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8 — transport 接线与对账
|
||||||
|
|
||||||
|
**文件**: `src/polygateway/transports/openai_compat.py`(改)、`src/polygateway/thinking.py`(改)、`tests/unit/test_openai_compat.py`(改)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. `_build_payload`: 用 `effective_effort(...)` 求生效档位后调 `resolve_thinking(..., fallback=source.effort_fallback)`。注入结果仍**先于** `source.extra_body` 与 `overlay`(顺序即优先级,issue #4 决策 A,两行不可调换)。
|
||||||
|
2. `_warn_on_thinking_mismatch` 的节流键由 `(source.name, source.model, source.enable_thinking)` 改为 `(source.name, source.model, effective_effort)`——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。
|
||||||
|
3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。
|
||||||
|
4. `ThinkingUnsupportedError` 的捕获与翻译路径不变(→ `RequestRejectedError`,不重试不换源不计熔断)。
|
||||||
|
5. **把实际档送出 transport**(否则遥测记不到 `nearest` 映射后的真实档):
|
||||||
|
- `TransportResult` 末尾追加 `applied_effort: Effort | None = None`——带默认值,非 OpenAI 兼容的 transport(OCR/embedding)可不填,与 `thinking_observation` 同一先例;
|
||||||
|
- `LLMResponse` 末尾追加 `applied_effort: Effort | None = None`——**字段只增不删不改名**,符合 ARCH §5.1 迁移兼容约束;对下游也有价值(它终于能知道这次实际跑在哪档);
|
||||||
|
- `RetryMW` 在 `retry.py:375` 的 `TransportResult → LLMResponse` 转换处带上该字段。
|
||||||
|
|
||||||
|
**验收**: 档位不支持时抛 `RequestRejectedError` 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;`reconcile` 三类文案与既有逐字一致(除方向描述由 bool 改档位);`nearest` 映射后 `LLMResponse.applied_effort` 是**映射后**的档。
|
||||||
|
|
||||||
|
**测试**(先失败后通过): `::test_unsupported_tier_is_request_rejected`、`::test_no_retry_on_tier_error`、`::test_throttle_key_separates_tiers`、`::test_reconcile_none_vs_observed`、`::test_response_carries_mapped_tier`(请求 `medium`、能力 `(LOW,HIGH,MAX)` → 断言 `response.applied_effort is Effort.LOW`)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `feat: wire the tier through the transport and keep each tier's warning distinct`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9 — 全套件、文档与 wiki
|
||||||
|
|
||||||
|
**文件**: `CHANGELOG.md`、`.env.example`(复核)、Gitea Wiki(按 `research-wiki/docs-convention.md` §2)、`src/polygateway/__init__.py`(版本号)、`pyproject.toml`(版本号)
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. `make lint` + `make test` 全绿;`make format`。
|
||||||
|
2. CHANGELOG 加「未发布」段: 破坏性变更(`ThinkingCapability` 构造签名)、新增(八档 `Effort`、两个 env 键、遥测新列、四个 provider 段)、行为变更(`openai` 段两档由未知改为 OpenAI 标准形态)。
|
||||||
|
3. 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。**CHANGELOG 必须覆盖三条行为变更**,漏第三条是独立验证点名的风险: ① `ThinkingCapability` 构造签名(破坏性);② minimax/openai/anthropic/google 开档不再注 `medium`;③ `openai` 兜底段由「形态未知即报错」放宽为标准形态——把别家模型挂在该段下并配 `ENABLE_THINKING=true` 的下游,旧版装配期报错,新版静默不注入任何字节(对这四段涉及的模型无害,它们默认即推理;但语义变了,须明写)。
|
||||||
|
4. 版本号 **`1.3.3`**(2026-09-05 人类指令;不因破坏性变更走 minor),`pyproject.toml` 与 `src/polygateway/__init__.py` 两处一致。**本任务只 bump 不发布**——发布走 CLAUDE.md §4.4.1 全清单。
|
||||||
|
|
||||||
|
**验收**: `make ci` 通过;CHANGELOG 与 wiki 均含破坏性变更条目。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway make ci` → PASS
|
||||||
|
|
||||||
|
- [ ] 提交: `docs: cut 1.3.3 notes for the tier work`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10 — e2e 实测校正初始能力表(标 `slow`)
|
||||||
|
|
||||||
|
**文件**: `tests/e2e/test_thinking_live.py`(改)、`src/polygateway/thinking.py`(改——`DEFAULT_CAPABILITIES` 与 evidence 就在此处,实测结论要写回它,否则本任务只跑不改,设计 §8/§13 第 5 条落不了地)
|
||||||
|
|
||||||
|
**行为**: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 `supported_efforts`,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 `usage.completion_tokens_details.reasoning_tokens`;对声明不可关的模型额外验证「请求 `none` 是否真被拒或真未关」。测试标 `slow`(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 `evidence` 中的「文档推定」。
|
||||||
|
|
||||||
|
**为什么必须单列一个任务**: 人类 2026-09-04 定「能力表数据统一自己经 new-api 实测」;Task 1 落的是文档推定值,不实测则整张表都是假设。
|
||||||
|
|
||||||
|
**验收**: 每个已登记模型有一条实测记录;与文档推定不符者更新 `supported_efforts` 并在 evidence 记明分歧(尤其 `kimi-k3` 的保守登记、`gemini-3.1-pro` 的默认档两源打架)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v` → PASS(约 20-40 分钟,取决于网关)
|
||||||
|
|
||||||
|
- [ ] 提交: `test: replace the guessed tier table with what the gateway actually does`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行顺序与依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport)
|
||||||
|
T2(wire) ─────────┘ ↑
|
||||||
|
T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤
|
||||||
|
│ │
|
||||||
|
└─→ T6(缓存 key) ↓
|
||||||
|
T7(遥测) ─→ T9(文档) ─→ T10(实测,回写能力表)
|
||||||
|
```
|
||||||
|
|
||||||
|
T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);**T5b 依赖 T5**(要有 `ChatRequest.reasoning_effort` 才有得传);T6 依赖 T5;T8 依赖 T3 + T5b(没有 T5b 就拿不到请求级档位);**T7 依赖 T8**(自审纠正: 遥测要记的实际档由 T8 在 transport 内算出并经 `TransportResult`/`LLMResponse` 送上来,先做 T7 只能记到请求档);T9 在功能任务全绿后;T10 最后,且它会**改回 `thinking.py`**——与 T1 同一文件,故必须排在最后而非与其并行。
|
||||||
|
|
||||||
|
执行方式: 10 个任务耦合度中等(共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型),**直接按计划实现**,不派 `subagent-driven-development`——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-09-09-134-thinking-contracts
|
||||||
|
title: "1.3.4 推理契约实施计划"
|
||||||
|
date: 2026-09-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.4 推理契约与测试证据实施计划
|
||||||
|
|
||||||
|
> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),T0–T7 已实现并通过确定性验证;T8 文档已同步,独立验证/集成/live 与 T9 待执行**。
|
||||||
|
> 设计:`research-wiki/designs/2026-09-09-134-thinking-contracts-design.md`,用户已正式批准。
|
||||||
|
> 目标:解决 #21 的受管推理语义漏洞、#25 的测试归因漏洞、#26 的客户端遥测守卫缺口,不扩展生产端口或遥测 schema。
|
||||||
|
> 方案:在既有推理决策层添加窄校验并接入工厂/默认 transport;测试侧独立保留请求与响应证据,按明确命题判定覆盖。缓存仍由下游显式迁移,生产治理循环不重写。
|
||||||
|
> 技术:Python 3.12+、asyncio、httpx hooks/MockTransport、frozen dataclass、pytest、临时 SQLite、ruff、import-linter;不新增依赖。
|
||||||
|
|
||||||
|
本计划不涉及参考实现迁移,保真校验不适用;不得变更 Redis Lua、429/stall、取消结算、结构化重试或 #19/#23/#24 的生产机制。
|
||||||
|
|
||||||
|
## 1. 基线、授权与执行纪律
|
||||||
|
|
||||||
|
| 项目 | 固定边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| 分支/历史 | `feature/1.3.4-thinking-contracts`;保留已有 `758a127`/`6a09054`,不重写 main 历史;开始时记录实际 HEAD 与 origin/main |
|
||||||
|
| D1 | 已登记 AUTO 必须为清单成员;未知 AUTO 保留尽力+warning,空 wire 可能不发送推理字节,不保证开启 |
|
||||||
|
| D2 | 受管意图非 None 时,两层 raw 推理控制同值/被遮蔽也拒绝;raw-only 保留,不反向推断实际档 |
|
||||||
|
| D3 | 不添加 fallback 指纹、语义 revision、能力表版本或新缓存前置解析;显式更换 namespace/salt 是操作前置,未迁移可能回放旧语义 |
|
||||||
|
| 实施权限 | 一工作区仅一 writer;父会话负责前台委派与审核。用户已授权门通过后自行合并 main、测试并发布 1.3.X,无需逐步请示;1.4、新公共决策、验证豁免须停下确认 |
|
||||||
|
| 证据与秘密 | 不打印 `.env`、token、Authorization、私有提示词;不提交 `.pi/`、运行报告或 reference;命令输出只记录安全路径、状态、退出码 |
|
||||||
|
|
||||||
|
T0 开始调用 `writing-plans`;T1–T7 行为测试执行 `test-driven-development` 并阅读其 testing-anti-patterns;T1/T5 落日志前执行 `structured-logging`。每次提交执行 `commit` skill(英文祈使标题、无 AI 签名、显式路径暂存),T8 前执行 `requesting-code-review`/`verification-before-completion`,收到意见执行 `receiving-code-review`;异常先用 `systematic-debugging` 定根因。
|
||||||
|
|
||||||
|
用户自主发布授权涵盖既有发布清单的真实 slow 套件,沿既有配置/轮次/并发执行,不重复索取这一授权。超出既有测试矩阵的新研究实验先提交型号、轮次、并发和费用预算;禁止借研究名义追加裸 HTTP 对照。设计要求的新增覆盖先用现有矩阵表达,无法表达且增加调用量时升级该预算决策。
|
||||||
|
|
||||||
|
## 2. 文件职责与不变接缝
|
||||||
|
|
||||||
|
| 创建/修改 | 精确路径 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 修改 | `src/polygateway/thinking.py` | AUTO 成员检查、nearest 边界、wire 与 raw 冲突纯校验、错误及未知告警文案 |
|
||||||
|
| 修改 | `src/polygateway/providers.py` | MiniMax on_base 改空;修正空 wire docstring,不在声明层引入决策依赖 |
|
||||||
|
| 修改 | `src/polygateway/client.py` | `_guard_thinking` 校验源 raw;`chat` 请求显式档+已知 raw 冲突前置校验;指纹不改 |
|
||||||
|
| 修改 | `src/polygateway/transports/openai_compat.py` | `_build_payload` 完整守卫,两层浅覆盖次序不改,沿 complete 的异常翻译 |
|
||||||
|
| 修改 | `tests/unit/test_thinking.py`、`tests/unit/test_providers.py` | 纯解析、声明、已知/未知/自定义 wire、告警与边界 |
|
||||||
|
| 修改 | `tests/unit/test_client.py`、`tests/unit/test_config.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py` | 工厂、请求前置、真实 transport、无 HTTP 拒绝与治理收尾;离线兼容 |
|
||||||
|
| 修改 | `tests/unit/test_cache.py` | 显式迁移和未迁移风险回归;保留旧键黄金值 |
|
||||||
|
| 修改 | `tests/unit/test_embedding.py`、`tests/unit/test_ocr_client.py`、`tests/unit/test_telemetry.py`、`tests/unit/test_monkey_ocr.py` | 三入口 NULL、阳性、SQLite 与 wire;不改变生产 emitter/client 循环 |
|
||||||
|
| 新建 | `tests/live_evidence.py` | 测试专用 frozen 证据、有限归因、身份与覆盖判据、逐轮安全报告;无环境自读取 |
|
||||||
|
| 新建 | `tests/e2e/conftest.py` | 测试侧 hooks、任务局部关联、薄 transport 委托与配置装配;不复制生产 payload/重试算法 |
|
||||||
|
| 新建 | `tests/unit/test_live_evidence.py` | 分类、hooks/委托器和报告离线反例;导入新 conftest 中无副作用定义,不导入读 .env 的 live 模块 |
|
||||||
|
| 修改 | `tests/e2e/test_smoke_gateway.py`、`tests/e2e/test_compat_projects.py`、`tests/e2e/test_embed_probe.py`、`tests/e2e/test_thinking_live.py` | 迁入窄证据通道、逐轮完整性与命题分流,保留必须真实执行的行为断言 |
|
||||||
|
| 修改 | `README.md`、`CHANGELOG.md`、`.env.example`、`research-wiki/ARCHITECTURE.md`、`research-wiki/designs/2026-09-04-reasoning-effort-design.md` | 用户可达迁移说明、架构同步、旧设计被替代指针;不追改历史实验事实 |
|
||||||
|
| 修改/登记 | `research-wiki/schemas/llm-calls.md`、`research-wiki/metrics/call-telemetry-coverage.md`、`research-wiki/graph/edges.json`、`research-wiki/index.md`、`research-wiki/log.md` | 复用既有实体,登记本计划与四种遥测口径;只接受工具对相关实体的必要索引更新 |
|
||||||
|
| 新建(验收时) | `research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md` | 红绿、变异、失败与豁免索引,≤300 行;原始输出留 `tests/outputs/134/` |
|
||||||
|
| 修改(发布时) | `pyproject.toml`、`src/polygateway/__init__.py` | 两处版本一致到 1.3.4,不改变依赖或导出面 |
|
||||||
|
|
||||||
|
生产不修改 `ports.py`、`types.py`、`errors.py`、cache/telemetry 实现及 embedding/OCR 循环;若实际实现需要突破该清单,先说明设计要求与最小原因,由父会话核定,不顺手改动。
|
||||||
|
|
||||||
|
## 3. 跨任务接口(内部实现约定,不新增公共导出)
|
||||||
|
|
||||||
|
### 3.1 推理守卫
|
||||||
|
|
||||||
|
新函数置于 `thinking.py`,其余模块显式 import;保持决策方向 `client/transport → thinking → providers/types`。`Mapping`、`Any`、`Effort`、`ThinkingWire` 均为既有类型。函数体由 T2 实现,以下固定消费者签名:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def validate_thinking_wire(wire: ThinkingWire, *, model: str) -> None:
|
||||||
|
"""拒绝 on_base 偷带已知强度,抛 ThinkingUnsupportedError。"""
|
||||||
|
def validate_thinking_raw(
|
||||||
|
raw: Mapping[str, Any], *, effort: Effort | None,
|
||||||
|
wire: ThinkingWire | None, origin: str,
|
||||||
|
) -> None:
|
||||||
|
"""effort 表态时拒绝 raw 控制;wire=None 只检查标准词表。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
签名中的 wire 必填但可 None,None 是 chat 前置看不到实际 profile 的事实,不是容错默认;origin 只取固定位置名/源名,不含 raw 值。两函数无 I/O,不改变输入,抛现有 `ThinkingUnsupportedError`(ValueError 子类),不新建错误类。`validate_thinking_wire` 在 `resolve_thinking` 的 None 早退之前验证声明结构;不会要求无意图时 wire 必须已知,只拒绝结构上偷带强度。
|
||||||
|
|
||||||
|
标准 raw 根:`reasoning_effort`、`enable_thinking`、`thinking`、`thinking_budget`、`reasoning`、`thinkingConfig`;`output_config` 为 Mapping 且有 `effort` 时冲突。wire 可见时并入 on_base/off 全部顶层根与 effort_key,点号只是字面键。
|
||||||
|
|
||||||
|
wire 校验只拒绝 on_base 中自己的非 None effort_key、标准 reasoning_effort、标准嵌套 output_config.effort(含值为 auto/None);不解析任意私有方言。未知/非当前方向的形态检查继续由现有 `_wire_unknown_for` 负责,不改变 off-only 可用性。
|
||||||
|
|
||||||
|
### 3.2 测试证据与归因
|
||||||
|
|
||||||
|
`tests/live_evidence.py` 不 import e2e conftest、不读环境、不发网络。上下文中的密钥只做内存比较,不进下列值类型。一个 attempt 可以记录零/一/多 HTTP 事件;不能用 len(attempts) 冒充 HTTP 数。
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HttpEvidence:
|
||||||
|
call_id: str
|
||||||
|
request_checks: tuple[tuple[str, bool], ...]
|
||||||
|
status_code: int
|
||||||
|
error_body: bytes | None
|
||||||
|
raw_identity: tuple[bool, str | None]
|
||||||
|
```
|
||||||
|
|
||||||
|
`raw_identity` 是 T5 生产、T6 消费的**成功非流式原始身份快照**:(False, None) 表示未取证,(True, None) 表示已独立解析完整 JSON 对象且 model 缺失/为 null,(True, str) 表示原始字符串。由 response hook 保存的响应引用在该次 complete 结束后读取已缓冲 content,独立 JSON 解码(拒绝重复键)取得;不得从 TransportResult/LLMResponse.model_reported 回填。未缓冲、无可配对响应、非成功非流式、JSON 非对象/非法或 model 非字符串非 null 均不生成肯定证据,并保存原因供 FAIL;不把解析异常解释成身份缺失。成功 SSE 始终 (False, None),不新增捕获器、不预读流。
|
||||||
|
|
||||||
|
`request_checks` 必须完整包含 method/origin/path/model/stream(embedding 为 input_shape)/authorization/control/messages_digest,缺项不算全过;error_body 只允许 ≤65536 字节已缓冲完整错误体,超限或未缓冲为 None,并在安全报告写证据不足,不把截断文本拿来解析。记录只存在测试内存,不能直接 asdict 后落盘。
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AttemptEvidence:
|
||||||
|
call_id: str
|
||||||
|
http: tuple[HttpEvidence, ...]
|
||||||
|
error: Exception | None
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LiveVerdict:
|
||||||
|
status: Literal["PASS", "FAIL", "UNCOVERED"]
|
||||||
|
reason: str
|
||||||
|
```
|
||||||
|
|
||||||
|
消费者固定为 T5→T6,纯函数与报告出口如下;参数所用 Path、Mapping、Sequence、ThinkingObservation、Effort 为标准库/现有领域类型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def classify_live_failure(
|
||||||
|
error: Exception, attempts: Sequence[AttemptEvidence],
|
||||||
|
) -> LiveVerdict:
|
||||||
|
"""异常分类仅 FAIL/UNCOVERED;取消不交给此函数。"""
|
||||||
|
def write_live_round(
|
||||||
|
output_dir: Path, *, run_id: str, matrix_id: str, round_index: int,
|
||||||
|
safe_fields: Mapping[str, Any],
|
||||||
|
) -> Path:
|
||||||
|
"""只接受已脱敏报告字段,唯一文件写失败必须冒泡。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
身份与命题判定继续放 `tests/live_evidence.py`,避免 e2e 中四份条件分支:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def assess_model_identity(
|
||||||
|
*, requested: str, aliases: frozenset[str], reported: str | None,
|
||||||
|
raw_identity: tuple[bool, str | None], request_valid: bool,
|
||||||
|
) -> LiveVerdict:
|
||||||
|
"""raw_identity[0] 表示有独立原始身份取证;无证据不得归因上游。"""
|
||||||
|
def assess_thinking_coverage(
|
||||||
|
observations: Sequence[ThinkingObservation], *, planned_rounds: int,
|
||||||
|
proposition: Literal["enabled", "disabled", "cannot_disable"],
|
||||||
|
) -> LiveVerdict:
|
||||||
|
"""输入须先过请求/身份资格;缺轮与 UNKNOWN 不补足证明。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
拒绝能力测试不送入以上观测函数,按预声明异常类型/状态/机器字段独立断言。`cannot_disable` 保留 T10“实际未关闭与声明比较”的反证形态:完整合格轮次有 OBSERVED 可支持本条件下不可关闭;全 ABSENT 证伪声明;无 OBSERVED 但有 UNKNOWN 只能未覆盖。不得扩大成“证明所有私有上游参数都无法关闭”。
|
||||||
|
|
||||||
|
### 3.3 测试侧取证装配
|
||||||
|
|
||||||
|
新 `tests/e2e/conftest.py` 内 `ObservedTransport` 包裹**同一个**真实 `OpenAICompatTransport`,complete/embed 签名逐字保持 `ports.py`,参数原样传递。每次调用将 call_id 绑定实例持有的 ContextVar,finally reset;异常原样上抛,CancelledError 不转普通错误。不得在委托器做治理重试或 payload 修正。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LiveCapture:
|
||||||
|
def __init__(self, *, expectations: Mapping[str, Mapping[str, Any]]) -> None:
|
||||||
|
"""按源名持有测试矩阵显式预期,不含凭据或真实响应。"""
|
||||||
|
def round_context(self, *, session_id: str, parent_call_id: str) -> AbstractContextManager[None]:
|
||||||
|
"""外围绑定逻辑轮次,finally 复位,嵌套任务不串线。"""
|
||||||
|
def client_factory(self, source: SourceConfig) -> httpx.AsyncClient:
|
||||||
|
"""按实际源构造带 hooks 客户端,沿生产 timeout/trust_env。"""
|
||||||
|
def attempts(self, *, session_id: str, parent_call_id: str) -> tuple[AttemptEvidence, ...]:
|
||||||
|
"""返回本轮快照,含零 HTTP 的尝试,不从最终异常猜前序。"""
|
||||||
|
def raw_identity(self, *, session_id: str, parent_call_id: str, call_id: str) -> tuple[bool, str | None]:
|
||||||
|
"""精确读取本逻辑轮次和成功 attempt 唯一 HTTP 事件的原始身份快照。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`LiveCapture.expectations` 由调用者按源名提供,内层必需键为 model、origin、path、stream(embedding 用 input_shape)、control、messages_digest;缺键直接测试配置错误,不自动从待测 payload 补齐。结构化预期片段放 control 的显式预期对象,输出路径由 write_live_round 单独接收;并发异构轮次使用各自 capture 实例,不共享可变“当前预期”。预期不调用生产 `_build_payload` 生成。实际请求 hooks 逐项比较,凭据不进 repr/序列化;可保留失败事实后由轮次出口 FAIL,不能改写实发请求“修正”它。
|
||||||
|
|
||||||
|
取证 live 使用 `GatewayClient(...)` 全量注入。conftest 可复用 `client.py` 现有 `_build_limiter`/`_build_breaker`/`_build_selector`/`_build_structured` 等装配函数,但不新增生产注入口、不复制它们实现;自己创建的组件用 ExitStack/显式 finally 关闭,注入 GatewayClient 不会代关。工厂行为单独离线验证。未获得取证通道的工厂 live,异常一律保存后 FAIL。
|
||||||
|
|
||||||
|
T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidence` 保存快照;T6 使用最终 `LLMResponse.call_id` 调用 `capture.raw_identity(...)`,将返回值交给 `assess_model_identity(raw_identity=...)`,不可取本轮最后一条响应猜关联。查找必须精确匹配本轮且只有一条成功 HTTP 事件;重复/跨轮 call_id、多个候选响应是取证契约错误,报告后 FAIL,不降成外因未覆盖。未发 HTTP 或没有独立身份快照时返回 (False, None)。
|
||||||
|
成功非流式原始 model 通过上述快照接口交付;成功 SSE 不加捕获器、不预读流。原始身份无从确认而公共结果缺失/不符时 FAIL;原始正确而结果丢失/改错也 FAIL。只要公共身份合格且请求合格,正常能力测试不要求新增成功 SSE 的原始副本。
|
||||||
|
|
||||||
|
## 4. 任务与提交点
|
||||||
|
|
||||||
|
### T0:基线、计划审查与文档回滚点
|
||||||
|
|
||||||
|
- [x] 修改设计批准状态,新增本计划;父会话自审后前台 Codex 独立审,具体问题修正后方可执行 T1。计划无需再走人类门,不能把“已生成”当“已审”。
|
||||||
|
- [x] 记录 `git status --short --branch`、`git log --oneline origin/main..HEAD`、实际 HEAD;确认源代码零差异,保存未跟踪文件清单,禁止暂存 `.pi/`。
|
||||||
|
- [x] 执行基线:`make check`;`conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_cache.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py -q`。记录实际失败,不能先改期待绕过;本步骤预期现有非 slow 测试通过。
|
||||||
|
- [x] 以 `research-wiki` 工具登记 design/plan 节点及 implements 边,现有同路径文档不可被 add_entity 模板覆盖;先读工具已有文件处理行为,再登记、重建索引、检查生成 diff。只在本计划 writer 移交后由父会话执行这些额外文件写入。
|
||||||
|
- [x] 调用 commit skill,提交点 `docs: record approved thinking contracts and implementation plan`,形成生产修改前回滚点。
|
||||||
|
|
||||||
|
### T1:AUTO 成员语义与默认 MiniMax wire
|
||||||
|
|
||||||
|
**文件**:`thinking.py`、`providers.py`;`tests/unit/test_thinking.py`、`tests/unit/test_providers.py`,路径均按 §2。
|
||||||
|
|
||||||
|
1. 先添加/替换 `test_auto_never_trips_phase5`,以已登记不含 AUTO 的空/非空 on_base 为反例;调用 resolve_thinking 的 error/nearest 都明确拒绝且不提示 nearest。先跑新测试,旧实现因未拒绝而红;再修改 `_settle_tier`、`_tier_unsupported`,避免 AUTO 进入 `EFFORT_ORDER.index`。
|
||||||
|
2. 保留强度→纯开关 AUTO、等距弱侧、NONE 不自动映射、None 不表态、未知空/非空 wire 尽力警告。删除 default MiniMax 的 medium 并修正注释;先测试 M3 AUTO 拒绝、M3 medium payload、M2.5/M2.7 AUTO 空 payload 与 applied=AUTO。默认能力表成员不增删。
|
||||||
|
3. 用 loguru sink 检查未知告警确实说明不保证生效,default transport 实例节流不改;不引入新日志通道,不输出 raw 密钥。按 structured-logging 明确这是既有 warning 与既有列的修正。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py -q`;新拒绝和 wire 回归先红后绿,其余保留行为通过。若旧下游形态测试依赖 M3 True,需要在 T3 明确改为已批准迁移样本,不能暗改能力表让它绿。
|
||||||
|
|
||||||
|
- [x] 提交点:`fix: enforce registered auto reasoning capabilities`。
|
||||||
|
|
||||||
|
### T2:纯 wire/raw 所有权校验
|
||||||
|
|
||||||
|
**文件**:`src/polygateway/thinking.py`、`tests/unit/test_thinking.py`。实现 §3.1 两个签名;`resolve_thinking` 集中校验 wire,providers 不反向 import thinking。
|
||||||
|
|
||||||
|
| 红绿组 | 最小反例/保留不变量 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标准控制 | 六个顶层根+output_config.effort;AUTO 空片段仍拒绝 raw high;NONE/糖/未知同测 |
|
||||||
|
| 双来源 | 同值仍拒绝;分别传源与请求 raw 验证,被后层遮蔽也拒绝;不修改两个 Mapping |
|
||||||
|
| 自定义 | on_base/off 根并集、effort_key 自定义字面键、点号不解释路径;on_base 偷带自己的键或标准强度值(含 None)拒绝 |
|
||||||
|
| 嵌套 | 替换 thinking 整个根即拒绝;profile 不拥有 output_config 时仅 format 可过、effort 不可过;拥有根时 format 也不可覆写 |
|
||||||
|
| 不误伤 | effort=None 时 raw 原样允许;temperature/seed/response_format 不属词表,合法普通采样保持;off-only 形态及当前方向未知语义保持 |
|
||||||
|
|
||||||
|
新校验首次未实现导致的 import 错误不算行为红;可先在隔离基线把同输入经现有 payload 路径表现记录为“覆盖成功但本应拒绝”,或待 T3 在旧实现回放其失败断言,补齐语义红证据。纯函数自身还需逐例断言异常及未修改输入。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_thinking.py -q`,每类目标反例有有效红绿,保留行为绿。
|
||||||
|
|
||||||
|
- [x] 提交点:`fix: validate ownership of managed reasoning parameters`。
|
||||||
|
|
||||||
|
### T3:接入工厂、请求入口和默认 transport
|
||||||
|
|
||||||
|
**文件**:`src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`;`tests/unit/test_client.py`、`test_config.py`、`test_openai_compat.py`、`test_retry.py`。
|
||||||
|
|
||||||
|
先在旧路径跑源 HIGH+相同 raw HIGH、本次 AUTO+raw HIGH 的行为反例,确认旧实现实际发出 raw 参数而未拒绝。再接入两守卫:工厂先求 effective_effort 并校验 source.extra_body;chat coerce 后调用 wire=None 的已知词表检查;transport 对当前 profile 和 effective_effort 分别检查 extra_body/overlay,再保持原浅 update 顺序。
|
||||||
|
|
||||||
|
| 接缝 | 验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工厂 | SourceConfig 仍能表达 raw-only;from_env/from_settings 对受管源拒绝发生在 limiter/HTTP client 创建之前;记录构建计数零,不以网络偶然没发代替 |
|
||||||
|
| 请求前置 | 显式请求档与 overlay 已知键冲突,ValueError 且 handler/准入未触发;源级意图或自定义根留 transport 再查 |
|
||||||
|
| 全量注入 | 真实 OpenAICompatTransport 翻译为 RequestRejectedError;MockTransport 记录零 HTTP,RetryMW 不换源不重试、limiter inflight=0、已有探针收尾路径正常 |
|
||||||
|
| 参数保真 | raw-only 允许,applied=None;普通采样源<请求<结构化 overlay 的现状保留;显式档/nearest 成功 payload、TransportResult/LLMResponse applied 与真实成功遥测相符 |
|
||||||
|
| 多源/并发 | 每次以选中源 profile 校验,不因另一个源清单不同提前判整个池死;共享 client 无“最后档”串线;错误不包含 raw 值 |
|
||||||
|
|
||||||
|
工厂将来被请求覆盖不能救活一个已拒绝源,这是已批行为。不要为全量注入自定义 transport 添加 preflight 端口。已有 fixture 需要调整时,只将不再合法的受管+raw 双来源改为显式单来源,新增拒绝反例保留迁移证明。
|
||||||
|
|
||||||
|
**新增生产默认 HTTP factory 离线守卫**:现有 `tests/unit/test_openai_compat.py` 没有 auth/timeout/trust_env 构造断言,不能写作“保留”。新增 `TestDefaultClientFactory`,直接调用生产 `_default_client_factory(source)` 返回真实 AsyncClient,不使用 T5 的测试 factory,也不 mock 整个 AsyncClient。用两组不同假 api_key、非默认 timeout_s、trust_env=True/False 参数化;不发送网络,finally aclose。节点为 `test_authorization_uses_source_api_key`(检查 client.headers 及 build_request 生成的 Authorization)、`test_timeout_uses_source_timeout_for_all_phases`(connect/read/write/pool 全部等于输入 timeout_s)、`test_trust_env_uses_source_setting`(检查 client.trust_env)。在隔离副本逐个删除 Authorization 传入、遗漏 timeout 参数、遗漏 trust_env 参数/写死 True,指定节点必须因值不符红,再恢复通过;当前实现本来正确,以这些语义变异作为红证据,不改生产 factory 凑红。
|
||||||
|
独立命令:`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py::TestDefaultClientFactory -q`,原始实现绿、每个遗漏变异被对应断言杀死、恢复绿;这套测试与 T5 hooks 校验分别验证生产装配和测试取证两条路径。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -q`,加 T1/T2 的测试一起跑;工厂/请求/全量注入拒绝均有旧实现红、新实现绿。
|
||||||
|
|
||||||
|
- [x] 提交点:`fix: reject conflicting raw reasoning overrides before sending`。
|
||||||
|
|
||||||
|
### T4:显式缓存迁移和四种遥测口径回归
|
||||||
|
|
||||||
|
**文件**:`tests/unit/test_cache.py`、`tests/unit/test_client.py`、`tests/unit/test_telemetry.py`;不改生产 cache、指纹或 emitter。
|
||||||
|
|
||||||
|
使用真实 InMemoryCache、GatewayClient、默认 transport+MockTransport 构造两个客户端。旧语义 payload 可按 1.3.3 真实序列化形态预写(历史数据夹具,不需要在当前生产放回漏洞);同版本 nearest→error 则运行真实客户端写入。
|
||||||
|
|
||||||
|
| 场景 | 断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| 旧 AUTO/raw 记录 | 旧身份可回放是已知风险;换全新 namespace 或 salt 后 miss,实际进入新拒绝,异常不缓存 |
|
||||||
|
| nearest→error | 源不表态、请求 medium、模型 glm-5.3;nearest 写入后 error 同身份可命中;error 换身份后零 HTTP 拒绝,不添加 fallback 指纹 |
|
||||||
|
| 能力表变化 | 新增 `TestExplicitCacheMigration::test_capability_change_requires_explicit_identity`:相同源配置、请求 AUTO 和 wire,两客户端注入同一测试模型的不同能力表(旧含 AUTO+HIGH,新仅 HIGH),源级不表态以允许装配。旧客户端真实写入后新客户端同身份回放且无新 HTTP;换全新 namespace 或 salt(参数化)后 miss,进入新能力表并 RequestRejected、无新 HTTP、不写失败值。只用局部测试能力表,不修改 DEFAULT |
|
||||||
|
| 自定义 wire 变化 | 新增 `TestExplicitCacheMigration::test_custom_wire_change_requires_explicit_identity`:相同源/模型/能力表及请求 HIGH,分别注入同名自定义 profile 的旧/新 effort_key(例如 depth_a/depth_b),on_base 均为空。旧客户端写缓存,新客户端同身份回放旧值且无新 HTTP;新 namespace 或 salt 后 miss,MockTransport 必须收到 depth_b=high 且无 depth_a,返回可区分的新结果,旧身份仍能回放旧值。profile 仅局部注入,不改源配置让现有指纹意外变化 |
|
||||||
|
| 入口与范围 | 工厂默认 namespace、per-call 覆盖默认、构造全量注入、共享多源 scope、两个租户原前缀保留;只改默认无法覆盖 per-call,需专门反例 |
|
||||||
|
| 并行/回滚 | 旧新身份可并行且不覆盖对方;回到旧身份确实重见旧值;未受影响调用 key 黄金值逐字不变 |
|
||||||
|
| 四行口径 | 真实成功=applied、失败尝试=effective 意图、cache_hit/scope 终态=本次请求级;缓存不读取历史 applied 作本次遥测档 |
|
||||||
|
|
||||||
|
该任务多数是已有正确行为的守卫,不人为改生产获得红:隔离变异遗漏 namespace/salt、将 cache_hit 遥测改读历史 applied,要求相应行为断言红,恢复后绿。T3 新拒绝路径旧实现红绿可复用,但不能只报它替代迁移维度证据。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_client.py tests/unit/test_telemetry.py -q`。两项新增能力/wire 迁移节点均置于 `tests/unit/test_cache.py::TestExplicitCacheMigration`,单跑 `conda run -n PolyGateway pytest tests/unit/test_cache.py::TestExplicitCacheMigration -q`;分别在隔离副本去掉其 namespace/salt 隔离输入,必须因没有新拒绝/新 wire 而红,恢复后绿,不能只以 nearest→error 的测试代替这两类。两客户端的源指纹必须断言相等,生产指纹算法一字不改。
|
||||||
|
|
||||||
|
- [x] 提交点:`test: pin explicit cache migration and reasoning row semantics`。
|
||||||
|
|
||||||
|
### T5:有限测试归因和独立取证
|
||||||
|
|
||||||
|
**文件**:新增 `tests/live_evidence.py`、`tests/e2e/conftest.py`、`tests/unit/test_live_evidence.py`。按 §3.2/3.3 实现;不读 .env 的模块可被日常单测安全 import。执行 structured-logging:记录内容按设计 §6,不另建库表。
|
||||||
|
|
||||||
|
1. 纯分类默认 FAIL。仅一条完整 HTTP 错误、请求检查齐全、无别的 attempt 异常、404、完整无重复键 JSON 的 error.type 精确匹配、外抛 RequestRejectedError 且 status 一致,才 UNCOVERED;多次尝试/多 HTTP、不同 call_id、空检查元组、重复 JSON 键均 FAIL。
|
||||||
|
2. hooks 不预读成功 SSE,不将 summary 当 JSON;response 引用等该次 transport 完成后检查 content 是否已缓冲。64 KiB 上限、0 字节、非对象 error、重复键、坏编码各有反例。薄委托器 finally 恢复上下文,零 HTTP 尝试也保存。
|
||||||
|
3. 身份函数区分 raw 取证缺失和原始响应明确缺 model;增加 `test_raw_identity_snapshot_reaches_round_consumer`,用真实默认 transport+MockTransport 非流式响应依次覆盖正确 model、缺失/null,以及 JSON 非对象/非法/重复键,断言 §3.3 accessor 的来源和区别;故意让公共响应丢 model 时原始快照仍保留正确串并判 FAIL。并发两逻辑轮次+一次重试验证按 session/parent/成功 call_id 精确选择,不回放前次失败的身份;成功 SSE 快照未取证且公共身份异常时必须 FAIL。覆盖函数按 enabled/disabled/cannot_disable 命题判断,UNKNOWN 不假绿;预期 400 负向契约单独测。
|
||||||
|
4. 用 MockTransport 驱动 request/response hooks:改错 model、Authorization、端点、SSE/JSON 解析→FAIL;成功 SSE 不被提前消费;原始 model 正确但公共字段错误→FAIL。交错并发及取消证明 context reset、凭据不泄露、资源释放;薄委托器不额外调用一次 HTTP。
|
||||||
|
5. 安全报告每轮独立文件,采用 run UUID+轮次与矩阵安全标识;只接受白名单 safe_fields,拒绝原始异常/HttpEvidence 对象直接序列化。第二轮失败仍可读第一轮;写入失败是 FAIL;最终汇总统计 PASS/FAIL/UNCOVERED 和缺轮,不能只数 pytest 退出码。
|
||||||
|
|
||||||
|
对旧策略红证据:用合成记录隔离执行现有“整类 skip/正文子串/UNKNOWN 安静”判据,目标测试要求 FAIL/UNCOVERED,确认语义不符;恢复新纯函数后通过。新文件缺失造成 import error 不计红。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py -q`。安全测试使用假的唯一 sentinel 凭据/私有提示词,逐文件检查不出现 sentinel,不能拿真实密钥做输出搜索。
|
||||||
|
|
||||||
|
- [x] 实现及离线证据完成:窄分类/hooks/独立身份/逐轮安全报告;与 T6 合并提交。
|
||||||
|
|
||||||
|
### T6:迁移四个 live 文件并离线化装配断言
|
||||||
|
|
||||||
|
**文件**:四个 `tests/e2e/test_*.py` 路径见 §2,`tests/e2e/conftest.py`、`tests/unit/test_live_evidence.py`、`tests/unit/test_client.py`、`tests/unit/test_config.py`。
|
||||||
|
|
||||||
|
| 原接缝 | 改动与离线验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| smoke/compat chat | 每轮 session_id+parent_call_id,委托原参数,先报告再 skip/raise;仍验证流/非流、结构化 JSON/模型。断言异常也必须留报告,不只包 await 的异常 |
|
||||||
|
| compat 平铺键 | 移到 test_config.py 的完整合成 env,删除逐源 TIMEOUT_S 才能验证 LLM_TIMEOUT 回落;不靠真实配置“恰好已有覆盖”过测。无 HTTP、无可达 Redis/PG,明确其仅是本库兼容契约 |
|
||||||
|
| compat Protocol | 既有真实外部 Protocol 缺包时记录未覆盖;合成 runtime Protocol 和本库调用签名在 test_client.py 无 slow 执行,不能宣称缺失仓库原测试通过 |
|
||||||
|
| embedding probe | 走同一薄 embed 委托和窄分类,所有路径 finally 关闭;model_not_found 不写成“网关不支持 embeddings”;timeout/trust_env 取已校验源配置,不用 30s 硬编码压紧生产预算 |
|
||||||
|
| L1–L9 | M3 True 拒绝单独离线/本地断言,真实开启用显式 medium,保留未登记/未知 wire 场景;L8 装配拒绝只计本地契约,不算 live 能力 |
|
||||||
|
| T10 | 预声明 NONE 可关闭/不可关闭/档位预期拒绝;每轮结束即留证。去掉“仅保留可用轮降低分母”、completion 长短提升 UNKNOWN 的成功逻辑;模型部分档未覆盖不可汇总全 PASS |
|
||||||
|
|
||||||
|
保持原 `_MODEL_PROVIDER`/显式别名表,不新增 AUTO 成员。`_run_rounds`、`_probe_effort` 返回路径不许遗漏失败轮;默认基线也走同一证据出口。T10 临时能力表仅为探测绕过清单,不写回 DEFAULT;其控制字段预期由矩阵声明,不能调用被测 resolver 产生预期。
|
||||||
|
|
||||||
|
既有 `_tier_settings` 将 stall 强制压到 60s,迁移时去掉该临时缩小值,沿已校验生产配置;不得因持续 429 慢而修改 #22 算法。保留既有轮次/并发设置;先收集矩阵和预计调用数,额外研究不自动展开。M2.5/M2.7 AUTO 复用 T10 现有登记档,M3 medium 流/非流复用对应原开启用例,不以新增多轮研究暗增预算。
|
||||||
|
|
||||||
|
测试工厂替代仅改变取证装配,不能靠调用生产私有 `_client_factory` 的同一实现来证明鉴权构造正确;生产默认 factory 的头/timeout/trust_env 离线守卫由 T3 **新增** `TestDefaultClientFactory`,T6 验证时一并运行,不宣称旧源码已有覆盖。各能力轮次用 §3.3 的 `raw_identity(session_id=..., parent_call_id=..., call_id=resp.call_id)` 给身份函数提供独立快照,缺失来源不得猜测。live 无取证通道的失败按 FAIL,不为凑分类额外开生产接口。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py::TestDefaultClientFactory -q`;`conda run -n PolyGateway pytest tests/e2e/ -m slow --collect-only -q`(只采集,不视作真实通过)。在离线注入旧整类 skip、丢第一轮、UNKNOWN→PASS、identity 丢失→skip 变异,分别红;新实现恢复绿。
|
||||||
|
|
||||||
|
- [x] 实现及日常离线验证/90节点collect-only完成;未执行live,不代表能力覆盖通过。
|
||||||
|
|
||||||
|
### T7:无推理路径真链路与四类变异
|
||||||
|
|
||||||
|
**文件**:`tests/unit/test_embedding.py`、`tests/unit/test_ocr_client.py`、`tests/unit/test_telemetry.py`、`tests/unit/test_monkey_ocr.py`;生产不改。
|
||||||
|
|
||||||
|
复用 `_embed_client`/`_client`/脚本 transport/内存 recorder,参数化 embed、recognize_text、parse_layout 与源 True/HIGH,两次尝试(Transient→成功)必须恰有 2 行、一错一成、所有 reasoning_effort None。再覆盖 RequestRejected 一行和耗尽非零失败行;不要求新增不存在的逻辑终态。SQL 锚点用真实 `SQLiteRecorder(tmp_path / "reasonless.sqlite", auto_migrate=True)`,三入口分别走 client→emitter→SQLite,查询总数/失败数/NULL 数,finally 同步 close 注入 recorder。
|
||||||
|
|
||||||
|
chat 阳性走真实 RetryMW+emitter:True 糖失败 auto、显式请求失败保留意图、nearest 成功为实际映射档。四行遥测继续沿 T4 口径。共享 recorder 并发用测试 session/parent 配对,attempt call_id 唯一且集合不相交。embedding 默认 transport 和 MonkeyOCR text/layout 真实 MockTransport 回包验证 wire 无推理键,不仅断言 emitter 的 False 实参。
|
||||||
|
|
||||||
|
| 隔离变异 | 必须被哪些断言杀死 |
|
||||||
|
| --- | --- |
|
||||||
|
| `embedding.py::_emit` False→True | 误配 True/HIGH 的失败尝试 NULL 断言 |
|
||||||
|
| `ocr.py::_emit` False→True | text 和 layout 各一个独立节点均因错误行非 NULL 红 |
|
||||||
|
| `middleware/retry.py::_emit` True→False | chat 阳性实际档/请求档断言,不是签名 TypeError |
|
||||||
|
| `middleware/telemetry.py::_attempt_effort` 去掉 applies 短路 | 无推理真实失败行断言;确认不是全空数据或假 recorder |
|
||||||
|
|
||||||
|
工作方法:以 T7 当前提交建仓库外临时副本(仅 src/tests/必要工程文件,不复制 `.env`/reference/.pi),用 `PYTHONPATH=<副本>/src` 和副本 cwd 执行 conda pytest;先检查 `polygateway.__file__` 指向副本。逐个变异、跑指定节点记录 exit 1 和目标断言、恢复文件校验散列,再跑 exit 0。绝不在主工作区改 False 假装先红。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py tests/unit/test_monkey_ocr.py tests/unit/test_retry.py -q`,再执行上表隔离变异;原实现绿、四类有效红、还原绿。
|
||||||
|
|
||||||
|
- [x] 提交点:`test: guard reasoning-free telemetry through real client paths`。
|
||||||
|
|
||||||
|
### T8:文档、日志登记与独立验证
|
||||||
|
|
||||||
|
**文件**:§2 列出的用户文档/架构/旧设计/schema/metric/知识索引,以及验收 finding;不新增运行时字段。
|
||||||
|
|
||||||
|
同步设计 M1–M9 到 README 可执行迁移节与 `.env.example` 注释,保留型号证据来源;CHANGELOG 未发布段点名 AUTO 新拒绝、未知尽力、raw 同值拒绝、显式缓存身份迁移、UNKNOWN/SKIP 限制。schema 既有 reasoning_effort “实际发出”总括修成四种行来源,不修改 DDL;metric 复用既有 call-telemetry-coverage,记录三个无推理入口错误行 NULL/chat 阳性为 100% 契约,真实覆盖基线留待首次实际运行,不能填伪百分比。
|
||||||
|
|
||||||
|
由父会话前台派全新 verifier:只给批准设计、计划、分支 diff、验证命令,不给实现自评。至少覆盖正确性/回归和测试归因/范围两个角度;Critical/Important 清零。审查先核对实际路径与仓库语言,不接受不存在文件的结果。补丁回到单 writer,重跑受影响红绿及静态门。
|
||||||
|
|
||||||
|
| 检查 | 命令/证据要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| 静态与边界 | `make check`;`git diff --check`;`conda run -n PolyGateway python -m compileall -q src/polygateway tests/live_evidence.py tests/e2e/conftest.py` |
|
||||||
|
| 日常全量 | `make test`,保存真实退出码/coverage ≥80%,不能只运行改动文件;连接依赖 skip 单列 |
|
||||||
|
| LSP | 若会话已有 LSP diagnostics 工具,对四个生产变更文件和新增测试支持文件取诊断;本轮检查 conda 内 pyright/basedpyright 均未安装且工程无其配置,不安装新依赖或虚报 LSP 通过。可用时命令 `conda run -n PolyGateway pyright src/polygateway/thinking.py src/polygateway/providers.py src/polygateway/client.py src/polygateway/transports/openai_compat.py tests/live_evidence.py tests/e2e/conftest.py`,不可用明确记未执行,ruff/import-linter/compileall 是实际既有静态门,不冒称等价 LSP |
|
||||||
|
| 真实采集清单 | `conda run -n PolyGateway pytest tests/ -m slow --collect-only -q`,先列必需节点、型号/模式/轮次/并发/所用配置身份(不含秘密) |
|
||||||
|
| 真实执行 | `conda run --no-capture-output -n PolyGateway pytest tests/ -m slow -ra`;保持生产超时,检查每项报告而非仅 exit 0 |
|
||||||
|
| 反回归 | 四类 #26 变异+T4 缓存迁移+T5/T6 假绿反例全部有独立失败断言与还原通过,finding 引用原始报告路径 |
|
||||||
|
|
||||||
|
长跑用 tmux,`PYTHONUNBUFFERED=1`,命令 stdout/stderr 重定向到 `tests/outputs/134/`,原命令后立刻独立保存 `$?`;不得接 `tail` 管道改写退出码。父会话等待准确 tmux 完成信号/PID,不能 pgrep 完整命令自匹配。不把初次失败覆盖成重跑后的单一绿日志。
|
||||||
|
|
||||||
|
- [ ] 提交点:`docs: document reasoning ownership and explicit cache migration`;必要修复各自按 commit skill 提交,不把 verifier 自动反馈当授权扩范围。
|
||||||
|
|
||||||
|
### T9:发布准备、合并后复验与 1.3.4 发布
|
||||||
|
|
||||||
|
仅在 T8 无未处理阻塞后执行。用户已授权所有本节动作,无需为 merge/push/上传再请示;发现 1.3.4 已存在不可覆盖,停下协调版本,不能私自跳到 1.4。
|
||||||
|
|
||||||
|
| 顺序 | 精确动作与完成证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 文档先行 | 更新 README 安装约束与能力说明;CHANGELOG 定版为 1.3.4(实际日期),pyproject 和包 `__version__` 同步;本计划复选框只能按已得证据勾选 |
|
||||||
|
| 发版提交 | 执行 commit skill,标题 `chore: prepare release 1.3.4`,先核对测试报告和 staged 无秘密;运行 `conda run -n PolyGateway pytest tests/unit/test_package.py -q`,不改变公共字段计数 |
|
||||||
|
| 合并 | `git fetch origin`,确认远端未出现未审变更;`git switch main`,`git merge --no-ff feature/1.3.4-thinking-contracts`。保留已有两个本地提交,禁止 reset/force push |
|
||||||
|
| 合并后门 | main 上重新 `make lint`、`make test`、`conda run --no-capture-output -n PolyGateway pytest tests/ -m slow -ra`;若 lint --fix 改代码,重新审 diff、提交并重跑,不把脏代码与 tag 分离 |
|
||||||
|
| 推送与 tag | `git push origin main`;`git tag -a v1.3.4 -m "Release 1.3.4"`;`git push origin v1.3.4`,核对远端 tag 指向最终已验证提交 |
|
||||||
|
| 构建 | 核实 cwd 后按 CLAUDE 清除旧 dist 产物;`conda run -n PolyGateway python -m build`;`conda run -n PolyGateway python -m twine check dist/*`;缺构建工具先报告环境缺项,不更改核心依赖 |
|
||||||
|
| 上传 | 从既有 tea 配置安全取 token,仅放 TWINE_PASSWORD 环境变量;`conda run -n PolyGateway python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*`,TWINE_USERNAME 沿已有账号;不在 argv/日志输出 token,不把命令成功当最终发布完成 |
|
||||||
|
| 下载检查 | `conda run -n PolyGateway pip download --no-deps --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ polygateway==1.3.4 -d <临时目录>`;解包核对新守卫与 MiniMax wire、版本、README 元数据;从仓库外使用该环境 Python 将 wheel 安装到独立 target 并验证 import 来源及拒绝行为 |
|
||||||
|
| 外部可见 | 建 Gitea v1.3.4 Release(正文来自定版 CHANGELOG);调用 `POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/PolyGateway`;查看 Release/registry 包页面正文、仓库链接、下载产物,逐项记录 URL 与实际结果 |
|
||||||
|
|
||||||
|
测试不在 wheel 内,下载后以无网络小调用核对已安装 `resolve_thinking` 和冲突守卫;不要从工作树 src import 后宣称发布包通过。只在外部结果确认后评论/关闭 #21/#25/#26,正文引用各自验证与迁移边界,不能称所有渠道故障已自动识别。若上传成功但页面/下载校验失败,记录部分发布状态,不重发同版本不同字节。
|
||||||
|
|
||||||
|
- [ ] 提交/发布点:main 的发布提交与 `v1.3.4` 对齐;Release 与 registry 外部验证全部成立。
|
||||||
|
|
||||||
|
## 5. 阻塞矩阵:哪些可以执行,哪些不能冒充通过
|
||||||
|
|
||||||
|
| 缺口 | 本库可完成 | 不可自行宣称/处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 下游工作区缺失 | 本库完整合成 env、runtime Protocol、M1–M9 与显式缓存迁移回归 | GovDoc/CHS 实际配置未取证、Video-Tree 已退出迁移但历史兼容面仍可测;三者均不能虚构实测。提前向父会话登记缺口,发布前须拿到相关负责人脱敏配置与验证证据,或人类明确豁免缺失项;不阻止独立离线实现继续 |
|
||||||
|
| M2 空 wire AUTO | 默认 wire 单测、实际既有 T10 型号档位复验 | 真实缺身份/无信号/不可达不能当已验证;需有效重测或人类具名豁免,不补回 medium 或无证据改表 |
|
||||||
|
| M3 非流式 UNKNOWN | 可验证 payload、响应形态、UNKNOWN 不假绿 | 不把长度差当开启/关闭证明;必需能力单元无法满足时保留未覆盖并走人类决策,不新增临时“通过”阈值 |
|
||||||
|
| 429/5xx/网络失败 | 完整证据保存,库回归用离线契约定位 | 本批归因默认 FAIL 是设计批准范围,不能为了 #25 关闭率改宽 skip;外部证据由人类决定发布豁免 |
|
||||||
|
| 研究新增预算 | 原有 slow 套件按已有授权跑,已有配置保持 | 额外模型/轮次/对照实验需预算批准;不得将批准设计偷换成无限研究调用授权 |
|
||||||
|
| 设计外漏洞 | 独立记录实际文件与反例,父会话核定是否阻塞 | 不顺手实施 #19/#22/#23/#24、新 schema 或新 deadline;无强制单源分支 |
|
||||||
|
|
||||||
|
## 6. 自审与验收映射
|
||||||
|
|
||||||
|
| 设计节/需求 | 任务 |
|
||||||
|
| --- | --- |
|
||||||
|
| §4.1/4.2 AUTO 与 MiniMax、未知尽力 | T1,T3 双入口,T6/T8 真实证据 |
|
||||||
|
| §4.3/4.4 raw 同值/嵌套/自定义/时机 | T2、T3;无公共端口新增、无深合并 |
|
||||||
|
| §5 D3 与 M1–M9 | T4、T8;未迁移风险显式保留,绝不补指纹 |
|
||||||
|
| §6 归因/身份/UNKNOWN/负向命题 | T5、T6;完整请求证据、默认 FAIL、逐轮持久化 |
|
||||||
|
| §7 无推理路径与变异 | T7;三入口真 client+SQLite,四类隔离变异 |
|
||||||
|
| §8 四种行口径与日志 | T1/T4/T7/T8;既有 schema/emitter,不新增数据面 |
|
||||||
|
| §9/10 文档、下游与发布门 | T8/T9及阻塞矩阵;外部结果与测试缺口不冒充通过 |
|
||||||
|
|
||||||
|
自审已核对:生产守卫所有消费者在 §3 定义;新增测试文件有确定路径;conftest 当前不存在故明确新建;默认工厂不支持 transport 注入故使用已批准全量注入而非偷扩 API;缓存不改指纹;无从公共 model_reported 倒推上游身份;所有命令均在 conda 环境;未执行的测试不写为已通过。
|
||||||
|
计划审查由父会话组织,完成后直接实施,不新增人类计划审批门。执行中本文件任务勾选与 finding 保持实际状态一致;本次计划编写未运行 pytest、变异或真实模型调用。
|
||||||
|
|
||||||
|
## 本轮实施证据
|
||||||
|
|
||||||
|
T0–T4/T7 的命令、实际失败与修复、11 个隔离变异及 1241 项单测通过,见 `findings/2026-09-09-134-thinking-contracts-validation.md`。T5/T6 已续作:1357单元通过、8个隔离假绿变异exit1/还原0、e2e 90节点仅采集;T8文档同步完成。未执行live、集成、独立verifier和发布。具体节点及残余见同一finding续作节。
|
||||||
|
|
||||||
|
### T5/T6续作决策记录
|
||||||
|
|
||||||
|
父会话确认无已批准型号→400机器type白名单:不编造,缺机器证据400默认FAIL;精确预期拒绝契约离线守卫,具体live负向缺基线记录未验证。不可关闭命题完整合格轮次有OBSERVED支持本条件下未关闭,全ABSENT证伪,无OBSERVED但UNKNOWN未覆盖。T8复选框保持未勾选,因为独立verifier与全量/live证据门未执行;本轮仅其文档同步部分完成,禁止发布。
|
||||||
|
|
||||||
|
T5/T6实现提交:`73008ad`。最终日常单元1357、受影响含factory331、make check、compileall、e2e collect-only90通过;完整T8/T9仍未执行。日志路径及8项红→还原绿详见同一finding。
|
||||||
|
|
||||||
|
### 独立审查修复续作(d332287 后)
|
||||||
|
|
||||||
|
已按 receiving-code-review 核验四项并仅修改测试及本计划/finding:结构化重问采用先验前缀/反馈角色与预算+委托摘要的 wire 保真;未登记候选先资格再观测未覆盖;同一用例 run/model 关联所有子运行并保留计划/完成分母;落盘机器字段只准 model_not_found/omitted。没有生产/版本修改、slow执行或额外调用预算。
|
||||||
|
|
||||||
|
新增18个离线节点,四项及首轮精确消息守卫均有目标断言先红→绿。最终129项取证单测、1375全单元、make check及e2e collect-only90通过;命令日志/退出码详见既有finding“独立审查四项修复”。修复后独立复审、集成与live尚未完成,**T8/T9仍不勾选,不放行发布**。原结构化重问“保守FAIL”说明已标为被本次修复替代,不能再当成可接受限制。
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-09-09-135-call-observability
|
||||||
|
title: "1.3.5 逻辑调用统计与结构化失败诊断实施计划"
|
||||||
|
date: 2026-09-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.5 逻辑调用统计与结构化失败诊断实施计划
|
||||||
|
|
||||||
|
> 设计:`research-wiki/designs/2026-09-09-135-call-observability-design.md`,**人类于 2026-09-09 正式批准**(§10 六项批准项全数获批)。
|
||||||
|
> 计划审核门:Claude 自审 + **独立模型替代审查**(用户已同意以独立模型替代 Codex 那一道,结论仍须逐条核验后就地修订);plan 无人类门,审毕直接执行。
|
||||||
|
> 目标:把治理单位从"一次尝试"补齐到"一次逻辑调用"——四种响应带 `CallStats`,遥测补 10 列诊断/归因字段与 `terminal_failure` 行(issue #19、#23)。
|
||||||
|
> 方案:设计方案 B——每调用一个显式传递的私有可变上下文 + 领域异常下沉到单一遥测出口;复用既有 Emitter / schema / 三条治理循环,不新增表、不新增依赖、不引入追踪平台。
|
||||||
|
> 技术:Python 3.12+、asyncio、frozen dataclass、`inspect.signature`、pytest + FakeClock + MockTransport、临时 SQLite、真实 PG 沙箱、ruff、import-linter。
|
||||||
|
> 基线 HEAD:`a81cc91`(分支 `feature/1.3.5-call-observability`,工作区仅 `CLAUDE.md` 既有 markdown 格式差异与未跟踪 `.pi/`,两者本计划一律不动、不暂存)。
|
||||||
|
|
||||||
|
本计划不涉及 `reference/` 参考实现迁移(治理主循环、Lua 限流、熔断状态机、退避公式一字不改),**保真校验不适用**。不得变更:重试预算与退避、429 免预算与 stall 算法、取消结算、限流/熔断语义、推理能力表与档位解析、缓存 key 公式、`#22`/`#24` 的调度机制。
|
||||||
|
|
||||||
|
## 1. 边界、授权与执行纪律
|
||||||
|
|
||||||
|
| 项目 | 固定边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。用户已授权本轮无需逐步请示,1.3.X 合并/发布授权沿用;跨到 1.4、新公共决策或验证豁免须停下确认 |
|
||||||
|
| 公共承诺 | 只做设计已批准的四项公共面变更:`CallStats` 导出、四响应新增字段、`TelemetryRecorder` 扩 10 参、`terminal_failure` 行;**不新增其它公共 API、不改既有字段名与位置** |
|
||||||
|
| 依赖铁律 | `ports.py`/`types.py`/`errors.py` 保持最内层,`types.py` 不 import 任何实现层;`middleware/` 只依赖端口与内核;`transports/`、`telemetry/`、`backends/` 互不依赖(import-linter 执法) |
|
||||||
|
| 取消 | `CancelledError` 永不吞没;终态写**取消优先、不 shield、不开后台任务**;快照冻结是同步动作,不 await |
|
||||||
|
| 降级方向 | recorder 写失败仍只 warning;限流/熔断后端仍 fail-closed;装配期签名不符 → **当场报错**(不是 warning) |
|
||||||
|
| 证据与秘密 | 不打印 `.env`、token、Authorization;不提交 `.pi/`、`tests/outputs/`、reference;命令输出只记安全路径、状态与退出码 |
|
||||||
|
| 证据复用 | 已发布 1.3.4 的有效证据(能力矩阵、live 轮次、发布外部验证)**直接引用不重建**;本版**不补跑未变更的模型能力矩阵**,无需新增付费调用 |
|
||||||
|
|
||||||
|
Skill 纪律:T0 已执行 `writing-plans`;T1–T3 行为变更执行 `test-driven-development`(先失败后通过的证据须落在本会话工具输出里);T1 触及运行时数据落库,执行 `structured-logging`;每次提交执行 `commit`(英文祈使标题、无 AI 签名、显式路径暂存);T4 前执行 `requesting-code-review` 与 `verification-before-completion`,收到意见执行 `receiving-code-review`;异常先 `systematic-debugging` 定根因。
|
||||||
|
|
||||||
|
## 2. 文件职责与不变接缝
|
||||||
|
|
||||||
|
| 动作 | 精确路径 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 修改 | `src/polygateway/types.py` | 新增 `CallStats`、私有 `_CallContext`、`CallOperation`/`EventKind` 词表;`ChatRequest` 追加内部上下文字段;四种响应追加 `call_stats` |
|
||||||
|
| 修改 | `src/polygateway/__init__.py` | 导出 `CallStats`(`__all__` 保序插入);版本号在 T4 统一 |
|
||||||
|
| 修改 | `src/polygateway/ports.py` | `TelemetryRecorder.record_llm_call` 追加 10 个 keyword-only 无默认值参数(唯一签名事实源) |
|
||||||
|
| 修改 | `src/polygateway/telemetry/schema.py` | `SQLITE_DDL` / `PG_DDL` / `SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` / `COLUMNS` **五处**同序同增 10 列,追加在物理列末尾(`PG_BACKFILL`、`_ALTER_BY_BACKEND`、`telemetry_schema_sql` 自动派生,不手改) |
|
||||||
|
| 修改 | `src/polygateway/middleware/telemetry.py` | Emitter 构造期注入 `scope` + 装配闸;三个 emit 入口收 `operation` 与领域异常对象;诊断字段单一提取 helper;`event_kind`;终态出口 helper;`TelemetryMW` 只保留 cache_hit |
|
||||||
|
| 修改 | `src/polygateway/middleware/structured.py` | 有界错误说明常量与拼装函数改为库内可复用(重问反馈与遥测说明同一口径,数值只一份) |
|
||||||
|
| 修改 | `src/polygateway/middleware/retry.py` | transport 调用前登记一次尝试;`_emit` 传异常对象与 `operation="chat"` |
|
||||||
|
| 修改 | `src/polygateway/middleware/cache.py` | `_serialize` 剔除 `call_stats`;`_rehydrate` 显式覆盖为 `None`(防历史 dict 冒充) |
|
||||||
|
| 修改 | `src/polygateway/client.py` | 自存 `now`/`emitter`;`chat` 创建上下文、附加统计、领域失败与取消经单一终态出口 |
|
||||||
|
| 修改 | `src/polygateway/embedding.py` | `embed` 拥有上下文(空输入 0 尝试 0 遥测行);逐批传递;终态请求摘要按 §3.5 |
|
||||||
|
| 修改 | `src/polygateway/ocr.py` | 上下文在 `image` 校验通过后创建;两公开方法各自给 `operation`;错误文本保留类名前缀(经出口的显式策略参数) |
|
||||||
|
| 修改 | `src/polygateway/transports/openai_compat.py` | `_status_to_error` 增 keyword-only `operation`;三个调用点显式给值:`:512`(`embed` 非 200)传 `"embedding"`(修正历史误标)、`:527`(流式 chat)与 `:625`(非流式 chat)传 `"chat"`;`ocr_text`/`parse`/`download_result` 词表不动。**归属以本表为准**:设计 §5 把 `:527` 也叙述成 `embed()` 的分支,实测 `:527` 属 `_complete_stream`(流式 chat)、`embed` 只有 `:512` 一处;勿按设计行号把两条 chat 失败回退误标成 embedding |
|
||||||
|
| 修改 | `tests/unit/test_types.py`、`tests/unit/test_ports.py` | `CallStats`/上下文纯行为、端口签名实测(含无默认值与 keyword-only) |
|
||||||
|
| 修改 | `tests/unit/test_telemetry.py` | 列序/列数、三类行语义、诊断保真、装配闸、SQLite 新旧表、`_record_minimal` 字段字典 |
|
||||||
|
| 修改 | `tests/unit/test_retry.py`、`test_client.py`、`test_cache.py`、`test_structured.py` | 计数/计时/缓存命中/结构化耗尽终态与历史 dict 防护 |
|
||||||
|
| 修改 | `tests/unit/test_embedding.py`、`test_ocr_client.py`、`test_openai_compat.py`、`test_monkey_ocr.py` | 多批、空输入、双 HTTP 一次尝试、`exc.operation` 修正、200 失败行 |
|
||||||
|
| 修改 | `tests/unit/test_pricing.py`、`tests/unit/test_usage_source_domain.py` | **仅 Emitter 调用点机械迁移**(`TelemetryEmitter(` 补 `scope=`、`emit_*` 补 `operation=`、`emit_terminal_failure` 去 `latency_ms` 改传 `stats`);两文件的 `_MemoryRecorder` 是 `**fields` 形态,recorder 签名不动 |
|
||||||
|
| 修改 | `tests/integration/test_postgres_telemetry.py` | PG auto 追加 / manual 缺列裁剪 / 旧行 NULL / 新旧进程混写(真实 `pg_sandbox`) |
|
||||||
|
| 修改 | `README.md`、`CHANGELOG.md`、`.env.example`、`research-wiki/ARCHITECTURE.md`、`research-wiki/schemas/llm-calls.md`、`research-wiki/metrics/call-telemetry-coverage.md` | 字段数实测改写、cap 覆盖面澄清、SQL 迁移五项、终态行语义 |
|
||||||
|
| 新建(验收时) | `research-wiki/findings/2026-09-09-135-call-observability-validation.md` | 红绿、变异、失败与豁免索引,≤300 行;原始输出留 `tests/outputs/135/` |
|
||||||
|
|
||||||
|
**不改**:`errors.py`(异常类与分类逐字不动,不加可变字段)、`middleware/admission.py`、`middleware/ratelimit.py`、`middleware/breaker.py`、`sources.py`、`thinking.py`、`providers.py`、`telemetry/sqlite.py`、`telemetry/postgres.py`(两个 recorder 靠 `**fields` + `schema.COLUMNS` 自动吃到新列,逻辑零改动)、`transports/monkey_ocr.py`(双 HTTP 仍在同一 transport 调用内,不拆)。若实现时发现必须突破本清单,先说明设计依据与最小原因交父会话核定,不顺手改。
|
||||||
|
|
||||||
|
## 3. 跨任务接口(可执行定义,禁止占位)
|
||||||
|
|
||||||
|
### 3.1 统计内核(`types.py`)
|
||||||
|
|
||||||
|
`types.py` 需新增 `import uuid`、`from collections.abc import Callable` 与 `from typing import Literal`(`Mapping`/`Any` 已在;全文现无 `Literal`)。
|
||||||
|
|
||||||
|
**运行时求值约束(`types.py` 无 `from __future__ import annotations`,实测 :1-17)**:`Callable` 与 `Literal` 必须是**运行时 import**,不得放进 `TYPE_CHECKING`(`_CallContext.__init__` 的函数注解与 `CallOperation` 别名都在 def/赋值时求值);且 `_CallContext` 必须**定义在 `ChatRequest`(:339)之前**,否则 `call_context: _CallContext | None` 的类注解在类创建时即 `NameError`。
|
||||||
|
|
||||||
|
上下文是**每调用一个实例**的单任务对象:chat 重试、结构化重问、embedding 分批都在同一任务内串行推进,故计数无需锁;严禁提升为 client 实例属性。
|
||||||
|
|
||||||
|
```python
|
||||||
|
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
|
||||||
|
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
|
||||||
|
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
|
||||||
|
EVENT_KINDS: tuple[EventKind, ...] = ("attempt", "cache_hit", "terminal_failure")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CallStats:
|
||||||
|
"""一次公开调用的统计快照;第三方合成响应的 `None` 表示未知,不得伪造 0。"""
|
||||||
|
|
||||||
|
logical_call_id: str
|
||||||
|
attempts: int
|
||||||
|
total_latency_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
class _CallContext:
|
||||||
|
"""私有可变逻辑调用上下文:只持计数、单调时钟与终态去重位,不做 I/O。"""
|
||||||
|
|
||||||
|
__slots__ = ("logical_call_id", "_now", "_started", "_attempts", "_terminal_claimed")
|
||||||
|
|
||||||
|
def __init__(self, *, now: Callable[[], float]) -> None: ...
|
||||||
|
|
||||||
|
def register_attempt(self) -> None:
|
||||||
|
"""transport 调用前登记一次尝试(含免预算 429 与端口本地拒绝)。"""
|
||||||
|
|
||||||
|
def snapshot(self) -> CallStats:
|
||||||
|
"""同步冻结当前快照;绝不 await,可多次调用。"""
|
||||||
|
|
||||||
|
def claim_terminal(self) -> bool:
|
||||||
|
"""首次 True、其后 False:保证每逻辑调用至多写一条终态行。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`ChatRequest` 追加(末尾,`dataclasses.replace` 保留同一引用;`StructuredMW` 的 replace 已核对不重建它):
|
||||||
|
|
||||||
|
```python
|
||||||
|
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
|
||||||
|
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 logical_call_id 落 NULL。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`LLMResponse`、`EmbeddingResponse`、`OcrTextResult`、`OcrLayoutResult` 各追加**末尾**字段:
|
||||||
|
|
||||||
|
```python
|
||||||
|
call_stats: CallStats | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 遥测出口(`middleware/telemetry.py`)
|
||||||
|
|
||||||
|
Emitter 是全库唯一 `record_llm_call` 调用点,本版新增的一切诊断/归因取值也只在这里定型。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TelemetryEmitter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
recorder: TelemetryRecorder,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
pricing: PricingTable | None = None,
|
||||||
|
text_cap: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""`scope` 构造期注入(三个 client 各一行),使三类行都带池名;不拿 source_name 顶替。
|
||||||
|
|
||||||
|
同处执行装配闸 `_assert_recorder_shape(recorder)`。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def emit_attempt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
request: ChatRequest,
|
||||||
|
source: SourceConfig,
|
||||||
|
call_id: str,
|
||||||
|
latency_ms: int,
|
||||||
|
response: LLMResponse | None,
|
||||||
|
error: PolyGatewayError | str | None,
|
||||||
|
reasoning_applies: bool,
|
||||||
|
operation: CallOperation,
|
||||||
|
class_prefixed_error: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""`event_kind='attempt'`;失败异常对象在此提取 status/cause/body 四列。"""
|
||||||
|
|
||||||
|
async def emit_cache_hit(
|
||||||
|
self, *, request: ChatRequest, response: LLMResponse, operation: CallOperation
|
||||||
|
) -> None:
|
||||||
|
"""`event_kind='cache_hit'`;attempts/total_latency_ms 列恒 NULL。"""
|
||||||
|
|
||||||
|
async def emit_terminal_failure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
request: ChatRequest,
|
||||||
|
call_id: str,
|
||||||
|
error: PolyGatewayError | str,
|
||||||
|
operation: CallOperation,
|
||||||
|
stats: CallStats,
|
||||||
|
class_prefixed_error: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""`event_kind='terminal_failure'`;`latency_ms` 与 `total_latency_ms` 同取 `stats`。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`emit_terminal_failure` **不再收 `latency_ms`**(同一冻结快照供两列,避免双时钟微差)。诊断提取收敛为一个纯 helper,只读领域异常的既有属性,**不遍历任意对象、不猜正文**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ErrorFields:
|
||||||
|
"""一行遥测的错误列;未知一律 None。"""
|
||||||
|
|
||||||
|
error: str | None
|
||||||
|
error_type: str | None
|
||||||
|
cause_type: str | None
|
||||||
|
http_status_code: int | None
|
||||||
|
error_body: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _error_fields(
|
||||||
|
error: PolyGatewayError | str | None,
|
||||||
|
*,
|
||||||
|
event_kind: EventKind,
|
||||||
|
class_prefixed: bool,
|
||||||
|
) -> _ErrorFields:
|
||||||
|
"""三种入参形态的唯一定型点(设计 §5/§6)。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
判定规则(测试逐条钉死):
|
||||||
|
|
||||||
|
| 入参 | error | error_type | cause_type / http_status_code / error_body |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `None` | None | None | 全 None |
|
||||||
|
| `str`(如既有 `"cancelled"`) | 原样 | None | 全 None(**不解析字符串猜诊断**) |
|
||||||
|
| 领域异常,`event_kind='attempt'` | 见下 | `type(exc).__name__` | `type(exc.__cause__).__name__ or None` / `exc.status_code` / `summarize_body` 已产出的 `exc.body_text or None` |
|
||||||
|
| 领域异常,`event_kind='terminal_failure'` | 见下 | `type(exc).__name__` | **全 None**(C1:不搬最后一次 attempt 的状态与正文冒充整池归因) |
|
||||||
|
|
||||||
|
error 文本:`str(exc)` 为空退回 `type(exc).__name__`;`class_prefixed=True` 时前置 `"{类名}: "`(OCR 既有 metric 归组口径);取消路径一律传字符串(attempt 行沿用既有 `"cancelled"`,终态行用同一明确取消文案),故 `error_type` 与其余三列均 NULL;`ResultInvalidError` 且为终态时并入有界结构化说明:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _structured_detail(exc: ResultInvalidError) -> str:
|
||||||
|
"""结构化耗尽的有界说明:`repair=` 至多 200 字符 + `validation=` 至多 3 条×200 字符;
|
||||||
|
**不含 raw_text**(模型正文预算已由 attempt 行的 response 列承担),两段以 ` | ` 拼接。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
复用 `structured.py` 的既有规则所有者,不复制数值:把 `_MAX_FEEDBACK_ERRORS`/`_MAX_ERROR_CHARS` 改名为 `MAX_FEEDBACK_ERRORS`/`MAX_ERROR_CHARS`,`_format_errors` 改名 `format_bounded_errors(errors: Sequence[str]) -> str`(行为逐字不变),`StructuredMW` 与 `_structured_detail` 两个消费者共同引用。
|
||||||
|
|
||||||
|
装配闸(设计 §7,C3):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
|
||||||
|
"""装配期一次 `signature.bind` 形状校验:不执行写入,只证明该形状能被接受。
|
||||||
|
|
||||||
|
参数名从 `TelemetryRecorder.record_llm_call` 的协议签名派生(不手抄第四份清单),
|
||||||
|
绑定用哨兵 `None`,不读真实请求数据。`**kwargs`(VAR_KEYWORD)自动通过;
|
||||||
|
`TypeError` → `ValueError` 装配期报错;不可 inspect(C 实现等)同样按配置错误报错。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
终态唯一出口(公开边界 helper,三个 client 共用):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def emit_terminal_once(
|
||||||
|
emitter: TelemetryEmitter | None,
|
||||||
|
*,
|
||||||
|
request: ChatRequest,
|
||||||
|
context: _CallContext,
|
||||||
|
error: PolyGatewayError | str,
|
||||||
|
operation: CallOperation,
|
||||||
|
class_prefixed_error: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""去重(claim_terminal)+ 同步冻结快照 + best effort 写入。
|
||||||
|
|
||||||
|
`emitter is None` 或已写过 → 直接返回;写入侧异常按既有降级只落 warning;
|
||||||
|
**`CancelledError` 原样传播**(取消优先,不 shield、不开后台任务)。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`TelemetryMW` 相应收缩:删除其 `except (GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError)` 与 `except CancelledError` 两个终态分支(改由 `GatewayClient.chat` 的边界统一写,避免两处同时写),保留 `cache_hit` 记录并传 `operation="chat"`。
|
||||||
|
|
||||||
|
### 3.3 `_record` 的 10 个新列取值(唯一定型点)
|
||||||
|
|
||||||
|
| 列 | attempt | cache_hit | terminal_failure |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `scope` | 构造期注入值 | 同 | 同 |
|
||||||
|
| `operation` | 调用点给定的四值之一,**绝不读 `exc.operation`** | 同 | 同 |
|
||||||
|
| `logical_call_id` | `request.call_context.logical_call_id`,上下文缺席 → NULL(不造 ID) | 同 | 同 |
|
||||||
|
| `event_kind` | `'attempt'` | `'cache_hit'` | `'terminal_failure'` |
|
||||||
|
| `http_status_code` / `cause_type` / `error_body` | 见 §3.2 表(成功行全 NULL,成功不统一填 200) | 全 NULL | 全 NULL |
|
||||||
|
| `error_type` | 该行自身错误类名 | NULL | 自身类名(`AllSourcesExhausted` / `CircuitOpenError` / `ResultInvalidError` / …) |
|
||||||
|
| `attempts` / `total_latency_ms` | NULL | NULL | `stats` 两字段 |
|
||||||
|
|
||||||
|
其余 26 列口径逐字不变;终态行仍 `cost=None`、`usage_source='unavailable'`、token 0,不复制 attempt 的用量与费用。
|
||||||
|
|
||||||
|
### 3.4 schema 与端口(`telemetry/schema.py`、`ports.py`)
|
||||||
|
|
||||||
|
10 列按**同一顺序**追加进五处(`SQLITE_DDL`、`PG_DDL`、`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS`、`COLUMNS`),全部可空、无默认值,物理位置在现有末列 `reasoning_effort` 之后:
|
||||||
|
|
||||||
|
| 顺序 | 列名 | SQLite | Postgres |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `scope` | TEXT | TEXT |
|
||||||
|
| 2 | `operation` | TEXT | TEXT |
|
||||||
|
| 3 | `logical_call_id` | TEXT | TEXT |
|
||||||
|
| 4 | `event_kind` | TEXT | TEXT |
|
||||||
|
| 5 | `http_status_code` | INTEGER | INTEGER |
|
||||||
|
| 6 | `error_type` | TEXT | TEXT |
|
||||||
|
| 7 | `cause_type` | TEXT | TEXT |
|
||||||
|
| 8 | `error_body` | TEXT | TEXT |
|
||||||
|
| 9 | `attempts` | INTEGER | INTEGER |
|
||||||
|
| 10 | `total_latency_ms` | INTEGER | INTEGER |
|
||||||
|
|
||||||
|
`COLUMNS` 由 26 → 36,物理列(含 `created_at`)27 → 37。**不建索引、不改旧列、不回填旧行、不 ALTER 默认生产 PG**(manual 档仍裁剪写入)。`ports.py` 按同序追加 10 个 keyword-only 无默认值参数,docstring 更新为"36 字段冻结",并说明 `error_body` 沿用 `summarize_body` 上限、不进 `PGW_TELEMETRY_TEXT_CAP` 覆盖面。
|
||||||
|
|
||||||
|
### 3.5 三条链路的上下文所有权
|
||||||
|
|
||||||
|
| 链路 | 创建点 | 传递 | 附加统计 | 终态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| chat | `GatewayClient.chat`:overlay/维度/档位三项校验**之后**(校验异常保持原行为,在统计边界外) | 放进 `ChatRequest.call_context`,洋葱各层经 `replace` 保留同一引用;`RetryMW._attempt` 在 `transport.complete` 前 `register_attempt()`(**上下文为 `None` 时跳过**——库内现场构造的 `ChatRequest` 不得因此报错) | 返回前 `dataclasses.replace(response, call_stats=ctx.snapshot())`(含缓存命中路径与内联遥测耗时) | `except PolyGatewayError` / `except CancelledError` 各调 `emit_terminal_once`,随后原样 `raise`;非领域异常不捕、无终态 |
|
||||||
|
| embed | `EmbeddingClient.embed`:`texts` 类型校验与**调用方维度校验**(`validate_caller_dimensions`)之后;`expected_dim` 校验在 `_attempt` 内,本就在统计边界内 | 显式参数传到 `_embed_batch` → `_attempt`;`_emit` 现场构造的 `ChatRequest` 带 `call_context=ctx`;`transport.embed` 前登记 | 合并结果 `replace(..., call_stats=ctx.snapshot())`;**空输入返回真实 `attempts=0` 统计且不写任何遥测行** | 同上,`operation="embed"`;终态 messages = `<embed texts=N batches=M>` 占位 + 第一批(至多 `batch_size` 条、每条 200 字符,与逐批行同款构造) |
|
||||||
|
| OCR | `OcrClient._call`:`image` 的 `bytes`/非空校验**通过之后**(M1 例外) | `_call` 拥有上下文并返回 `tuple[_AttemptOutcome, CallStats]`,两个公开方法据此附加统计;`transport` 调用前登记(layout 的 POST+ZIP GET 计 **1** 次) | `OcrTextResult` / `OcrLayoutResult` 的 `call_stats` | `_call` 内 `except PolyGatewayError` / `except CancelledError` 调 `emit_terminal_once`,`operation` 由公开方法传入(`recognize_text` / `parse_layout`),`class_prefixed_error=True`;`no_sources` 的 `raise` 在循环之前,`try` 必须**包住该 raise**,否则无源终态行写不出 |
|
||||||
|
|
||||||
|
`GatewayClient` 需自存 `self._now` 与 `self._emitter`(现未保存);统计生效与否**不由 telemetry 是否启用决定**(`emitter is None` 时统计照常,仅无行可写)。
|
||||||
|
|
||||||
|
## 4. 任务与提交点
|
||||||
|
|
||||||
|
### T0:设计批准状态、计划与基线(本任务)
|
||||||
|
|
||||||
|
- [x] 设计文档状态行改为"人类已批准(2026-09-09)",新建本计划(≤600 行)。
|
||||||
|
- [x] 记录基线:`git status --short --branch`、`git log --oneline -3`、HEAD `a81cc91`;确认源码零差异,`CLAUDE.md` 既有 markdown 差异与 `.pi/` 一律不动、不暂存。
|
||||||
|
- [ ] 基线验证(由执行者在 T1 之前跑一次,作为"先失败"的对照底):`make check`;`conda run -n PolyGateway pytest tests/unit -q` → 预期全绿。**基线计数以本会话实跑输出为准**(近期会话记录为 1385 项量级),不拿计划里的数字当期待值;实际失败照录,不改期待绕过。
|
||||||
|
- [ ] 计划自审 + 独立模型审查(替代 Codex,用户已同意);意见逐条核验后就地修订。
|
||||||
|
- [ ] wiki 登记:`.claude/tools/research_wiki.py add_entity/add_edge/rebuild_index`(design ↔ plan `implements`),登记前先确认工具不覆盖同路径已有文档。
|
||||||
|
- [ ] 提交点:`docs: record approved call observability design and plan`(生产改动前的回滚点)。
|
||||||
|
|
||||||
|
### T1:统计内核与三条链路的逻辑边界(不动遥测列)
|
||||||
|
|
||||||
|
**文件**:`types.py`、`__init__.py`、`client.py`、`embedding.py`、`ocr.py`、`middleware/retry.py`、`middleware/cache.py`;测试 `tests/unit/test_types.py`、`test_client.py`、`test_retry.py`、`test_cache.py`、`test_embedding.py`、`test_ocr_client.py`、`test_structured.py`。
|
||||||
|
|
||||||
|
按 §3.1 与 §3.5 实现。先写测试并确认在旧实现上红(`AttributeError: 'LLMResponse' object has no attribute 'call_stats'` 不算行为红——先落一个"同一次调用的重试次数无处可读"的行为断言,再实现)。
|
||||||
|
|
||||||
|
| 红绿组 | 必须证明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 计数 | 一次成功=1;失败重试=实际尝试数;免预算 429 计入;多源拒绝(端口本地拒绝)计入;结构化重问计入同一上下文且不重置;embedding 三批=3;OCR layout 双 HTTP=1;缓存命中=0;空输入=0 |
|
||||||
|
| 逻辑 ID | 重试/重问/分批共享同一 `logical_call_id`;同一 client 并发两次调用互不串(`asyncio.gather` 两路各自计数与 ID);`parent_call_id` 语义不变 |
|
||||||
|
| 计时(FakeClock) | 缓存 IO、退避 sleep、准入等待、重问、内联遥测收尾全部计入 `total_latency_ms`;毫秒/秒不混用(1.5s → 1500);`emitter=None` 时统计仍正确。**替身构造要求**:假缓存后端的 `get`/`set` 与假 recorder 的 `record_llm_call` 内部**必须主动推进 FakeClock**,否则"缓存 IO/内联遥测计入总耗时"的断言会退化成恒等于 0 的空转绿 |
|
||||||
|
| 缓存不毒化 | `_serialize` 输出无 `call_stats` 键;手写含 `"call_stats": {...}` 的历史条目命中后 `response.call_stats is None`(**dict 不得冒充 `CallStats`**);缓存 key 逐字节不变(黄金值) |
|
||||||
|
| 附加点 | 失败异常上**不附任何统计字段**(`hasattr(exc, "call_stats") is False`);`CancelledError` 类型与语义不变;非领域异常原样传播 |
|
||||||
|
| 空输入 | `embed([])` 返回 `attempts=0`、真实 `logical_call_id`,且注入的内存 recorder **零行** |
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_client.py tests/unit/test_retry.py tests/unit/test_cache.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_structured.py -q` → 目标断言先红后绿,其余保留行为绿;`make check`(含 import-linter:`types.py` 不得引入实现层 import)。
|
||||||
|
|
||||||
|
- [ ] 提交点:`feat: track logical call statistics across governed calls`
|
||||||
|
|
||||||
|
### T2:遥测 10 列、诊断保真、scope/operation 与装配闸
|
||||||
|
|
||||||
|
**文件**:`telemetry/schema.py`、`ports.py`、`middleware/telemetry.py`、`middleware/structured.py`、`middleware/retry.py`、`transports/openai_compat.py`、三个 client 的 Emitter 构造行;测试 `tests/unit/test_telemetry.py`、`test_ports.py`、`test_openai_compat.py`、`test_monkey_ocr.py`、`test_structured.py`。
|
||||||
|
|
||||||
|
按 §3.2–§3.4 实现(本任务只产出 attempt / cache_hit 两类行的新列,终态行留 T3)。先写测试确认旧实现红:现状下 `error` 列是被 `str()` 压平的自由文本、无 `scope`/`operation` 列、旧签名 recorder 只落 warning。
|
||||||
|
|
||||||
|
| 红绿组 | 必须证明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 列与 SQL | `len(COLUMNS) == 36`、物理列 37、五处列序一致(新建库与 ALTER 追加列序相同);`insert_sql` 两端语句更新;`telemetry_schema_sql` 输出与库内 DDL 同源 |
|
||||||
|
| 端口 | `inspect.signature` 实测 10 个新参存在、keyword-only、无默认值;docstring 字段数与实测一致 |
|
||||||
|
| 诊断保真 | 中转把 529 改写成 503 → 记 503(**不猜回 529**);直接 529 → 529;空 `str()` 的 Connect/Read/Write/PoolTimeout → error 落类名、`cause_type` 为对应 httpx 类名;`error_body` 为 `summarize_body` 摘要且不等于 `raw_text`;成功行五列 NULL(不填 200) |
|
||||||
|
| operation | `embed` 非 200 → `exc.operation == "embedding"`(历史误标修正);新列 `operation` 恒为四值之一,与 `exc.operation` 无关、不随异常变化;`monkey_ocr` 的 `success != true` 带 **200** 的失败行 → `http_status_code == 200`(该列不可作失败判据) |
|
||||||
|
| scope | attempt 与 cache_hit 行都带 scope;model/provider/source 未选出时仍留原空值 |
|
||||||
|
| 字符串入参 | 取消 attempt 的 `"cancelled"` 仍原样落 `error`,其余四列 NULL(**不解析字符串**) |
|
||||||
|
| 装配闸 | 旧签名 recorder → 构造 `TelemetryEmitter`(即三个 client 装配)**抛错**而非 warning;`**kwargs` recorder 通过;不可 inspect 的对象 → 配置错误报错;参数名确由协议签名派生(改协议后闸自动跟随的断言) |
|
||||||
|
| 有界说明 | `format_bounded_errors` 重命名后 `StructuredMW` 反馈文案逐字不变(黄金串);常量只有一份定义 |
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_openai_compat.py tests/unit/test_monkey_ocr.py tests/unit/test_structured.py -q`;`make check`。
|
||||||
|
|
||||||
|
- [ ] 提交点:`feat: record scope, operation and failure diagnostics per row`
|
||||||
|
|
||||||
|
### T3:终态行、取消口径与统一出口
|
||||||
|
|
||||||
|
**文件**:`middleware/telemetry.py`(终态 helper 与 `TelemetryMW` 收缩)、`client.py`、`embedding.py`、`ocr.py`;测试 `tests/unit/test_telemetry.py`、`test_client.py`、`test_embedding.py`、`test_ocr_client.py`、`test_structured.py`、`test_retry.py`。
|
||||||
|
|
||||||
|
按 §3.2 的 `emit_terminal_once` 与 §3.5 的三条链路实现。先写测试确认旧实现红:结构化耗尽当前**没有任何失败行**;embedding/OCR 的无源、准入拒绝、重试耗尽与尝试外取消同样无终态行。
|
||||||
|
|
||||||
|
| 红绿组 | 必须证明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 补漏 | chat 结构化耗尽、embedding/OCR 的 `no_sources`、准入拒绝、`retry_exhausted`、尝试外取消各恰有 **1** 条 `terminal_failure` |
|
||||||
|
| 不变量 I3 | 领域失败每调用至多 1 条(recorder 写失败仅 warning,SQL 可见 ≤1);重复调用出口不产生第二条(`claim_terminal`);直接 `RequestRejectedError` / `ResultInvalidError` 现在**既有 attempt 错误行也有终态行**(400 密集负载错误行翻倍是已批准的下游可见变化) |
|
||||||
|
| 非领域异常 | 编程错(如 `KeyError`)→ **0 条**终态行、原样传播、分类不被改写 |
|
||||||
|
| 取消三路 | chat / embed / OCR 同策略尽力写一条(允许 0);**终态写的 await 上被取消 → `CancelledError` 传播**(不 shield、无后台任务);permit 与探针释放行为不变(`inflight == 0`) |
|
||||||
|
| 归因 SQL | 设计 §5 那条 `WHERE logical_call_id = :lcid` 查询同时给出终态 reason 文案与逐源状态码/正文;终态行 `http_status_code`/`cause_type`/`error_body` 三列 NULL;结构化耗尽的终态 `error` 含有界 validation/repair 说明且**不含 `raw_text`** |
|
||||||
|
| 行语义 | 三类行均带 scope;`event_kind` 可区分;`SUM(cost)` 不因终态行变化(终态 `cost IS NULL`、usage `unavailable`);`AVG(latency_ms)` 按 `event_kind` 分组的断言;终态 `latency_ms == total_latency_ms`(同一快照) |
|
||||||
|
| 快照时机 | 终态快照不含自身写入耗时(FakeClock:写入内推进时钟,列值不变);成功响应快照含返回前已完成的内联遥测耗时 |
|
||||||
|
| 摘要 | embedding 终态 messages 为占位 + 第一批截断文本,不含全量原输入;OCR 终态沿用 `<ocr:{kind} image_bytes=…>`,图像 bytes 不入库;OCR 终态 error 保留类名前缀 |
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit -q`(全量单测,含上述文件);`make check`。
|
||||||
|
|
||||||
|
- [ ] 提交点:`feat: emit one terminal failure row per logical call`
|
||||||
|
|
||||||
|
### T4:旧测试机械核对、存储兼容、变异证据、文档与独立验证
|
||||||
|
|
||||||
|
**文件**:下述机械核对清单 + `tests/integration/test_postgres_telemetry.py` + 文档清单 + 验收 finding。
|
||||||
|
|
||||||
|
**一次机械核对(禁止分散反复修)**:本版真正的破坏面**不只是 recorder 签名,还有 Emitter 侧**(`TelemetryEmitter.__init__` 新增必填 `scope`、三个 `emit_*` 新增必填 `operation`、`emit_terminal_failure` 去 `latency_ms` 改收 `stats`),这些关键词不含 `record_llm_call`/`COLUMNS`。故全量清单用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "record_llm_call\|COLUMNS\|_EXPECTED_COLUMNS\|TelemetryEmitter(\|emit_attempt(\|emit_cache_hit(\|emit_terminal_failure(" tests/ --include=*.py
|
||||||
|
```
|
||||||
|
|
||||||
|
一轮改完再跑,不逐个文件试错。已核实的免改项:`tests/unit/test_backpressure.py:231` 的假 emitter 是 `emit_attempt(self, *args, **kwargs)`,兼容;`tests/integration/` 无 Emitter 构造点。已知点:
|
||||||
|
|
||||||
|
| 位置 | 动作 |
|
||||||
|
| --- | --- |
|
||||||
|
| `tests/unit/test_ports.py:99` `_DummyRecorder` | 显式签名补齐 10 参(它是"新签名可实现"的活证据,不改成 `**kwargs`) |
|
||||||
|
| `tests/unit/test_ports.py:264` `TestTelemetryRecorderSignature` | 新参进 `no default` / keyword-only 参数化 |
|
||||||
|
| `tests/unit/test_telemetry.py:40` `_EXPECTED_COLUMNS`、:220/:321/:597/:639/:647/:686-698 计数与尾部断言 | 26→36、27→37、尾部 10 列、旧表 backfill 目标列数 |
|
||||||
|
| `tests/unit/test_telemetry.py:108` `_record_minimal` | 字段字典补 10 键(默认 NULL 形态) |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py:100` 字段字典 | 同上(该函数返回值被逐列断言消费,改动须与 `COLUMNS` 同序) |
|
||||||
|
| `test_client.py` / `test_embedding.py` / `test_ocr_client.py` / `test_openai_compat.py` / `test_pricing.py` / `test_cache.py` / `test_usage_source_domain.py` 的 `_MemoryRecorder` | 均为 `**fields` 形态,**recorder 签名无需改**;只需核对断言里的字段计数与新列期望 |
|
||||||
|
| `tests/unit/test_pricing.py`、`tests/unit/test_usage_source_domain.py` 的 **Emitter 调用点** | **须改**:`TelemetryEmitter(` 补 `scope=`、`emit_attempt(`/`emit_cache_hit(` 补 `operation=`;`test_usage_source_domain.py:297` 的 `emit_terminal_failure(...)` 同时缺 `scope`/`operation`/`stats` 且多传 `latency_ms`,不改必 `TypeError`(recorder 形态兼容 ≠ emitter 调用点兼容) |
|
||||||
|
|
||||||
|
**存储兼容**(真实 PG,复用 `tests/integration/conftest.py` 的 `pg_sandbox` / `pg_catalog_probe`,不新建沙箱设施;**不引用 `assert_no_leftovers`**——它是 `tests/integration/test_pg_sandbox.py:19` 的模块级 fixture,对 `test_postgres_telemetry.py` 不可见,上提它要改 §2 清单外的 `conftest.py`。本版不新增沙箱资源创建路径,残留风险与 1.3.4 逐字相同,由该文件既有用例覆盖,属可复用的既有证据):
|
||||||
|
|
||||||
|
- SQLite:新建库 37 列;旧表(1.3.4 形态 27 列)auto 档补齐 10 列且列序与新建库一致;manual 档不发 DDL、按现有列裁剪写入并发一条点名缺列的 warning;旧行新列为 NULL。
|
||||||
|
- PG(验收取以下**四项**):auto 追加 10 列;**manual 缺列裁剪**写入成功且不抛(`_trim_columns` 路径);旧行新列为 NULL;新旧进程混写同一表(旧列集写入 + 新列集写入并存)。
|
||||||
|
|
||||||
|
**变异证据**(仓库外临时副本 + `PYTHONPATH=<副本>/src`,先确认 `polygateway.__file__` 指向副本;逐个变异 → 跑指定节点记 exit 1 与被杀断言 → 恢复校验散列 → exit 0;**绝不在主工作区改生产代码凑红**):
|
||||||
|
|
||||||
|
| 变异 | 必须被杀死的断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| `register_attempt()` 移到 transport 调用之后的 `except` 分支外/内错位 | 失败重试与 429 计数断言 |
|
||||||
|
| `ChatRequest` 上下文字段改为 `replace` 时新建实例(模拟上下文复制) | 重问/分批共享同一 `logical_call_id` 的断言 |
|
||||||
|
| `_rehydrate` 去掉 `call_stats=None` 覆盖 | 历史 dict 冒充 `CallStats` 的断言 |
|
||||||
|
| Emitter 入口提前 `str(exc)` 压平 | `error_type`/`http_status_code`/`error_body` 保真断言 |
|
||||||
|
| 终态行复制最后一次 attempt 的 token/cost | 费用聚合与终态 `cost IS NULL` 断言 |
|
||||||
|
| 去掉 `claim_terminal` 去重 | 每失败调用至多一条终态的断言 |
|
||||||
|
| 装配闸改为捕获 `TypeError` 后 warning | 旧签名 recorder 装配期报错的断言 |
|
||||||
|
|
||||||
|
**文档同步(发布前必须同批;wiki 站点自 2026-08-02 下线,按 docs-convention §2 的下线期条款,承接方为 README / CHANGELOG / .env.example / ARCHITECTURE 四处)**:
|
||||||
|
|
||||||
|
| 位置 | 改什么 |
|
||||||
|
| --- | --- |
|
||||||
|
| `README.md:23` | "必录 26 字段" → 实测值(`len(inspect.signature(TelemetryRecorder.record_llm_call).parameters) - 1`,预期 36),不凭记忆 |
|
||||||
|
| `README.md:26/399/455`、`ARCHITECTURE.md:592`、`.env.example:109` | `PGW_TELEMETRY_TEXT_CAP` 覆盖面仍是四处;明确 `error_body` 沿用 `summarize_body` 上限、`error` 的新增结构化说明另有独立限长,**二者都不在 cap 覆盖内** |
|
||||||
|
| `README.md` 能力表 + 新增小节 | 四响应的 `call_stats` 读法;设计 §8 的 SQL 迁移**五项**(失败行数改判据、`error IS NOT NULL` 不再是判据、`AVG(latency_ms)` 须按 `event_kind` 分组、费用口径不变、失败行可能带 200) |
|
||||||
|
| `ARCHITECTURE.md:565` 必录字段清单 + §7.8 补列一节 | 追加 10 列语义、`event_kind` 三态、终态行不变量 I3/I4、`operation` 与 `exc.operation` 是两个语义 |
|
||||||
|
| `CHANGELOG.md` 未发布段 | 公共面四项 + 下游动作清单(点名"计失败调用改 `WHERE event_kind = 'terminal_failure'`"与自定义 recorder 的装配期报错) |
|
||||||
|
| `research-wiki/schemas/llm-calls.md`、`metrics/call-telemetry-coverage.md` | 复用既有实体登记新列与三类行口径;真实覆盖基线待首次运行填,不写伪百分比 |
|
||||||
|
|
||||||
|
**验收命令与证据**:
|
||||||
|
|
||||||
|
| 检查 | 命令 / 要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| 静态 | `make check`;`git diff --check`;`conda run -n PolyGateway python -m compileall -q src/polygateway` |
|
||||||
|
| 日常全量 | `make test`(真实退出码、coverage ≥80%,连接依赖 skip 单列) |
|
||||||
|
| 集成 | `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py tests/integration/test_governance_stack.py -q` |
|
||||||
|
| 契约 | `conda run -n PolyGateway pytest tests/contracts -q`(限流契约随实现一起交付的既有套件) |
|
||||||
|
| 变异 | 上表七项逐条 exit 1 → 还原 exit 0,日志留 `tests/outputs/135/` |
|
||||||
|
| 独立验证 | 由**父会话前台派全新上下文 verifier**:只给批准设计、本计划、分支 diff 与命令,不给实现自评;至少覆盖"正确性/回归"与"下游可见变化/范围"两角度,Critical/Important 清零 |
|
||||||
|
| slow | `pytest -m slow` 属发布清单第 4 步(CLAUDE §4.4.1),在合并 main 之后统一跑;**本版不新增 live 轮次、不补跑未变更的模型能力矩阵**,1.3.4 已有有效证据直接引用 |
|
||||||
|
|
||||||
|
长跑用 tmux,`PYTHONUNBUFFERED=1`,输出重定向到 `tests/outputs/135/`,命令后**不得接管道**(退出码失真),完成判定用 `wait`/PID 轮询,不用会自匹配的 `pgrep -f`。
|
||||||
|
|
||||||
|
- [ ] 提交点:`docs: document logical call telemetry and migration impact`(机械核对与存储兼容若需单独回滚点,可先提 `test: align telemetry fixtures with the 36-field recorder`,仍在 5 个提交内)
|
||||||
|
|
||||||
|
## 5. 测试矩阵 → 任务映射(设计 §9 逐族落位)
|
||||||
|
|
||||||
|
| 设计测试族 | 任务 |
|
||||||
|
| --- | --- |
|
||||||
|
| logical 计数(含空输入 0 尝试 0 行) | T1 |
|
||||||
|
| 计时(缓存/退避/准入/重问/收尾、关 recorder) | T1(快照时机的终态部分在 T3) |
|
||||||
|
| 失败与取消(三路同策略、终态 await 取消传播、非领域异常 0 行) | T3 |
|
||||||
|
| 保真诊断(529/503、空超时文案、`exc.operation`、新列四值) | T2 |
|
||||||
|
| 归因 SQL(终态 reason + 逐源现场、结构化说明无 `raw_text`) | T3 |
|
||||||
|
| 行语义(三类行 scope、至多一条终态、费用不重复、`AVG` 分组) | T2(前两项)+ T3 |
|
||||||
|
| 装配闸(旧签名报错、`**kwargs` 通过、不可 inspect) | T2 |
|
||||||
|
| 存储兼容(SQLite 新旧表、PG manual/auto、旧行 NULL、混写) | T4 |
|
||||||
|
| 变异(计数位置、上下文复制、缓存回放、提前压平、终态双计费用) | T4 |
|
||||||
|
|
||||||
|
**共享测试设施一律复用,不新建**:`tests/contracts/conftest.py:26` 与 `tests/unit/test_ocr_client.py:344` 的 `FakeClock`(注入 `now`/`sleep`,确定性计时);各测试文件既有的 `_MemoryRecorder`(`**fields`)与脚本化 MockTransport;`tmp_path` + 真实 `SQLiteRecorder(auto_migrate=...)`;`tests/integration/conftest.py` 的 PG 沙箱三件套。真实 LLM 输出如产生,按 CLAUDE §4.6 落 `tests/outputs/<module>/`。
|
||||||
|
|
||||||
|
## 6. 阻塞矩阵与发布交接
|
||||||
|
|
||||||
|
| 情形 | 本库可完成 | 不可自行宣称/处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 无 PG 可用 | 单测与 SQLite 全部覆盖;PG 用例 skip 并单列 | 不得把 skip 记作通过;发布前须补跑或取人类具名豁免 |
|
||||||
|
| 下游自定义 recorder 未知 | 装配闸 + 迁移文档 + `**kwargs` 兼容路径 | 不能声称"后端完全不受影响":其 schema、INSERT 字段与契约测试仍须同步 |
|
||||||
|
| 设计外漏洞 | 独立记录实际文件与反例,交父会话核定 | 不顺手实施 #20 之外的 issue、不新增表/端口/deadline/hedging |
|
||||||
|
| 发布 | 门全绿后按 CLAUDE §4.4.1 逐步执行(文档先行 → CHANGELOG 定版 → 双处版本号 → 合并 main → `make lint`/`make test`/`pytest -m slow` → tag → 构建 → 上传 → 下载验证 → Release + 挂仓库 + 页面核对) | 本计划**不复制**该清单,也不预先勾选任何发布步骤;只 bump 版本号不叫发布 |
|
||||||
|
|
||||||
|
## 7. 自审
|
||||||
|
|
||||||
|
- 设计每节可指到任务:§3→T1、§4→T1、§5→T2/T3、§6→T3、§7→T2、§8→T4、§9→§5 映射表、§10 六项批准项全部落在 T1–T3 的公共面改动内。
|
||||||
|
- 无占位符与待定项:跨任务消费的类型(`CallStats`、`_CallContext`、`_ErrorFields`)、四个 emit 入口、两个 helper(`_assert_recorder_shape`、`emit_terminal_once`)、10 列取值表、10 列 DDL 类型均已写出可执行定义;被引用的 `PolyGatewayError`、`ResultInvalidError`、`summarize_body`、`format_bounded_errors`、`FakeClock`、`pg_sandbox` 全部指向既有实现或本计划已定义项。
|
||||||
|
- 一致性核对:`emit_terminal_failure` 去掉 `latency_ms` 后,唯一调用者是 `emit_terminal_once`;`TelemetryMW` 的终态分支删除后 chat 终态只剩客户端边界一处;`operation` 只由调用点给定,链路上无任何位置读 `exc.operation`。
|
||||||
|
- 未采纳项(异常上挂可变统计、终态搬运最后一次 attempt 的状态与正文、成功侧汇总行)理由在设计正文,本计划不复活。
|
||||||
|
- 本计划编写过程**未运行 pytest、未做变异、未调用任何模型、未提交**;T0 基线验证与其后各任务的红绿证据由执行者在自己的会话内出示。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:plan-issue15-telemetry-pool-lifecycle
|
||||||
|
title: "实现计划: 遥测连接池的资源语义与生命周期(issue #15)"
|
||||||
|
date: 2026-08-24
|
||||||
|
---
|
||||||
|
|
||||||
|
# 实现计划: 遥测连接池的资源语义与生命周期(issue #15)
|
||||||
|
|
||||||
|
正文: `2026-08-24-issue15-telemetry-pool-lifecycle.md`(380 行)。实现 [[design:issue15-telemetry-pool-lifecycle]]。状态: **T0–T7 全部完成 + T8 处置独立验证发现的 5 个问题(2026-08-24)**,提交表见正文末尾。
|
||||||
|
|
||||||
|
- **八个任务**: T0 分支与基线(把已完成的 Python 3.12 迁移落盘)→ T1 D 组所有权纪律(独立回滚点)→ T2 C 组 tracker 与状态快照 → T3 A 组池语义与两个新配置键 → T4 有界关闭 → T5 B 组失败三分与冷却降级(核心)→ T6 真实 PG 集成验证 → T7 文档与发布说明。
|
||||||
|
- **顺序的关键理由**: tracker(T2)排在池语义(T3)与失败判据(T5)**之前**——后两步的每个降级点都要向 tracker 报告,反过来做要把日志代码返工一遍。代价是 T2 结束时 `_failed` 与 tracker 状态**临时并存**(为了让 T2 能独立全绿提交),T5 必须收掉,两份状态只允许存活一个任务的跨度。
|
||||||
|
- **执行前必读的两条事实**: ① Python 3.12 迁移的改动**还在 main 的工作区未提交**(T0 第一件事就是落到分支);② **建池路径今天零测试覆盖**——全 `tests/` 对 `create_pool`/`_open_pool` 的引用数为 0,现有 PG 用例一律经 `pool=_FakePgPool(...)` 注入、走 `_external_pool=True` 分支从不建池。这正是 `min_size=10` 潜伏至今的原因,也意味着 T3 要建这一路的**第一个**用例。
|
||||||
|
- **提交门是任务边界的实际约束**: `.claude/scripts/hooks/pre-commit-guard.sh` 对每次 `git commit` 阻塞式跑 ruff + radon(圈复杂度 ≥C 即拦)+ 全套件。由此两条硬约束: 不得留红态跨提交(不能把一个行为拆成"改实现"和"改测试"两次);T5 同时改三个降级点,`record_llm_call` 逼近 C 时必须抽私有方法——这不算计划外重构,是提交门的硬要求。
|
||||||
|
- **两条既有承诺挂了检查点,不得被本次改动破坏**: [[design:issue13-schema-mode]] 的"manual 档缺列时裁剪 INSERT 继续写、逐行暴露"(故 `42703` 是失败分类的唯一具名例外)、[[design:issue9-telemetry-ddl-probe]] 的"表存在就绝不发 DDL"(`to_regclass` 探测那段控制流一行不动)。
|
||||||
|
- **保真校验不适用**: 遥测后端无 `reference/` 蓝本(ARCH §7.8 明记"参考仓无先例: 三项目遥测全 SQLite")。
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:reasoning-effort
|
||||||
|
title: "实现计划: 推理档位一等化"
|
||||||
|
date: 2026-09-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# 实现计划: 推理档位一等化
|
||||||
|
|
||||||
|
正文: `2026-09-04-reasoning-effort.md`(378 行,10 任务)。实现 `design:reasoning-effort`。
|
||||||
|
|
||||||
|
- **拆分逻辑**: T1(`Effort` 词汇 + 能力表)与 T2(`ThinkingWire` + 8 段 provider 表)可并行 → T3(五道关卡 + nearest 映射)→ T4(源级 env 入口)→ T5(请求级入口与优先级)→ T6(缓存 key 两处)/T7(遥测第 26 列)→ T8(transport 接线与告警节流)→ T9(CHANGELOG/wiki/1.4.0)→ T10(经 new-api 逐模型实测,标 `slow`)。
|
||||||
|
- **T10 单列的理由**: 人类定「能力表数据统一自己经 new-api 实测」。T1 落的是文档推定值(四方交叉: 官方文档/OpenRouter/cherry-studio/LiteLLM),不实测则整张表都是假设——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下三档、`perplexity/` 下六档,中转改档位有第三方证据。
|
||||||
|
- **执行方式**: 直接按计划实现,**不派** `subagent-driven-development`——10 个任务共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型,独立上下文的 subagent 容易在签名上分叉。
|
||||||
|
- **保真校验**: 不适用(`thinking.py` 系库自研,非 `reference/` 移植蓝本;且三项目当前不在工作区)。
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
---
|
---
|
||||||
type: schema
|
type: schema
|
||||||
node_id: schema:llm-calls
|
node_id: schema:llm-calls
|
||||||
title: "表结构: llm_calls(遥测 22 字段)"
|
title: "表结构: llm_calls(遥测 36 字段)"
|
||||||
date: 2026-07-20
|
date: 2026-07-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# 表结构: llm_calls(遥测 22 字段)
|
# 表结构: llm_calls(遥测 36 字段)
|
||||||
|
|
||||||
|
|
||||||
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
||||||
|
|
||||||
| 列 | 类型 | 说明 |
|
| 列 | 类型 | 说明 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| call_id | TEXT PRIMARY KEY | 每次尝试独立 UUID;INSERT OR IGNORE 幂等 |
|
| call_id | TEXT PRIMARY KEY | 每次尝试独立 UUID;INSERT OR IGNORE 幂等 |
|
||||||
| parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) |
|
| parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) |
|
||||||
| model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) |
|
| model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) |
|
||||||
@@ -28,11 +27,46 @@ date: 2026-07-20
|
|||||||
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
|
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
|
||||||
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
|
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
|
||||||
| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 |
|
| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 |
|
||||||
|
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 |
|
||||||
|
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB |
|
||||||
|
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
|
||||||
|
| reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
|
||||||
|
| scope | TEXT | 池名(2026-09-09,issue #19/#23);Emitter 构造期注入,三类行都带,**不拿 `source_name` 顶替** |
|
||||||
|
| operation | TEXT | `chat` / `embed` / `recognize_text` / `parse_layout`,由调用点给定;与 `PolyGatewayError.operation` 是两个语义,链路上不得读后者填本列 |
|
||||||
|
| logical_call_id | TEXT | 一次逻辑调用一个 ID(重试/换源/重问/分批共享);上下文缺席落 NULL,**不造 ID** |
|
||||||
|
| event_kind | TEXT | 三态 `attempt` / `cache_hit` / `terminal_failure`——三类行的唯一机械判据。见下方口径 |
|
||||||
|
| http_status_code | INTEGER | 失败 attempt 行的实收状态码(中转改写过就记改写后的,**不猜回原值**);成功行与终态行恒 NULL,且 200 也可能是失败行(MonkeyOCR `success != true`) |
|
||||||
|
| error_type | TEXT | 该行自身错误的类名;取消路径传字符串故为 NULL |
|
||||||
|
| cause_type | TEXT | `__cause__` 的类名(如 `ReadTimeout`)——httpx 超时类的 `str()` 为空,只靠 error 列分不出是哪种超时;仅失败 attempt 行非空 |
|
||||||
|
| error_body | TEXT | 网关响应正文摘要(`summarize_body` 上限,**不在 `PGW_TELEMETRY_TEXT_CAP` 覆盖面内**);仅失败 attempt 行非空 |
|
||||||
|
| attempts | INTEGER | 该逻辑调用真实打出去的尝试次数(免预算 429 也计);**只属终态行** |
|
||||||
|
| total_latency_ms | INTEGER | 该逻辑调用的总墙钟(含缓存 IO、退避、准入等待、重问);**只属终态行**,与该行 `latency_ms` 同取一份冻结快照 |
|
||||||
|
|
||||||
|
## 三类行与失败归因口径(2026-09-09,issue #19/#23)
|
||||||
|
|
||||||
|
遥测行不再只有"一次尝试"一种。`event_kind` 三态同时决定其余列的取值面:
|
||||||
|
|
||||||
|
| event_kind | 什么时候写 | 列取值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `attempt` | 每次真实尝试(含失败与取消) | 失败时诊断四列可非空;`attempts`/`total_latency_ms` NULL |
|
||||||
|
| `cache_hit` | 缓存命中 | 诊断四列与快照两列全 NULL |
|
||||||
|
| `terminal_failure` | 每次**领域失败**的整池终态,每逻辑调用至多一条 | `attempts`/`total_latency_ms` 非空;`http_status_code`/`cause_type`/`error_body` 恒 NULL;`cost` NULL、`usage_source='unavailable'`、token 0 |
|
||||||
|
|
||||||
|
两条不变量: 每次领域失败至多一条终态行(I3,`claim_terminal()` 去重);非领域异常(编程错)**零条**终态行、原样传播(I4)。
|
||||||
|
|
||||||
|
**终态行三列恒 NULL 是红线**: 把最后一次 attempt 的状态码与正文搬上来,等于拿最后一个源冒充整池归因。逐源现场由同一 `logical_call_id` 的 attempt 行给出:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT event_kind, source_name, http_status_code, error_type, cause_type, error, error_body
|
||||||
|
FROM llm_calls WHERE logical_call_id = :lcid ORDER BY created_at;
|
||||||
|
```
|
||||||
|
|
||||||
|
下游口径迁移四条: ① 计失败调用改 `WHERE event_kind = 'terminal_failure'`;② `error IS NOT NULL` 不再是失败调用判据(跨两类行);③ `AVG(latency_ms)` 须按 `event_kind` 分组(终态行是整个逻辑调用的总耗时);④ 费用口径不变(终态行 cost 恒 NULL)。
|
||||||
|
|
||||||
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
||||||
|
|
||||||
| usage_source | 含义 | 生产者 | cost |
|
| usage_source | 含义 | 生产者 | cost |
|
||||||
|---|---|---|---|
|
| --- | --- | --- | --- |
|
||||||
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是事实) | 按 token 换算 |
|
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是事实) | 按 token 换算 |
|
||||||
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
|
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
|
||||||
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | NULL |
|
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | NULL |
|
||||||
@@ -54,14 +88,16 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
|
|||||||
|
|
||||||
## 采样参数口径(2026-07-31,issue #4)
|
## 采样参数口径(2026-07-31,issue #4)
|
||||||
|
|
||||||
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故统计口径须为 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
|
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故当时的统计口径是 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
|
||||||
|
|
||||||
|
> **该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报 `completion_tokens_details`(MiniMax 这一路实测已停),此时 NULL 只意味着「没上报」而非「没推理」——同一次调用里库拿得到 185 字符推理正文。统计一律改按新列 `thinking_observation` 分组,见下方「推理观测口径」。
|
||||||
|
|
||||||
`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
|
`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
|
||||||
|
|
||||||
三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同:
|
三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同:
|
||||||
|
|
||||||
| 入口 | 调用者 | 有生效源? | 记什么 |
|
| 入口 | 调用者 | 有生效源? | 记什么 |
|
||||||
|---|---|---|---|
|
| --- | --- | --- | --- |
|
||||||
| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` |
|
| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` |
|
||||||
| `emit_cache_hit` | TelemetryMW(最外) | 无 | 仅 `request.sampling` |
|
| `emit_cache_hit` | TelemetryMW(最外) | 无 | 仅 `request.sampling` |
|
||||||
| `emit_terminal_failure` | TelemetryMW | 无 | 仅 `request.sampling` |
|
| `emit_terminal_failure` | TelemetryMW | 无 | 仅 `request.sampling` |
|
||||||
@@ -77,6 +113,57 @@ SELECT DISTINCT sampling FROM llm_calls
|
|||||||
WHERE session_id = $1 AND cache_hit = false AND error IS NULL;
|
WHERE session_id = $1 AND cache_hit = false AND error IS NULL;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 推理观测口径(2026-08-25,issue #16/#17)
|
||||||
|
|
||||||
|
`thinking_observation` 是**响应侧的裁定结果**,不是请求侧的声明: 推理正文(`thinking`)非空即 `observed`(正文是事实本身,压倒 usage 明细这一转述);正文空而 `reasoning_tokens > 0` 亦 `observed`;`reasoning_tokens == 0` 为 `absent`(上游明确上报未推理);两个信号双缺为 `unknown`。
|
||||||
|
|
||||||
|
**`unknown` 不得并进「未推理」**。它是本列存在的全部理由: MiniMax 这一路上游 2026-08-25 起不再返回 `completion_tokens_details`,`reasoning_tokens` 因此恒 NULL,而同一次调用里库拿得到 185 字符推理正文——旧口径 `reasoning_tokens IS NULL OR = 0` 会把这类调用统计成「没推理」。**该旧口径自本版起作废**,统计一律按本列分组。M3 非流式档更极端: 推理已计费(completion 53 vs 关闭档 3)却不回传正文,该档只能是 `unknown`,任何把它读成「没推理」的报表都在撒谎。
|
||||||
|
|
||||||
|
按模型看各观测态占比,用于发现某模型从哪天起观测不到推理:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT model,
|
||||||
|
thinking_observation,
|
||||||
|
count(*) AS calls,
|
||||||
|
round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY model), 1) AS pct
|
||||||
|
FROM llm_calls
|
||||||
|
WHERE cache_hit = false AND error IS NULL
|
||||||
|
AND created_at >= now() - interval '7 days'
|
||||||
|
GROUP BY model, thinking_observation
|
||||||
|
ORDER BY model, calls DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
三条限定各有理由: `cache_hit = false` 与 `cost`/`cached_prompt_tokens` 同源——缓存命中行原样回放历史观测值,计入即重复计数;`error IS NULL` 排除失败尝试与终态失败行,那些行的本列恒为 `unknown`(无响应可裁定,默认值本身不撒谎),混进来会把「观测不到」的占比整体抬高;时间窗是为了让**变化**可见——某模型的 `unknown` 占比从 0 跳到 100%,正是它停报推理信号的那一天。补列之前写入的历史行本列为 NULL,与 `unknown` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。
|
||||||
|
|
||||||
|
## 推理档位口径(2026-09-04,issue #20)
|
||||||
|
|
||||||
|
`reasoning_effort` 的来源取决于行类型,不能总括为「实际发出」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
|
||||||
|
|
||||||
|
三个 emit 入口的取值同样各自定死,与 `sampling` 同构:
|
||||||
|
|
||||||
|
| 入口 | 有生效源? | 记什么 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** |
|
||||||
|
| `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** |
|
||||||
|
| `emit_cache_hit` | 无 | 本次 `request.reasoning_effort`,不取历史 applied、不推源级 |
|
||||||
|
| `emit_terminal_failure` | 无 | 本次 `request.reasoning_effort`,可能尚未选源 |
|
||||||
|
|
||||||
|
真实成功尝试必须读实际编码档而非重算(分析须同时排除 cache_hit 和 error;未知 AUTO 仅尽力,不证明上游推理,raw-only=NULL): 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。
|
||||||
|
|
||||||
|
OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。
|
||||||
|
|
||||||
|
按档位看推理产出,即压测「高档是不是真的多想」的基本查询:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT model, reasoning_effort,
|
||||||
|
count(*) AS calls,
|
||||||
|
round(avg(reasoning_tokens)) AS avg_reasoning_tokens
|
||||||
|
FROM llm_calls
|
||||||
|
WHERE cache_hit = false AND error IS NULL AND reasoning_effort IS NOT NULL
|
||||||
|
GROUP BY model, reasoning_effort
|
||||||
|
ORDER BY model, calls DESC;
|
||||||
|
```
|
||||||
|
|
||||||
## 埋点位置(单一 helper 铁律)
|
## 埋点位置(单一 helper 铁律)
|
||||||
|
|
||||||
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
|
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
|
||||||
@@ -86,3 +173,7 @@ WHERE session_id = $1 AND cache_hit = false AND error IS NULL;
|
|||||||
## 评估基线
|
## 评估基线
|
||||||
|
|
||||||
首版无历史基线,标"待首次运行后建立";验收断言: 单测覆盖成功/失败/缓存命中/取消四路径各产生恰一行;并发 50 协程写全落库。
|
首版无历史基线,标"待首次运行后建立";验收断言: 单测覆盖成功/失败/缓存命中/取消四路径各产生恰一行;并发 50 协程写全落库。
|
||||||
|
|
||||||
|
## 1.3.4 测试侧证据(不新增 schema)
|
||||||
|
|
||||||
|
`tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。
|
||||||
|
|||||||
@@ -22,22 +22,43 @@ from polygateway.errors import (
|
|||||||
)
|
)
|
||||||
from polygateway.ocr import OcrClient
|
from polygateway.ocr import OcrClient
|
||||||
from polygateway.pricing import ModelPrice, PricingTable
|
from polygateway.pricing import ModelPrice, PricingTable
|
||||||
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
|
from polygateway.providers import (
|
||||||
|
DEFAULT_PROFILES,
|
||||||
|
ProviderProfile,
|
||||||
|
ThinkingWire,
|
||||||
|
register_provider,
|
||||||
|
)
|
||||||
from polygateway.telemetry.schema import telemetry_schema_sql
|
from polygateway.telemetry.schema import telemetry_schema_sql
|
||||||
|
from polygateway.thinking import (
|
||||||
|
ThinkingCapability,
|
||||||
|
ThinkingResolution,
|
||||||
|
ThinkingUnsupportedError,
|
||||||
|
get_capability,
|
||||||
|
register_capability,
|
||||||
|
resolve_thinking,
|
||||||
|
)
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
|
EFFORT_ORDER,
|
||||||
|
CallStats,
|
||||||
|
Effort,
|
||||||
EmbeddingResponse,
|
EmbeddingResponse,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
OcrLayoutElement,
|
OcrLayoutElement,
|
||||||
OcrLayoutResult,
|
OcrLayoutResult,
|
||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
TelemetryStatus,
|
||||||
|
ThinkingObservation,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.2.4"
|
__version__ = "1.3.5"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROFILES",
|
"DEFAULT_PROFILES",
|
||||||
|
"EFFORT_ORDER",
|
||||||
|
"Effort",
|
||||||
"AllSourcesExhausted",
|
"AllSourcesExhausted",
|
||||||
|
"CallStats",
|
||||||
"CircuitOpenError",
|
"CircuitOpenError",
|
||||||
"EmbeddingClient",
|
"EmbeddingClient",
|
||||||
"EmbeddingResponse",
|
"EmbeddingResponse",
|
||||||
@@ -61,9 +82,18 @@ __all__ = [
|
|||||||
"SourceConfig",
|
"SourceConfig",
|
||||||
"SourceDeadError",
|
"SourceDeadError",
|
||||||
"SourceNotConfiguredError",
|
"SourceNotConfiguredError",
|
||||||
|
"TelemetryStatus",
|
||||||
|
"ThinkingCapability",
|
||||||
|
"ThinkingObservation",
|
||||||
|
"ThinkingResolution",
|
||||||
|
"ThinkingUnsupportedError",
|
||||||
|
"ThinkingWire",
|
||||||
"TransientError",
|
"TransientError",
|
||||||
"__version__",
|
"__version__",
|
||||||
"gather_bounded",
|
"gather_bounded",
|
||||||
|
"get_capability",
|
||||||
|
"register_capability",
|
||||||
"register_provider",
|
"register_provider",
|
||||||
|
"resolve_thinking",
|
||||||
"telemetry_schema_sql",
|
"telemetry_schema_sql",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,14 +25,20 @@ class RedisCache:
|
|||||||
"Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'"
|
"Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'"
|
||||||
) from _IMPORT_ERROR
|
) from _IMPORT_ERROR
|
||||||
self._client = client
|
self._client = client
|
||||||
|
# 注入的客户端归注入方管理: 关掉它会弄死共享同一连接的其他组件
|
||||||
|
# (与 RedisLimiter/RedisGate 同一纪律)
|
||||||
|
self._owns_client = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_url(cls, url: str) -> RedisCache:
|
def from_url(cls, url: str) -> RedisCache:
|
||||||
|
"""自建并持有 Redis 客户端(aclose 时代关);共享后端请直接注入 client。"""
|
||||||
if aioredis is None:
|
if aioredis is None:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'"
|
"Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'"
|
||||||
) from _IMPORT_ERROR
|
) from _IMPORT_ERROR
|
||||||
return cls(aioredis.from_url(url, decode_responses=True))
|
cache = cls(aioredis.from_url(url, decode_responses=True))
|
||||||
|
cache._owns_client = True
|
||||||
|
return cache
|
||||||
|
|
||||||
async def get(self, key: str) -> str | None:
|
async def get(self, key: str) -> str | None:
|
||||||
return await self._client.get(key)
|
return await self._client.get(key)
|
||||||
@@ -41,4 +47,7 @@ class RedisCache:
|
|||||||
await self._client.set(key, value, ex=ttl_s)
|
await self._client.set(key, value, ex=ttl_s)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
await self._client.aclose()
|
"""幂等释放自建客户端;注入的客户端归注入方管理。"""
|
||||||
|
if self._owns_client:
|
||||||
|
self._owns_client = False
|
||||||
|
await self._client.aclose()
|
||||||
|
|||||||
+201
-37
@@ -9,23 +9,26 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from polygateway.backends.memory.breaker import InMemoryGate
|
from polygateway.backends.memory.breaker import InMemoryGate
|
||||||
from polygateway.backends.memory.cache import InMemoryCache
|
from polygateway.backends.memory.cache import InMemoryCache
|
||||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
from polygateway.config import GatewaySettings
|
from polygateway.config import GatewaySettings
|
||||||
|
from polygateway.errors import PolyGatewayError
|
||||||
from polygateway.middleware.base import compose
|
from polygateway.middleware.base import compose
|
||||||
from polygateway.middleware.cache import CacheMW
|
from polygateway.middleware.cache import CacheMW
|
||||||
from polygateway.middleware.retry import RetryMW
|
from polygateway.middleware.retry import RetryMW
|
||||||
from polygateway.middleware.structured import StructuredMW
|
from polygateway.middleware.structured import StructuredMW
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW, emit_terminal_once
|
||||||
|
from polygateway.ports import TelemetryStatusProvider
|
||||||
from polygateway.pricing import PricingTable
|
from polygateway.pricing import PricingTable
|
||||||
from polygateway.providers import get_capability, get_provider, resolve_thinking
|
from polygateway.providers import get_provider
|
||||||
from polygateway.sources import (
|
from polygateway.sources import (
|
||||||
AdaptivePacer,
|
AdaptivePacer,
|
||||||
HealthAwareSelector,
|
HealthAwareSelector,
|
||||||
@@ -33,10 +36,20 @@ from polygateway.sources import (
|
|||||||
RoundRobinSelector,
|
RoundRobinSelector,
|
||||||
SourceCooldownMemo,
|
SourceCooldownMemo,
|
||||||
)
|
)
|
||||||
|
from polygateway.thinking import (
|
||||||
|
effective_effort,
|
||||||
|
get_capability,
|
||||||
|
resolve_thinking,
|
||||||
|
validate_thinking_raw,
|
||||||
|
)
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
|
Effort,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
TelemetryStatus,
|
||||||
|
_CallContext,
|
||||||
|
coerce_effort,
|
||||||
validate_caller_dimensions,
|
validate_caller_dimensions,
|
||||||
validate_request_overlay,
|
validate_request_overlay,
|
||||||
)
|
)
|
||||||
@@ -56,15 +69,14 @@ if TYPE_CHECKING:
|
|||||||
TelemetryRecorder,
|
TelemetryRecorder,
|
||||||
Transport,
|
Transport,
|
||||||
)
|
)
|
||||||
from polygateway.providers import ProviderProfile, ThinkingCapability
|
from polygateway.providers import ProviderProfile
|
||||||
|
from polygateway.thinking import ThinkingCapability
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
|
||||||
|
|
||||||
|
|
||||||
def _guard_thinking(
|
def _guard_thinking(
|
||||||
sources: list[SourceConfig],
|
sources: list[SourceConfig],
|
||||||
@@ -78,23 +90,44 @@ def _guard_thinking(
|
|||||||
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
|
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
|
||||||
"""
|
"""
|
||||||
for source, profile in zip(sources, profiles, strict=True):
|
for source, profile in zip(sources, profiles, strict=True):
|
||||||
|
effort = effective_effort(
|
||||||
|
request_effort=None,
|
||||||
|
source_effort=source.reasoning_effort,
|
||||||
|
enable_thinking=source.enable_thinking,
|
||||||
|
)
|
||||||
resolve_thinking(
|
resolve_thinking(
|
||||||
profile,
|
profile,
|
||||||
get_capability(source.model, table=capabilities),
|
get_capability(source.model, table=capabilities),
|
||||||
source.enable_thinking,
|
effort,
|
||||||
model=source.model,
|
model=source.model,
|
||||||
|
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
|
||||||
|
# 判死,而它在运行期本来是能映射到最近档跑起来的
|
||||||
|
fallback=source.effort_fallback,
|
||||||
|
)
|
||||||
|
validate_thinking_raw(
|
||||||
|
source.extra_body,
|
||||||
|
effort=effort,
|
||||||
|
wire=profile.thinking,
|
||||||
|
origin=f"源 {source.name} extra_body",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fingerprint_mark(source: SourceConfig) -> str:
|
def _fingerprint_mark(source: SourceConfig) -> str:
|
||||||
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。
|
"""单源的指纹标记;`enable_thinking` 与 `reasoning_effort` 仅在**表态时**追加。
|
||||||
|
|
||||||
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
|
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
|
||||||
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。
|
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。
|
||||||
|
|
||||||
|
`reasoning_effort`(issue #20)与 `enable_thinking` 同规则、同理由: 它一旦真正
|
||||||
|
改变请求体,"把源级档位从 low 改成 max 后重启"就会读到 low 档时缓存的旧响应。
|
||||||
|
两者取值域不相交(`"none"`/`"low"`… vs `true`/`false`),故追加进同一个列表也
|
||||||
|
不会把两种写法摘要成同一身份。
|
||||||
"""
|
"""
|
||||||
parts: list[Any] = [source.model, dict(source.extra_body)]
|
parts: list[Any] = [source.model, dict(source.extra_body)]
|
||||||
if source.enable_thinking is not None:
|
if source.enable_thinking is not None:
|
||||||
parts.append(source.enable_thinking)
|
parts.append(source.enable_thinking)
|
||||||
|
if source.reasoning_effort is not None:
|
||||||
|
parts.append(source.reasoning_effort)
|
||||||
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
|
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,16 +135,25 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
|
|||||||
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
|
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
|
||||||
|
|
||||||
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
|
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
|
||||||
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理
|
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking`(issue #5)与
|
||||||
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时
|
源级 `reasoning_effort`(issue #20)同理: 它们一旦真正改变请求体,"关掉推理后
|
||||||
缓存的旧响应。全源两者皆未表态时字面量与历史实现逐字相同,不触发存量
|
重启"就会读到开着推理时缓存的旧响应。全源三者皆未表态时字面量与历史实现逐字
|
||||||
缓存冷启动。
|
相同,不触发存量缓存冷启动。
|
||||||
|
|
||||||
|
注意本指纹是**装配期**算出的**集合级**身份,覆盖不到逐次调用变化的请求级档位
|
||||||
|
——后者由 `build_cache_key` 的 `reasoning_effort` 参数单独承担(ARCH §7.5)。
|
||||||
"""
|
"""
|
||||||
fingerprint = ",".join(sorted({s.model for s in sources}))
|
fingerprint = ",".join(sorted({s.model for s in sources}))
|
||||||
# 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope
|
# 按 (model, extra_body[, enable_thinking][, reasoning_effort]) 而非源名摘要:
|
||||||
# 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
|
# 语义是"本 scope 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动。
|
||||||
|
# 过滤条件必须与 `_fingerprint_mark` 追加的字段逐项对齐: 漏掉一项,只配了该项
|
||||||
|
# 的源根本进不了 marks,`_fingerprint_mark` 改了也白改
|
||||||
marks = sorted(
|
marks = sorted(
|
||||||
{_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None}
|
{
|
||||||
|
_fingerprint_mark(s)
|
||||||
|
for s in sources
|
||||||
|
if s.extra_body or s.enable_thinking is not None or s.reasoning_effort is not None
|
||||||
|
}
|
||||||
)
|
)
|
||||||
if marks:
|
if marks:
|
||||||
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
|
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
|
||||||
@@ -119,6 +161,55 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
|
|||||||
return fingerprint
|
return fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
async def _aclose_component(component: object | None) -> None:
|
||||||
|
"""关闭一个**自建**组件: 优先 `aclose`,退到同步 `close`,两者皆无则跳过。
|
||||||
|
|
||||||
|
退到 `close` 是给 SQLiteRecorder 的(它只有同步收尾);内存后端两者皆无,
|
||||||
|
探测后静默跳过。三个 client 曾各持一份逐字复制的探测代码,收敛为一处是
|
||||||
|
所有权纪律能被维持的前提——复制即是下一个 bug 的种子(设计 §3.4)。
|
||||||
|
"""
|
||||||
|
if component is None:
|
||||||
|
return
|
||||||
|
aclose = getattr(component, "aclose", None)
|
||||||
|
if aclose is not None:
|
||||||
|
await aclose()
|
||||||
|
return
|
||||||
|
close = getattr(component, "close", None)
|
||||||
|
if close is not None:
|
||||||
|
close()
|
||||||
|
|
||||||
|
|
||||||
|
def _telemetry_status_of(telemetry: TelemetryRecorder | None) -> TelemetryStatus | None:
|
||||||
|
"""三个 client 共用的状态取值点: 不提供状态的 recorder 一律返回 None。
|
||||||
|
|
||||||
|
判定写成 `isinstance(可选端口)` 而不是裸 `getattr`: 两者运行时都是结构检查
|
||||||
|
(`@runtime_checkable` 按属性存在性判定),差别在**契约有没有名字**——端口是
|
||||||
|
写进 `ports.py` 的公开承诺,下游可以照着实现;散落的 `getattr` 不是,而
|
||||||
|
`aclose` 当年正是被复制成三份鸭子类型探测才漂移出越权关闭(设计 §3.3/§3.4)。
|
||||||
|
"""
|
||||||
|
if isinstance(telemetry, TelemetryStatusProvider):
|
||||||
|
return telemetry.telemetry_status
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_owned_components(
|
||||||
|
client: Any,
|
||||||
|
*,
|
||||||
|
limiter: RateLimiter | None,
|
||||||
|
breaker: ProviderGate | None,
|
||||||
|
telemetry: TelemetryRecorder | None,
|
||||||
|
) -> None:
|
||||||
|
"""工厂置位所有权(三个 client 共用): 传进来的是 None,就说明这一件是工厂自建的。
|
||||||
|
|
||||||
|
与 `RedisLimiter.from_url` 逐字同款——私有属性由工厂标记,公共 API 面不变。
|
||||||
|
transport 单列: 三处工厂都没有 transport 注入入口,它恒是自建的。
|
||||||
|
"""
|
||||||
|
client._owns_transport = True
|
||||||
|
client._owns_limiter = limiter is None
|
||||||
|
client._owns_breaker = breaker is None
|
||||||
|
client._owns_telemetry = telemetry is None
|
||||||
|
|
||||||
|
|
||||||
class GatewayClient:
|
class GatewayClient:
|
||||||
"""统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。"""
|
"""统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。"""
|
||||||
|
|
||||||
@@ -149,7 +240,7 @@ class GatewayClient:
|
|||||||
rng: Any = random.random,
|
rng: Any = random.random,
|
||||||
) -> None:
|
) -> None:
|
||||||
emitter = (
|
emitter = (
|
||||||
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap)
|
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
|
||||||
if telemetry is not None
|
if telemetry is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -201,11 +292,36 @@ class GatewayClient:
|
|||||||
self._structured_available = structured_strategy is not None
|
self._structured_available = structured_strategy is not None
|
||||||
self._terminal = terminal # 内部引用: 装配自省/测试用
|
self._terminal = terminal # 内部引用: 装配自省/测试用
|
||||||
self._handler = compose(middlewares, terminal)
|
self._handler = compose(middlewares, terminal)
|
||||||
|
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
|
||||||
|
self._now = now
|
||||||
|
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
|
||||||
|
self._emitter = emitter
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._telemetry = telemetry
|
self._telemetry = telemetry
|
||||||
self._cache = cache
|
self._cache = cache
|
||||||
|
# limiter/breaker 交给 RetryMW 之后仍须自持引用,否则 aclose 触达不到
|
||||||
|
# 自建的 redis 客户端(设计 §3.4 记录的现存泄漏)
|
||||||
|
self._limiter_backend = limiter
|
||||||
|
self._breaker_backend = breaker
|
||||||
|
# 所有权默认"不拥有": `__init__` 是全量注入路径,经它传入的一切都是
|
||||||
|
# 外部资源,关掉别人的连接会弄死共享同一后端的其他 client(ARCH §7.7 R5)。
|
||||||
|
# 只有工厂在真正自建时才置 True
|
||||||
|
self._owns_transport = False
|
||||||
|
self._owns_telemetry = False
|
||||||
|
self._owns_cache = False
|
||||||
|
self._owns_limiter = False
|
||||||
|
self._owns_breaker = False
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_status(self) -> TelemetryStatus | None:
|
||||||
|
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
|
||||||
|
|
||||||
|
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
|
||||||
|
的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。
|
||||||
|
"""
|
||||||
|
return _telemetry_status_of(self._telemetry)
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@@ -217,6 +333,7 @@ class GatewayClient:
|
|||||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||||
stream: bool = True,
|
stream: bool = True,
|
||||||
overlay: Mapping[str, Any] | None = None,
|
overlay: Mapping[str, Any] | None = None,
|
||||||
|
reasoning_effort: Effort | str | None = None,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
meta: Mapping[str, Any] | None = None,
|
meta: Mapping[str, Any] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@@ -226,6 +343,11 @@ class GatewayClient:
|
|||||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||||
参数不影响既有调用点(issue #4)。
|
参数不影响既有调用点(issue #4)。
|
||||||
|
|
||||||
|
`reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT`
|
||||||
|
与 `ENABLE_THINKING`(设计 §4.2)。`None` 是**不表态**(随源级配置),与
|
||||||
|
`Effort.NONE`("要求不推理")严格区分。裸字符串(`"low"`)也收,在此归一成
|
||||||
|
`Effort`,非法值当场 `ValueError`——与 `SourceConfig` 那条装配路同口径。
|
||||||
|
|
||||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
||||||
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
||||||
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
||||||
@@ -245,6 +367,18 @@ class GatewayClient:
|
|||||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||||
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
|
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
|
||||||
)
|
)
|
||||||
|
# 同样必须在洋葱之外归一: 档位一路要被 `is Effort.NONE` 身份比较,裸字符串
|
||||||
|
# 进去会在 transport 的错误路径上抛 `AttributeError`——那不属错误四分类,
|
||||||
|
# 会穿透 `except ThinkingUnsupportedError` 与 RetryMW 的分类捕获(库铁律
|
||||||
|
# 「错误分类驱动」)。归一失败是调用方编程错误,抛裸 ValueError 不进洋葱
|
||||||
|
effort = (
|
||||||
|
None
|
||||||
|
if reasoning_effort is None
|
||||||
|
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
|
||||||
|
)
|
||||||
|
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
|
||||||
|
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
|
||||||
|
context = _CallContext(now=self._now)
|
||||||
request = ChatRequest(
|
request = ChatRequest(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -255,29 +389,53 @@ class GatewayClient:
|
|||||||
stream=stream,
|
stream=stream,
|
||||||
overlay=sampling,
|
overlay=sampling,
|
||||||
sampling=sampling,
|
sampling=sampling,
|
||||||
|
reasoning_effort=effort,
|
||||||
tenant_id=dimension_tenant_id,
|
tenant_id=dimension_tenant_id,
|
||||||
meta=dimensions,
|
meta=dimensions,
|
||||||
|
call_context=context,
|
||||||
)
|
)
|
||||||
return await self._handler(request)
|
try:
|
||||||
|
response = await self._handler(request)
|
||||||
|
except PolyGatewayError as exc:
|
||||||
|
# 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3),
|
||||||
|
# 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述
|
||||||
|
# 的不是同一件事(尝试 vs 逻辑终态),由 `event_kind` 区分
|
||||||
|
await emit_terminal_once(
|
||||||
|
self._emitter, request=request, context=context, error=exc, operation="chat"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# 尽力而为且**取消优先**: 不 shield、不开后台任务;写入那一次 await 上
|
||||||
|
# 再被取消则 `CancelledError` 照常传播(与 TelemetryMW 历史行为同款)
|
||||||
|
await emit_terminal_once(
|
||||||
|
self._emitter,
|
||||||
|
request=request,
|
||||||
|
context=context,
|
||||||
|
error="cancelled",
|
||||||
|
operation="chat",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
# 快照在返回前冻结: 故它含缓存命中路径与已完成的内联遥测耗时
|
||||||
|
return dataclasses.replace(response, call_stats=context.snapshot())
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""幂等释放: transport 连接池、遥测连接、缓存客户端。"""
|
"""幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。
|
||||||
|
|
||||||
|
注入的组件一律不碰——它们可能被别的 client 共享,关掉即越权。
|
||||||
|
"""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
transport_aclose = getattr(self._transport, "aclose", None)
|
if self._owns_transport:
|
||||||
if transport_aclose is not None:
|
await _aclose_component(self._transport)
|
||||||
await transport_aclose()
|
if self._owns_telemetry:
|
||||||
telemetry_aclose = getattr(self._telemetry, "aclose", None)
|
await _aclose_component(self._telemetry)
|
||||||
if telemetry_aclose is not None:
|
if self._owns_cache:
|
||||||
await telemetry_aclose() # Postgres 等异步后端
|
await _aclose_component(self._cache)
|
||||||
else:
|
if self._owns_limiter:
|
||||||
telemetry_close = getattr(self._telemetry, "close", None)
|
await _aclose_component(self._limiter_backend)
|
||||||
if telemetry_close is not None:
|
if self._owns_breaker:
|
||||||
telemetry_close()
|
await _aclose_component(self._breaker_backend)
|
||||||
cache_aclose = getattr(self._cache, "aclose", None)
|
|
||||||
if cache_aclose is not None:
|
|
||||||
await cache_aclose()
|
|
||||||
|
|
||||||
async def __aenter__(self) -> GatewayClient:
|
async def __aenter__(self) -> GatewayClient:
|
||||||
return self
|
return self
|
||||||
@@ -305,12 +463,12 @@ class GatewayClient:
|
|||||||
profiles = [get_provider(s.provider, registry=registry) for s in sources]
|
profiles = [get_provider(s.provider, registry=registry) for s in sources]
|
||||||
_guard_thinking(sources, profiles, capabilities)
|
_guard_thinking(sources, profiles, capabilities)
|
||||||
strategy, escalation = _build_structured(profiles)
|
strategy, escalation = _build_structured(profiles)
|
||||||
return cls(
|
client = cls(
|
||||||
scope=settings.scope,
|
scope=settings.scope,
|
||||||
sources=sources,
|
sources=sources,
|
||||||
selector=_build_selector(settings.selector, rng=rng),
|
selector=_build_selector(settings.selector, rng=rng),
|
||||||
limiter=limiter or _build_limiter(settings, sources),
|
limiter=limiter if limiter is not None else _build_limiter(settings, sources),
|
||||||
breaker=breaker or _build_breaker(settings),
|
breaker=breaker if breaker is not None else _build_breaker(settings),
|
||||||
transport=OpenAICompatTransport(registry=registry, capabilities=capabilities),
|
transport=OpenAICompatTransport(registry=registry, capabilities=capabilities),
|
||||||
retry=settings.retry,
|
retry=settings.retry,
|
||||||
backpressure=settings.backpressure,
|
backpressure=settings.backpressure,
|
||||||
@@ -328,6 +486,9 @@ class GatewayClient:
|
|||||||
structured_escalation=escalation,
|
structured_escalation=escalation,
|
||||||
structured_max_retries=settings.structured_max_retries,
|
structured_max_retries=settings.structured_max_retries,
|
||||||
)
|
)
|
||||||
|
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||||
|
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
|
||||||
|
return client
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
@@ -410,7 +571,10 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
|||||||
|
|
||||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
||||||
return PostgresRecorder(
|
return PostgresRecorder(
|
||||||
settings.telemetry_pg_dsn, auto_migrate=settings.telemetry_auto_migrate
|
settings.telemetry_pg_dsn,
|
||||||
|
auto_migrate=settings.telemetry_auto_migrate,
|
||||||
|
pool_max=settings.telemetry_pg_pool_max,
|
||||||
|
write_timeout_s=settings.telemetry_pg_write_timeout_s,
|
||||||
)
|
)
|
||||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||||
|
|
||||||
@@ -442,7 +606,7 @@ def _build_structured(
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> list[_T]:
|
async def gather_bounded[T](aws: Iterable[Awaitable[T]], *, concurrency: int) -> list[T]:
|
||||||
"""有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。
|
"""有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。
|
||||||
|
|
||||||
语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。
|
语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。
|
||||||
@@ -451,7 +615,7 @@ async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> l
|
|||||||
raise ValueError("concurrency 必须 ≥ 1")
|
raise ValueError("concurrency 必须 ≥ 1")
|
||||||
sem = asyncio.Semaphore(concurrency)
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
|
||||||
async def _run(aw: Awaitable[_T]) -> _T:
|
async def _run(aw: Awaitable[T]) -> T:
|
||||||
async with sem:
|
async with sem:
|
||||||
return await aw
|
return await aw
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from polygateway.types import (
|
|||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
coerce_effort,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -43,6 +44,11 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
|||||||
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
|
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
|
||||||
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
|
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
|
||||||
"ENABLE_THINKING": ("enable_thinking", "bool"),
|
"ENABLE_THINKING": ("enable_thinking", "bool"),
|
||||||
|
# 档位两键(issue #20);值域校验分工: 档位在此(解析即校验,报错点得出 env 键名),
|
||||||
|
# fallback 交给 SourceConfig 构造期(那道同时覆盖构造函数注入与 dataclasses.replace)
|
||||||
|
"REASONING_EFFORT": ("reasoning_effort", "effort"),
|
||||||
|
# 归一化(strip+lower)在 SourceConfig 构造期,与值域校验同处一点,故这里是裸 "str"
|
||||||
|
"EFFORT_FALLBACK": ("effort_fallback", "str"),
|
||||||
"MISSING_DONE": ("missing_done", "str"),
|
"MISSING_DONE": ("missing_done", "str"),
|
||||||
"TRUST_ENV": ("trust_env", "bool"),
|
"TRUST_ENV": ("trust_env", "bool"),
|
||||||
"EXTRA_BODY": ("extra_body", "json"),
|
"EXTRA_BODY": ("extra_body", "json"),
|
||||||
@@ -63,6 +69,15 @@ _SCHEMA_MODES = frozenset({"auto", "manual"})
|
|||||||
_SCHEMA_MODE_KEY = "PGW_TELEMETRY_SCHEMA_MODE"
|
_SCHEMA_MODE_KEY = "PGW_TELEMETRY_SCHEMA_MODE"
|
||||||
# 遥测正文字符上限(issue #12);二态键,未设 = 不截断
|
# 遥测正文字符上限(issue #12);二态键,未设 = 不截断
|
||||||
_TEXT_CAP_KEY = "PGW_TELEMETRY_TEXT_CAP"
|
_TEXT_CAP_KEY = "PGW_TELEMETRY_TEXT_CAP"
|
||||||
|
# 遥测池的资源占用与写入预算(issue #15);缺省只写在这里,recorder 侧是必填参数
|
||||||
|
_POOL_MAX_KEY = "PGW_TELEMETRY_PG_POOL_MAX"
|
||||||
|
_WRITE_TIMEOUT_KEY = "PGW_TELEMETRY_PG_WRITE_TIMEOUT_S"
|
||||||
|
# 4 条实测约 15.6 行/秒(跨内网 RTT ≈ 123ms 的实验室 PG,50 行并发批耗时 3.2s)。
|
||||||
|
# **不要按 `pool_max / RTT` 折算**——那会乐观一倍(一次 INSERT 的往返比一次
|
||||||
|
# SELECT 1 重)。够单 client 十余并发;闲时占 0 条
|
||||||
|
_DEFAULT_PG_POOL_MAX = 4
|
||||||
|
# 实测稳态写入 123ms、首次含建连 513ms;5s 宽松且**有界**
|
||||||
|
_DEFAULT_PG_WRITE_TIMEOUT_S = 5.0
|
||||||
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
|
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
|
||||||
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
||||||
_DEFAULT_STALL_WINDOW_S = 300.0
|
_DEFAULT_STALL_WINDOW_S = 300.0
|
||||||
@@ -84,6 +99,10 @@ def _cast(raw: str, kind: str, key: str) -> object:
|
|||||||
if lowered in ("0", "false", "no", "off"):
|
if lowered in ("0", "false", "no", "off"):
|
||||||
return False
|
return False
|
||||||
raise ValueError(f"非法布尔值: {raw!r}")
|
raise ValueError(f"非法布尔值: {raw!r}")
|
||||||
|
if kind == "effort":
|
||||||
|
# 归一化只有一份实现(`types.coerce_effort`),env 路与两条装配路同口径;
|
||||||
|
# origin 传空串是因为 env 键名由下面统一的"配置 X 解析失败"补上
|
||||||
|
return coerce_effort(raw, origin="")
|
||||||
if kind == "json":
|
if kind == "json":
|
||||||
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
|
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
@@ -152,6 +171,13 @@ class GatewaySettings:
|
|||||||
# 既有下游正依赖这一行为。值域(> 0)由 `_validate_telemetry` 把关,直接构造、
|
# 既有下游正依赖这一行为。值域(> 0)由 `_validate_telemetry` 把关,直接构造、
|
||||||
# `dataclasses.replace` 与 env 三条路一并覆盖
|
# `dataclasses.replace` 与 env 三条路一并覆盖
|
||||||
telemetry_text_cap: int | None
|
telemetry_text_cap: int | None
|
||||||
|
# 遥测池对外声明的资源占用上限与整次写入的硬预算(issue #15)。库内每一处外部
|
||||||
|
# 资源都按需建连,唯独遥测池此前预占 10 条(asyncpg 默认 `min_size`),共享实例
|
||||||
|
# 余量紧张时先倒下的必然是它。这两个字段是库对自己占用的**显式表态**:
|
||||||
|
# 稳态并发上限 = `pool_max`,闲时 0 条;单次写入(准备+取连接+执行)≤ 预算。
|
||||||
|
# 值域由 `_validate_telemetry` 把关,直接构造、`dataclasses.replace` 与 env 三条路一致
|
||||||
|
telemetry_pg_pool_max: int
|
||||||
|
telemetry_pg_write_timeout_s: float
|
||||||
redis_url: str | None
|
redis_url: str | None
|
||||||
pricing_path: str | None
|
pricing_path: str | None
|
||||||
structured_max_retries: int
|
structured_max_retries: int
|
||||||
@@ -248,6 +274,7 @@ class GatewaySettings:
|
|||||||
f"telemetry_text_cap({_TEXT_CAP_KEY})必须 > 0: {self.telemetry_text_cap};"
|
f"telemetry_text_cap({_TEXT_CAP_KEY})必须 > 0: {self.telemetry_text_cap};"
|
||||||
"不截断请不设该键(None),0 只会让每条正文退化成一个省略标记"
|
"不截断请不设该键(None),0 只会让每条正文退化成一个省略标记"
|
||||||
)
|
)
|
||||||
|
self._validate_telemetry_pool()
|
||||||
if self.telemetry_backend == "none" and self.telemetry_auto_migrate:
|
if self.telemetry_backend == "none" and self.telemetry_auto_migrate:
|
||||||
object.__setattr__(self, "telemetry_auto_migrate", False)
|
object.__setattr__(self, "telemetry_auto_migrate", False)
|
||||||
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
|
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
|
||||||
@@ -266,6 +293,24 @@ class GatewaySettings:
|
|||||||
)
|
)
|
||||||
object.__setattr__(self, "telemetry_pg_dsn", stripped)
|
object.__setattr__(self, "telemetry_pg_dsn", stripped)
|
||||||
|
|
||||||
|
def _validate_telemetry_pool(self) -> None:
|
||||||
|
"""遥测池两个标量的值域(issue #15);与 backend 无关,三条装配路一并覆盖。
|
||||||
|
|
||||||
|
不按 `telemetry_backend == "postgres"` 才校验: 值域错就是错,提前拦住
|
||||||
|
比等到有人把 backend 切成 postgres 时才炸更接近"缺失关键配置直接报错"。
|
||||||
|
报错文本同时点字段名与 env 键名(两类调用方各看得懂自己那套)。
|
||||||
|
"""
|
||||||
|
if self.telemetry_pg_pool_max < 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"telemetry_pg_pool_max({_POOL_MAX_KEY})必须 >= 1: "
|
||||||
|
f"{self.telemetry_pg_pool_max};0 条上限等于永远取不到连接,遥测会全灭"
|
||||||
|
)
|
||||||
|
if self.telemetry_pg_write_timeout_s <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"telemetry_pg_write_timeout_s({_WRITE_TIMEOUT_KEY})必须 > 0: "
|
||||||
|
f"{self.telemetry_pg_write_timeout_s};预算 0 会让每一行当场超预算被丢弃"
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_lease(self) -> None:
|
def _validate_lease(self) -> None:
|
||||||
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
|
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
|
||||||
slowest = max(s.timeout_s for s in self.sources)
|
slowest = max(s.timeout_s for s in self.sources)
|
||||||
@@ -486,6 +531,8 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
|||||||
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
|
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
|
||||||
"telemetry_auto_migrate": auto_migrate,
|
"telemetry_auto_migrate": auto_migrate,
|
||||||
"telemetry_text_cap": _load_text_cap(env),
|
"telemetry_text_cap": _load_text_cap(env),
|
||||||
|
"telemetry_pg_pool_max": _load_pool_max(env),
|
||||||
|
"telemetry_pg_write_timeout_s": _load_write_timeout(env),
|
||||||
"redis_url": redis_url,
|
"redis_url": redis_url,
|
||||||
"pricing_path": env.get("PGW_PRICING_PATH") or None,
|
"pricing_path": env.get("PGW_PRICING_PATH") or None,
|
||||||
"structured_max_retries": _load_structured_retries(env),
|
"structured_max_retries": _load_structured_retries(env),
|
||||||
@@ -543,6 +590,43 @@ def _load_text_cap(env: Mapping[str, str]) -> int | None:
|
|||||||
return int(_cast(found[1], "int", found[0]))
|
return int(_cast(found[1], "int", found[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pool_max(env: Mapping[str, str]) -> int:
|
||||||
|
"""读 `PGW_TELEMETRY_PG_POOL_MAX`(issue #15);未设即缺省 4。
|
||||||
|
|
||||||
|
与 `_load_text_cap` 同为二态键,只是"未设"落到一个具体缺省而非 None:
|
||||||
|
池上限没有"不设上限"这一档——不表态就是继承第三方默认值,而那正是本 issue
|
||||||
|
的病灶。值域(>= 1)留给构造期守卫,它同时覆盖直接构造与 `dataclasses.replace`。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: 已合并的环境映射。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
遥测池允许的最大连接数。
|
||||||
|
"""
|
||||||
|
found = _first(env, _POOL_MAX_KEY)
|
||||||
|
if found is None:
|
||||||
|
return _DEFAULT_PG_POOL_MAX
|
||||||
|
return int(_cast(found[1], "int", found[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_write_timeout(env: Mapping[str, str]) -> float:
|
||||||
|
"""读 `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(issue #15);未设即缺省 5.0 秒。
|
||||||
|
|
||||||
|
这个值同时是 connect、acquire 与整次写入的上界: 遥测是业务路径上的内联
|
||||||
|
await,"不设预算"不是一个允许存在的档位(铁律"丢一条 < 拖垮调用")。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: 已合并的环境映射。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
单次遥测写入的硬预算(秒)。
|
||||||
|
"""
|
||||||
|
found = _first(env, _WRITE_TIMEOUT_KEY)
|
||||||
|
if found is None:
|
||||||
|
return _DEFAULT_PG_WRITE_TIMEOUT_S
|
||||||
|
return float(_cast(found[1], "float", found[0]))
|
||||||
|
|
||||||
|
|
||||||
def _strip_dsn_driver(dsn: str) -> str:
|
def _strip_dsn_driver(dsn: str) -> str:
|
||||||
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
|
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
|
||||||
scheme, sep, rest = dsn.partition("://")
|
scheme, sep, rest = dsn.partition("://")
|
||||||
|
|||||||
+137
-22
@@ -16,6 +16,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
@@ -25,6 +26,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.client import _aclose_component, _telemetry_status_of
|
||||||
from polygateway.config import EmbeddingSettings
|
from polygateway.config import EmbeddingSettings
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
@@ -40,11 +42,13 @@ from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
|||||||
from polygateway.middleware.breaker import BreakerGate
|
from polygateway.middleware.breaker import BreakerGate
|
||||||
from polygateway.middleware.ratelimit import QuotaGate
|
from polygateway.middleware.ratelimit import QuotaGate
|
||||||
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
EmbeddingResponse,
|
EmbeddingResponse,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
TelemetryStatus,
|
||||||
|
_CallContext,
|
||||||
strip_unsupported_extra_body,
|
strip_unsupported_extra_body,
|
||||||
validate_caller_dimensions,
|
validate_caller_dimensions,
|
||||||
)
|
)
|
||||||
@@ -125,9 +129,20 @@ class EmbeddingClient:
|
|||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._retry = retry
|
self._retry = retry
|
||||||
self._emitter = (
|
self._emitter = (
|
||||||
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None
|
TelemetryEmitter(telemetry, scope=self._scope, pricing=pricing, text_cap=text_cap)
|
||||||
|
if telemetry
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
self._telemetry = telemetry
|
self._telemetry = telemetry
|
||||||
|
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
|
||||||
|
# 引用才关得到自建的 redis 客户端(设计 §3.4)
|
||||||
|
self._limiter_backend = limiter
|
||||||
|
self._breaker_backend = breaker
|
||||||
|
# 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True
|
||||||
|
self._owns_transport = False
|
||||||
|
self._owns_telemetry = False
|
||||||
|
self._owns_limiter = False
|
||||||
|
self._owns_breaker = False
|
||||||
self._pricing = pricing
|
self._pricing = pricing
|
||||||
self._batch_size = batch_size
|
self._batch_size = batch_size
|
||||||
self._normalize = normalize
|
self._normalize = normalize
|
||||||
@@ -173,7 +188,11 @@ class EmbeddingClient:
|
|||||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||||
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
|
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
|
||||||
)
|
)
|
||||||
|
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
|
||||||
|
context = _CallContext(now=self._now)
|
||||||
if not texts:
|
if not texts:
|
||||||
|
# 合法零尝试: 返回真实统计(attempts=0),且**不写任何遥测行**
|
||||||
|
# ——与 cache_hit 不同,不要按"遥测必录"推断它有台账行(设计 §3 M2)
|
||||||
return EmbeddingResponse(
|
return EmbeddingResponse(
|
||||||
vectors=[],
|
vectors=[],
|
||||||
dim=0,
|
dim=0,
|
||||||
@@ -184,7 +203,40 @@ class EmbeddingClient:
|
|||||||
latency_ms=0,
|
latency_ms=0,
|
||||||
call_id=str(uuid.uuid4()),
|
call_id=str(uuid.uuid4()),
|
||||||
source_name="",
|
source_name="",
|
||||||
|
call_stats=context.snapshot(),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
return await self._embed_all(
|
||||||
|
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
|
||||||
|
)
|
||||||
|
except PolyGatewayError as exc:
|
||||||
|
await self._emit_terminal(
|
||||||
|
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context, exc
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# 三条链路同一口径尽力写一条(允许 0 条);取消优先,不 shield
|
||||||
|
await self._emit_terminal(
|
||||||
|
texts,
|
||||||
|
session_id,
|
||||||
|
parent_call_id,
|
||||||
|
dimension_tenant_id,
|
||||||
|
dimensions,
|
||||||
|
context,
|
||||||
|
"cancelled",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _embed_all(
|
||||||
|
self,
|
||||||
|
texts: list[str],
|
||||||
|
session_id: str | None,
|
||||||
|
parent_call_id: str | None,
|
||||||
|
tenant_id: str | None,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
|
) -> EmbeddingResponse:
|
||||||
|
"""切批串行执行并合并;无源的 raise 必须在本方法内——否则无源终态行写不出。"""
|
||||||
if not self._sources:
|
if not self._sources:
|
||||||
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
|
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
|
||||||
outcomes = []
|
outcomes = []
|
||||||
@@ -194,11 +246,49 @@ class EmbeddingClient:
|
|||||||
texts[start : start + self._batch_size],
|
texts[start : start + self._batch_size],
|
||||||
session_id,
|
session_id,
|
||||||
parent_call_id,
|
parent_call_id,
|
||||||
dimension_tenant_id,
|
tenant_id,
|
||||||
dimensions,
|
meta,
|
||||||
|
context,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return self._merge(outcomes)
|
# 全批共享同一上下文,故分批是实现细节而非 N 次独立逻辑调用
|
||||||
|
return dataclasses.replace(self._merge(outcomes), call_stats=context.snapshot())
|
||||||
|
|
||||||
|
async def _emit_terminal(
|
||||||
|
self,
|
||||||
|
texts: list[str],
|
||||||
|
session_id: str | None,
|
||||||
|
parent_call_id: str | None,
|
||||||
|
tenant_id: str | None,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
|
error: PolyGatewayError | str,
|
||||||
|
) -> None:
|
||||||
|
"""终态行的请求摘要(设计 §6 M4): 计数占位 + 第一批截断文本。
|
||||||
|
|
||||||
|
描述的是**本次调用的整体输入**但不扩大单行正文预算: 失败批的具体文本由同
|
||||||
|
`logical_call_id` 的 attempt 行给出,终态行不保存全量原输入。
|
||||||
|
"""
|
||||||
|
batches = math.ceil(len(texts) / self._batch_size)
|
||||||
|
messages = [{"role": "user", "content": f"<embed texts={len(texts)} batches={batches}>"}]
|
||||||
|
# 与逐批行同款构造(至多 `batch_size` 条、每条 200 字符)
|
||||||
|
messages += [
|
||||||
|
{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in texts[: self._batch_size]
|
||||||
|
]
|
||||||
|
await emit_terminal_once(
|
||||||
|
self._emitter,
|
||||||
|
request=ChatRequest(
|
||||||
|
messages=messages,
|
||||||
|
session_id=session_id,
|
||||||
|
parent_call_id=parent_call_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
meta=meta,
|
||||||
|
call_context=context,
|
||||||
|
),
|
||||||
|
context=context,
|
||||||
|
error=error,
|
||||||
|
operation="embed",
|
||||||
|
)
|
||||||
|
|
||||||
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
|
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
|
||||||
|
|
||||||
@@ -209,6 +299,7 @@ class EmbeddingClient:
|
|||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
) -> _BatchOutcome:
|
) -> _BatchOutcome:
|
||||||
fails = 0
|
fails = 0
|
||||||
reasons: dict[str, str] = {}
|
reasons: dict[str, str] = {}
|
||||||
@@ -221,7 +312,7 @@ class EmbeddingClient:
|
|||||||
continue
|
continue
|
||||||
async with clock.attempting():
|
async with clock.attempting():
|
||||||
outcome = await self._attempt(
|
outcome = await self._attempt(
|
||||||
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
|
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta, context
|
||||||
)
|
)
|
||||||
if isinstance(outcome, _BatchOutcome):
|
if isinstance(outcome, _BatchOutcome):
|
||||||
return outcome
|
return outcome
|
||||||
@@ -247,10 +338,13 @@ class EmbeddingClient:
|
|||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
) -> _BatchOutcome | _FailedBatch:
|
) -> _BatchOutcome | _FailedBatch:
|
||||||
call_id = str(uuid.uuid4())
|
call_id = str(uuid.uuid4())
|
||||||
started = self._now()
|
started = self._now()
|
||||||
actual = 0
|
actual = 0
|
||||||
|
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
|
||||||
|
context.register_attempt()
|
||||||
try:
|
try:
|
||||||
result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
|
result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
|
||||||
if self._expected_dim is not None and result.dim != self._expected_dim:
|
if self._expected_dim is not None and result.dim != self._expected_dim:
|
||||||
@@ -276,6 +370,7 @@ class EmbeddingClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
context,
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
return _BatchOutcome(result, source, call_id, latency_ms)
|
return _BatchOutcome(result, source, call_id, latency_ms)
|
||||||
@@ -290,6 +385,7 @@ class EmbeddingClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
context,
|
||||||
error=exc,
|
error=exc,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
@@ -305,6 +401,7 @@ class EmbeddingClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
context,
|
||||||
error="cancelled",
|
error="cancelled",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
@@ -325,6 +422,7 @@ class EmbeddingClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
context,
|
||||||
error=exc,
|
error=exc,
|
||||||
)
|
)
|
||||||
return _FailedBatch(exc, immediate=dead)
|
return _FailedBatch(exc, immediate=dead)
|
||||||
@@ -361,8 +459,9 @@ class EmbeddingClient:
|
|||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
result: EmbeddingTransportResult | None = None,
|
result: EmbeddingTransportResult | None = None,
|
||||||
error: object | None = None,
|
error: PolyGatewayError | str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
|
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
|
||||||
if self._emitter is None:
|
if self._emitter is None:
|
||||||
@@ -375,6 +474,7 @@ class EmbeddingClient:
|
|||||||
parent_call_id=parent_call_id,
|
parent_call_id=parent_call_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
meta=meta,
|
meta=meta,
|
||||||
|
call_context=context,
|
||||||
)
|
)
|
||||||
response = None
|
response = None
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -399,7 +499,12 @@ class EmbeddingClient:
|
|||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
latency_ms=int((self._now() - started) * 1000),
|
latency_ms=int((self._now() - started) * 1000),
|
||||||
response=response,
|
response=response,
|
||||||
error=None if error is None else str(error),
|
# 异常对象原样下传: 状态码/底层异常类型/网关正文在 Emitter 内定型
|
||||||
|
error=error,
|
||||||
|
# embedding payload 硬编码 {model, input},从不带推理参数;源上即便
|
||||||
|
# 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事
|
||||||
|
reasoning_applies=False,
|
||||||
|
operation="embed",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
|
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
|
||||||
@@ -444,21 +549,28 @@ class EmbeddingClient:
|
|||||||
known = [c for c in costs if c is not None]
|
known = [c for c in costs if c is not None]
|
||||||
return sum(known) if known else None
|
return sum(known) if known else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_status(self) -> TelemetryStatus | None:
|
||||||
|
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
|
||||||
|
|
||||||
|
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
|
||||||
|
的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。
|
||||||
|
"""
|
||||||
|
return _telemetry_status_of(self._telemetry)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""幂等释放 transport 连接池与遥测连接(与 GatewayClient 对称)。"""
|
"""幂等释放**自建**资源(与 GatewayClient 对称);注入的组件一律不碰。"""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
transport_aclose = getattr(self._transport, "aclose", None)
|
if self._owns_transport:
|
||||||
if transport_aclose is not None:
|
await _aclose_component(self._transport)
|
||||||
await transport_aclose()
|
if self._owns_telemetry:
|
||||||
telemetry_aclose = getattr(self._telemetry, "aclose", None)
|
await _aclose_component(self._telemetry)
|
||||||
if telemetry_aclose is not None:
|
if self._owns_limiter:
|
||||||
await telemetry_aclose()
|
await _aclose_component(self._limiter_backend)
|
||||||
else:
|
if self._owns_breaker:
|
||||||
telemetry_close = getattr(self._telemetry, "close", None)
|
await _aclose_component(self._breaker_backend)
|
||||||
if telemetry_close is not None:
|
|
||||||
telemetry_close()
|
|
||||||
|
|
||||||
async def __aenter__(self) -> EmbeddingClient:
|
async def __aenter__(self) -> EmbeddingClient:
|
||||||
return self
|
return self
|
||||||
@@ -484,18 +596,19 @@ class EmbeddingClient:
|
|||||||
_build_limiter,
|
_build_limiter,
|
||||||
_build_selector,
|
_build_selector,
|
||||||
_build_telemetry,
|
_build_telemetry,
|
||||||
|
_mark_owned_components,
|
||||||
)
|
)
|
||||||
from polygateway.pricing import PricingTable
|
from polygateway.pricing import PricingTable
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
|
|
||||||
gw = settings.gateway
|
gw = settings.gateway
|
||||||
sources = list(gw.sources)
|
sources = list(gw.sources)
|
||||||
return cls(
|
client = cls(
|
||||||
scope=gw.scope,
|
scope=gw.scope,
|
||||||
sources=sources,
|
sources=sources,
|
||||||
selector=_build_selector(gw.selector),
|
selector=_build_selector(gw.selector),
|
||||||
limiter=limiter or _build_limiter(gw, sources),
|
limiter=limiter if limiter is not None else _build_limiter(gw, sources),
|
||||||
breaker=breaker or _build_breaker(gw),
|
breaker=breaker if breaker is not None else _build_breaker(gw),
|
||||||
transport=OpenAICompatTransport(registry=registry),
|
transport=OpenAICompatTransport(registry=registry),
|
||||||
retry=gw.retry,
|
retry=gw.retry,
|
||||||
backpressure=gw.backpressure,
|
backpressure=gw.backpressure,
|
||||||
@@ -512,6 +625,8 @@ class EmbeddingClient:
|
|||||||
normalize=settings.normalize,
|
normalize=settings.normalize,
|
||||||
expected_dim=settings.expected_dim,
|
expected_dim=settings.expected_dim,
|
||||||
)
|
)
|
||||||
|
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||||
|
return client
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.types import ChatRequest, LLMResponse
|
from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -28,6 +28,54 @@ _KEY_PREFIX = "pgw:cache:"
|
|||||||
_RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)}
|
_RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_observation(raw: Any) -> ThinkingObservation:
|
||||||
|
"""缓存里的三态取值 → 枚举;域外取值降级为 `UNKNOWN`,**不作废整条缓存**。
|
||||||
|
|
||||||
|
方向选择的理由: `_rehydrate` 对 JSON 里的**新字段**已经是宽容的(先按
|
||||||
|
`_RESPONSE_FIELDS` 过滤),对同一字段的**新取值**却不该是致命的。真实场景是
|
||||||
|
多个项目共用一个 Redis,先升级的那个写入了本版没有的取值,未升级的项目若把
|
||||||
|
这些条目判成未命中,就会每次真打网关、随后覆写回旧值,两个版本互相打对方的
|
||||||
|
缓存(表现是命中率莫名腰斩,而通用的"重建失败"文案给不出任何线索)。一个纯
|
||||||
|
可观测性字段不该有能力废掉内容完好的缓存响应——"整条作废"留给真正破坏内容
|
||||||
|
完整性的失败(JSON 坏了、结构化重建不过)。
|
||||||
|
|
||||||
|
降级到 `UNKNOWN` 而不是别的态: 它的语义恰好就是"本次判不出来",对一个本库
|
||||||
|
读不懂的取值,这是唯一诚实的说法。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return ThinkingObservation(raw)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"缓存条目的 thinking_observation 取值 {!r} 不在本版取值域内(多半由更新版本的"
|
||||||
|
"进程写入),已降级为 UNKNOWN;响应内容照常复活——可观测性字段不作废缓存",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
return ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_applied_effort(raw: Any) -> Effort | None:
|
||||||
|
"""缓存里的档位字符串 → 枚举;域外取值降级为 `None`,**不作废整条缓存**。
|
||||||
|
|
||||||
|
与 `_coerce_observation` 同源同向,理由逐条相同: 多项目共用一个 Redis 时,
|
||||||
|
先升级的进程可能写入本版没有的档位名,未升级的进程若把这些条目判成未命中,
|
||||||
|
两个版本就会互相打对方的缓存。归因字段不该有能力废掉内容完好的响应。
|
||||||
|
|
||||||
|
降级到 `None` 而不是别的档: 它的语义是"库不知道这次跑在哪档",对一个读不懂
|
||||||
|
的取值这是唯一诚实的说法——随便挑一档等于替上游声称了一件它没说过的事。
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Effort(raw)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"缓存条目的 applied_effort 取值 {!r} 不在本版档位词汇内(多半由更新版本的"
|
||||||
|
"进程写入),已降级为 None;响应内容照常复活——归因字段不作废缓存",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
|
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
|
||||||
|
|
||||||
@@ -58,12 +106,21 @@ def build_cache_key(
|
|||||||
salt: str | None,
|
salt: str | None,
|
||||||
*,
|
*,
|
||||||
sampling: Mapping[str, Any] | None = None,
|
sampling: Mapping[str, Any] | None = None,
|
||||||
|
reasoning_effort: Effort | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
||||||
|
|
||||||
`sampling` 仅**非空**时参与(与 salt 的"仅非 None"不同——空串是有意义的
|
`sampling` 仅**非空**时参与(与 salt 的"仅非 None"不同——空串是有意义的
|
||||||
salt,而空采样参数与不传无语义差别)。它必须进 key: 否则同 messages 跑 5 个
|
salt,而空采样参数与不传无语义差别)。它必须进 key: 否则同 messages 跑 5 个
|
||||||
seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。
|
seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。
|
||||||
|
|
||||||
|
`reasoning_effort` 是**请求级**档位(issue #20),仅非 `None` 时参与。它不能靠
|
||||||
|
`model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,一次调用改档位不会
|
||||||
|
让它变一个字节;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4
|
||||||
|
「5 个 seed 全命中同一响应」的逐字翻版。
|
||||||
|
|
||||||
|
判据用 `is not None` 而非真值: `Effort.NONE`(明确要求不推理)与 `None`
|
||||||
|
(不表态)语义不同——前者拿到的是没有推理过程的响应,合并即毒化。
|
||||||
"""
|
"""
|
||||||
key_obj: dict[str, Any] = {
|
key_obj: dict[str, Any] = {
|
||||||
"model": model_fingerprint,
|
"model": model_fingerprint,
|
||||||
@@ -74,6 +131,8 @@ def build_cache_key(
|
|||||||
key_obj["salt"] = salt
|
key_obj["salt"] = salt
|
||||||
if sampling:
|
if sampling:
|
||||||
key_obj["sampling"] = dict(sampling)
|
key_obj["sampling"] = dict(sampling)
|
||||||
|
if reasoning_effort is not None:
|
||||||
|
key_obj["reasoning_effort"] = str(reasoning_effort)
|
||||||
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
|
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
|
||||||
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
@@ -114,6 +173,9 @@ class CacheMW:
|
|||||||
namespace,
|
namespace,
|
||||||
request.cache_salt,
|
request.cache_salt,
|
||||||
sampling=request.sampling,
|
sampling=request.sampling,
|
||||||
|
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
|
||||||
|
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
|
||||||
|
reasoning_effort=request.reasoning_effort,
|
||||||
)
|
)
|
||||||
cached = await self._safe_get(key)
|
cached = await self._safe_get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -132,6 +194,13 @@ class CacheMW:
|
|||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
||||||
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
||||||
|
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
|
||||||
|
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
|
||||||
|
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
||||||
|
if "thinking_observation" in fields:
|
||||||
|
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
||||||
|
if "applied_effort" in fields:
|
||||||
|
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
|
||||||
fields.update(
|
fields.update(
|
||||||
cache_hit=True,
|
cache_hit=True,
|
||||||
latency_ms=0,
|
latency_ms=0,
|
||||||
@@ -139,6 +208,10 @@ class CacheMW:
|
|||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
call_id=str(uuid.uuid4()),
|
call_id=str(uuid.uuid4()),
|
||||||
structured_data=structured_data,
|
structured_data=structured_data,
|
||||||
|
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
|
||||||
|
# 过滤**会放行它**——不覆盖就会有 dict 冒充 `CallStats` 漏给调用方。
|
||||||
|
# 本次调用的真实统计由公开边界在返回前追加(设计 §3)
|
||||||
|
call_stats=None,
|
||||||
)
|
)
|
||||||
return LLMResponse(**fields)
|
return LLMResponse(**fields)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -161,6 +234,9 @@ class CacheMW:
|
|||||||
def _serialize(self, response: LLMResponse) -> str:
|
def _serialize(self, response: LLMResponse) -> str:
|
||||||
data = dataclasses.asdict(response)
|
data = dataclasses.asdict(response)
|
||||||
data.pop("structured_data", None) # pydantic 实例不可 JSON 往返(设计 §2.1)
|
data.pop("structured_data", None) # pydantic 实例不可 JSON 往返(设计 §2.1)
|
||||||
|
# 统计描述**本次**调用,存进去再放出来等于向下一个调用方谎称
|
||||||
|
# 它重试了 N 次;`asdict` 会把 `CallStats` 摊成 dict,故必须显式剔除
|
||||||
|
data.pop("call_stats", None)
|
||||||
return json.dumps(data, ensure_ascii=False)
|
return json.dumps(data, ensure_ascii=False)
|
||||||
|
|
||||||
async def _safe_get(self, key: str) -> str | None:
|
async def _safe_get(self, key: str) -> str | None:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from polygateway.types import LLMResponse
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
|
|
||||||
|
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||||
from polygateway.ports import (
|
from polygateway.ports import (
|
||||||
GateDecision,
|
GateDecision,
|
||||||
Permit,
|
Permit,
|
||||||
@@ -181,7 +182,7 @@ class RetryMW:
|
|||||||
circuit_open: str = "fail_fast",
|
circuit_open: str = "fail_fast",
|
||||||
cooldown_memo: SourceCooldownMemo | None = None,
|
cooldown_memo: SourceCooldownMemo | None = None,
|
||||||
pacer: AdaptivePacer | None = None,
|
pacer: AdaptivePacer | None = None,
|
||||||
emitter: object | None = None,
|
emitter: TelemetryEmitter | None = None,
|
||||||
now: Callable[[], float] = time.monotonic,
|
now: Callable[[], float] = time.monotonic,
|
||||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||||
rng: Callable[[], float] = random.random,
|
rng: Callable[[], float] = random.random,
|
||||||
@@ -278,6 +279,11 @@ class RetryMW:
|
|||||||
call_id = str(uuid.uuid4())
|
call_id = str(uuid.uuid4())
|
||||||
started = self._now()
|
started = self._now()
|
||||||
actual = 0
|
actual = 0
|
||||||
|
# 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样
|
||||||
|
# "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。
|
||||||
|
# 上下文为 None = 库内现场构造的请求,跳过而不是报错
|
||||||
|
if request.call_context is not None:
|
||||||
|
request.call_context.register_attempt()
|
||||||
try:
|
try:
|
||||||
result = await self._transport.complete(
|
result = await self._transport.complete(
|
||||||
messages=request.messages,
|
messages=request.messages,
|
||||||
@@ -285,6 +291,9 @@ class RetryMW:
|
|||||||
stream=request.stream,
|
stream=request.stream,
|
||||||
overlay=request.overlay,
|
overlay=request.overlay,
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
|
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
|
||||||
|
# 自己按选中的源解析,两者在 effective_effort 里汇合)
|
||||||
|
reasoning_effort=request.reasoning_effort,
|
||||||
)
|
)
|
||||||
if result.usage_source == "unavailable":
|
if result.usage_source == "unavailable":
|
||||||
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
|
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
|
||||||
@@ -390,6 +399,11 @@ class RetryMW:
|
|||||||
cached_prompt_tokens=result.cached_prompt_tokens,
|
cached_prompt_tokens=result.cached_prompt_tokens,
|
||||||
model_reported=result.model_reported,
|
model_reported=result.model_reported,
|
||||||
reasoning_tokens=result.reasoning_tokens,
|
reasoning_tokens=result.reasoning_tokens,
|
||||||
|
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判
|
||||||
|
thinking_observation=result.thinking_observation,
|
||||||
|
# 同理: 实际档由做注入的那一层裁定(`nearest` 映射后与请求档分叉),
|
||||||
|
# 本层若"顺手"改读 request.reasoning_effort,记的就是从未发出过的档
|
||||||
|
applied_effort=result.applied_effort,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _emit(
|
async def _emit(
|
||||||
@@ -400,9 +414,13 @@ class RetryMW:
|
|||||||
started: float,
|
started: float,
|
||||||
*,
|
*,
|
||||||
response: LLMResponse | None = None,
|
response: LLMResponse | None = None,
|
||||||
error: object | None = None,
|
error: PolyGatewayError | str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。"""
|
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。
|
||||||
|
|
||||||
|
异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码、底层异常
|
||||||
|
类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉。
|
||||||
|
"""
|
||||||
if self._emitter is None:
|
if self._emitter is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -412,7 +430,11 @@ class RetryMW:
|
|||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
latency_ms=int((self._now() - started) * 1000),
|
latency_ms=int((self._now() - started) * 1000),
|
||||||
response=response,
|
response=response,
|
||||||
error=None if error is None else str(error),
|
error=error,
|
||||||
|
# chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算
|
||||||
|
reasoning_applies=True,
|
||||||
|
# 公开方法四值之一;本中间件只服务 chat 洋葱
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from typing import TYPE_CHECKING
|
|||||||
from polygateway.errors import ResultInvalidError
|
from polygateway.errors import ResultInvalidError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from polygateway.ports import CallNext, StructuredOutputStrategy
|
from polygateway.ports import CallNext, StructuredOutputStrategy
|
||||||
from polygateway.types import ChatRequest, LLMResponse
|
from polygateway.types import ChatRequest, LLMResponse
|
||||||
|
|
||||||
@@ -22,12 +24,18 @@ _FEEDBACK_TEMPLATE = (
|
|||||||
"Your previous reply was not valid JSON matching the required schema. "
|
"Your previous reply was not valid JSON matching the required schema. "
|
||||||
"Errors: {errors}. Reply with ONLY the corrected JSON object."
|
"Errors: {errors}. Reply with ONLY the corrected JSON object."
|
||||||
)
|
)
|
||||||
_MAX_FEEDBACK_ERRORS = 3
|
MAX_FEEDBACK_ERRORS = 3
|
||||||
_MAX_ERROR_CHARS = 200
|
MAX_ERROR_CHARS = 200
|
||||||
|
|
||||||
|
|
||||||
def _format_errors(errors: list[str]) -> str:
|
def format_bounded_errors(errors: Sequence[str]) -> str:
|
||||||
clipped = [e[:_MAX_ERROR_CHARS] for e in errors[:_MAX_FEEDBACK_ERRORS]]
|
"""校验错误的有界拼装: 至多 3 条 × 每条 200 字符。
|
||||||
|
|
||||||
|
**本模块是这条规则的所有者**: 重问反馈文案与 1.3.5 终态行的结构化说明
|
||||||
|
两个消费者共用同一份实现与同一组数值——数值复制成两份必然漂移,而漂移后
|
||||||
|
"模型看到的错误"与"台账里记的错误"就不再是同一件事。行为与重命名前逐字相同。
|
||||||
|
"""
|
||||||
|
clipped = [e[:MAX_ERROR_CHARS] for e in list(errors)[:MAX_FEEDBACK_ERRORS]]
|
||||||
return "; ".join(clipped) if clipped else "output could not be parsed"
|
return "; ".join(clipped) if clipped else "output could not be parsed"
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +112,10 @@ class StructuredMW:
|
|||||||
messages = [
|
messages = [
|
||||||
*current.messages,
|
*current.messages,
|
||||||
{"role": "assistant", "content": bad_content},
|
{"role": "assistant", "content": bad_content},
|
||||||
{"role": "user", "content": _FEEDBACK_TEMPLATE.format(errors=_format_errors(errors))},
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": _FEEDBACK_TEMPLATE.format(errors=format_bounded_errors(errors)),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
reask = dataclasses.replace(current, messages=messages)
|
reask = dataclasses.replace(current, messages=messages)
|
||||||
if self._escalation is not None:
|
if self._escalation is not None:
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制
|
Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制
|
||||||
15 参调用的教训)。分工: RetryMW 经 Emitter 逐次记录每次尝试;TelemetryMW
|
15 参调用的教训)。分工: RetryMW 经 Emitter 逐次记录每次尝试;TelemetryMW
|
||||||
(最外层)只记尝试层看不见的事件——缓存命中、scope 级失败、取消;
|
(最外层)只记尝试层看不见的缓存命中;而**终态失败行**由三个 client 的公开
|
||||||
RequestRejected/ResultInvalid 已被尝试层记录,最外层放行不重复记。
|
边界经 `emit_terminal_once` 统一写出(1.3.5)——两处同时写就会双计。
|
||||||
|
|
||||||
|
一行遥测属于三类事件之一(`event_kind`): `attempt`(一次尝试)、`cache_hit`
|
||||||
|
(未产生网关调用)、`terminal_failure`(一次**逻辑调用**的失败终态)。后两者与
|
||||||
|
前者**不是重复事实**,故统计失败调用次数只能取 `terminal_failure`,
|
||||||
|
不得按 `error IS NOT NULL` 跨两类直接计数(设计 §6/§8)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -17,21 +23,28 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.errors import (
|
from polygateway.errors import PolyGatewayError, ResultInvalidError
|
||||||
GatewayUnavailableError,
|
|
||||||
GovernanceBackendError,
|
|
||||||
SourceNotConfiguredError,
|
|
||||||
)
|
|
||||||
from polygateway.middleware.cache import digest_messages
|
from polygateway.middleware.cache import digest_messages
|
||||||
from polygateway.types import canonical_sampling_json, merge_sampling
|
from polygateway.middleware.structured import MAX_ERROR_CHARS, format_bounded_errors
|
||||||
|
from polygateway.ports import TelemetryRecorder
|
||||||
|
from polygateway.thinking import effective_effort
|
||||||
|
from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from polygateway.ports import CallNext, TelemetryRecorder
|
from polygateway.ports import CallNext
|
||||||
from polygateway.pricing import PricingTable
|
from polygateway.pricing import PricingTable
|
||||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
from polygateway.types import (
|
||||||
|
CallOperation,
|
||||||
|
CallStats,
|
||||||
|
ChatRequest,
|
||||||
|
EventKind,
|
||||||
|
LLMResponse,
|
||||||
|
SourceConfig,
|
||||||
|
_CallContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
||||||
@@ -55,6 +68,95 @@ def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
|||||||
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
|
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_observation(raw: object) -> str:
|
||||||
|
"""三态裁定 → 落库用的裸 str;不是枚举也不在取值域时降级为 `unknown` 并告警。
|
||||||
|
|
||||||
|
**不写 `raw.value`**: `LLMResponse` 是无运行时校验的 frozen dataclass,下游
|
||||||
|
(尤其迁移期的测试替身)写 `LLMResponse(..., thinking_observation="observed")`
|
||||||
|
完全自然、`==` 比较照常成立,而 `.value` 会当场抛 `AttributeError`,被 `_record`
|
||||||
|
的 `except Exception` 吞成一条泛化 warning —— 丢的不是这一列,是**整行**,而
|
||||||
|
"遥测必录"是铁律。
|
||||||
|
|
||||||
|
域外取值同样只降级不抛: 直接 `ThinkingObservation(raw)` 会抛 `ValueError`,
|
||||||
|
落到同一个 `except` 上、同样丢整行,那只修好了裸 str 一半(口误值对测试替身
|
||||||
|
一样自然)。降级到 `unknown` 是诚实的——库确实判不出这个取值的含义,而单独
|
||||||
|
一条点名取值的 warning 保证它不被掩盖(P5 不许默认值掩盖错误)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return ThinkingObservation(raw).value
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"thinking_observation 取值 {!r} 不在取值域内,本行降级记为 unknown"
|
||||||
|
"(其余列照常落库);调用方应传 ThinkingObservation 成员",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
return ThinkingObservation.UNKNOWN.value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_effort(raw: object) -> str | None:
|
||||||
|
"""实际档位 → 落库用的裸 str;不表态与域外取值都落 `NULL`。
|
||||||
|
|
||||||
|
**不写 `raw.value`**,理由与 `_normalize_observation` 逐字相同: `LLMResponse`
|
||||||
|
是无运行时校验的 frozen dataclass,测试替身写 `applied_effort="low"` 完全自然,
|
||||||
|
而 `.value` 会当场抛 `AttributeError`,被 `_record` 的 `except Exception` 吞成
|
||||||
|
一条泛化 warning —— 丢的不是这一列,是**整行**。
|
||||||
|
|
||||||
|
域外取值降级为 `None` 而不抛,方向与 `CacheMW._coerce_applied_effort` 一致
|
||||||
|
(设计 §4.4): 多项目共用一套后端时,更新版本的进程可能带来本版没有的档位名,
|
||||||
|
归因字段不该有能力废掉一整行遥测。降级到 `None` 也是唯一诚实的说法——库确实
|
||||||
|
不知道这次跑在哪档,随便挑一档等于替上游声称了一件它没说过的事。
|
||||||
|
|
||||||
|
注意 `None` 在本列有**两个**来源(不表态 / 读不懂),二者都不可折叠进 `'none'`:
|
||||||
|
`'none'` 是"明确要求不推理",是一次表态。
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Effort(raw).value
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"推理档位取值 {!r} 不在本版档位词汇内,本行 reasoning_effort 降级记为 NULL"
|
||||||
|
"(其余列照常落库)",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _attempt_effort(
|
||||||
|
*,
|
||||||
|
request: ChatRequest,
|
||||||
|
source: SourceConfig,
|
||||||
|
response: LLMResponse | None,
|
||||||
|
applies: bool,
|
||||||
|
) -> str | None:
|
||||||
|
"""一次尝试该记哪一档: 成功读**实发档**,失败退回**请求档**(设计 §6)。
|
||||||
|
|
||||||
|
成功行一律读 `response.applied_effort` 而**绝不重算**: 源上开了
|
||||||
|
`EFFORT_FALLBACK=nearest` 时,请求 `medium` 而模型只有 low/high/max,实发的是
|
||||||
|
`low`;此处重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出
|
||||||
|
过的分组下——而两个值在没开映射的源上恒等,这个错在本地跑不出来。
|
||||||
|
|
||||||
|
失败尝试没有响应,实发档无从得知,故退回请求档并**接受这层含义差别**: 开了映射
|
||||||
|
的源上,成功行是映射后的档、失败行是请求档,两种行不是同一把尺子。仍然记而不是
|
||||||
|
留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,
|
||||||
|
这类行记的正是**被拒绝的那一档**——"哪一档配错了"是压测与排障要的信号。
|
||||||
|
|
||||||
|
回落走 `effective_effort` 而非裸读两个字段: `enable_thinking` 也是一次表态
|
||||||
|
(语法糖),漏掉它就会把一次明确要求推理的调用记成"没表态"。
|
||||||
|
"""
|
||||||
|
if not applies:
|
||||||
|
return None
|
||||||
|
if response is not None:
|
||||||
|
return _normalize_effort(response.applied_effort)
|
||||||
|
return _normalize_effort(
|
||||||
|
effective_effort(
|
||||||
|
request_effort=request.reasoning_effort,
|
||||||
|
source_effort=source.reasoning_effort,
|
||||||
|
enable_thinking=source.enable_thinking,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cap_text(text: str, cap: int | None) -> str:
|
def _cap_text(text: str, cap: int | None) -> str:
|
||||||
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
||||||
if cap is None or len(text) <= cap:
|
if cap is None or len(text) <= cap:
|
||||||
@@ -111,6 +213,9 @@ class _AttemptUsage:
|
|||||||
cached_prompt_tokens: int | None = None
|
cached_prompt_tokens: int | None = None
|
||||||
model_reported: str | None = None
|
model_reported: str | None = None
|
||||||
reasoning_tokens: int | None = None
|
reasoning_tokens: int | None = None
|
||||||
|
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
|
||||||
|
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
|
||||||
|
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
||||||
@@ -128,29 +233,163 @@ class _AttemptUsage:
|
|||||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||||
model_reported=response.model_reported,
|
model_reported=response.model_reported,
|
||||||
reasoning_tokens=response.reasoning_tokens,
|
reasoning_tokens=response.reasoning_tokens,
|
||||||
|
thinking_observation=response.thinking_observation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ErrorFields:
|
||||||
|
"""一行遥测的错误列;未知一律 `None`。
|
||||||
|
|
||||||
|
存在的理由是把"三种入参形态 × 两类行"的定型规则收敛到**一处**:
|
||||||
|
改前调用方先 `str(exc)` 压平,状态码、底层异常类型与网关正文全部丢失。
|
||||||
|
"""
|
||||||
|
|
||||||
|
error: str | None = None
|
||||||
|
error_type: str | None = None
|
||||||
|
cause_type: str | None = None
|
||||||
|
http_status_code: int | None = None
|
||||||
|
error_body: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_detail(exc: ResultInvalidError) -> str:
|
||||||
|
"""结构化阶梯耗尽的**有界**说明(设计 §5 C2)。
|
||||||
|
|
||||||
|
`ResultInvalidError("结构化输出阶梯耗尽")` 的 message 不含校验与修复错误,而该
|
||||||
|
失败发生在 StructuredMW 之上——RetryMW 侧的 attempt 行全是**成功行**,终态行是
|
||||||
|
唯一记录。故把说明并入现有 `error` 串。
|
||||||
|
|
||||||
|
**不含 `raw_text`**: 它是模型正文,attempt 行的 `response` 列已按 `text_cap` 记过
|
||||||
|
一份;再存一份等于绕过既有的正文预算。条数与限长复用 `structured.py` 的同一
|
||||||
|
套常量(重问反馈与本说明同一口径),数值只有一份。
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
if exc.repair_error:
|
||||||
|
parts.append(f"repair={exc.repair_error[:MAX_ERROR_CHARS]}")
|
||||||
|
if exc.validation_errors:
|
||||||
|
parts.append(f"validation={format_bounded_errors(exc.validation_errors)}")
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_fields(
|
||||||
|
error: PolyGatewayError | str | None,
|
||||||
|
*,
|
||||||
|
event_kind: EventKind,
|
||||||
|
class_prefixed: bool,
|
||||||
|
) -> _ErrorFields:
|
||||||
|
"""三种入参形态的唯一定型点(设计 §5/§6)。
|
||||||
|
|
||||||
|
- `None` → 全 None(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了")。
|
||||||
|
- `str`(取消路径的 `"cancelled"`)→ 原样落 `error`,**不解析字符串猜诊断**。
|
||||||
|
- 领域异常 → 只读它既有的属性,不遍历任意对象、不猜正文。
|
||||||
|
|
||||||
|
**终态行的三列恒为 NULL(C1 红线)**: `GatewayUnavailableError` 家族从不携带
|
||||||
|
状态码与响应体,NULL 正是它自身的真实状态——把最后一次 attempt 的状态码与正文
|
||||||
|
搬上来,就是拿最后一个源冒充整池归因。逐源现场由同一 `logical_call_id` 的
|
||||||
|
attempt 行给出。
|
||||||
|
"""
|
||||||
|
if error is None:
|
||||||
|
return _ErrorFields()
|
||||||
|
if isinstance(error, str):
|
||||||
|
return _ErrorFields(error=error)
|
||||||
|
name = type(error).__name__
|
||||||
|
# 空 `str()` 退回类名(httpx 的 Connect/Read/Write/PoolTimeout 文案就是空的);
|
||||||
|
# `class_prefixed` 是 OCR 的既有口径(按类名归组的 metric),故逐字保留它的拼法
|
||||||
|
text = f"{name}: {error}" if class_prefixed else (str(error) or name)
|
||||||
|
if event_kind == "terminal_failure":
|
||||||
|
if isinstance(error, ResultInvalidError):
|
||||||
|
detail = _structured_detail(error)
|
||||||
|
if detail:
|
||||||
|
text = f"{text} | {detail}"
|
||||||
|
return _ErrorFields(error=text, error_type=name)
|
||||||
|
cause = error.__cause__
|
||||||
|
return _ErrorFields(
|
||||||
|
error=text,
|
||||||
|
error_type=name,
|
||||||
|
cause_type=type(cause).__name__ if cause is not None else None,
|
||||||
|
# getattr 而非直读: 本函数在 `_record` 的降级 try **之外**求值,
|
||||||
|
# 一个非领域异常误传进来不得把一次真实失败换成 AttributeError
|
||||||
|
http_status_code=getattr(error, "status_code", None),
|
||||||
|
# 空串归 None: 既有 `body_text` 的缺省就是空串,而本列的语义是"未知"
|
||||||
|
error_body=getattr(error, "body_text", "") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
|
||||||
|
"""装配期一次 `signature.bind` 形状校验: 不执行写入,只证明该形状能被接受。
|
||||||
|
|
||||||
|
`_record` 的 `except Exception` 会把旧 recorder 的 `TypeError` 吞成 warning,
|
||||||
|
后果是自定义 recorder 在下游升级后**100% 丢遥测且调用照常成功**——正是
|
||||||
|
"遥测必录"要防的形态,而文档级迁移清单挡不住它。故在装配期当场报错
|
||||||
|
(不是 warning: 降级方向的铁律管的是**运行期写失败**,不是装配错误)。
|
||||||
|
|
||||||
|
参数名从 `TelemetryRecorder.record_llm_call` 的协议签名**派生**(不手抄第四份
|
||||||
|
字段清单),绑定用哨兵 `None`,不读任何真实请求数据;`**kwargs`
|
||||||
|
(VAR_KEYWORD)自动通过。不可 inspect(C 实现等)同样按配置错误报错——宁可
|
||||||
|
装配不起来,不进入"运行期静默丢行"。
|
||||||
|
|
||||||
|
边界诚实声明: 它只证明该形状能被接受,**不能证明函数体真的落这些列**。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 签名不符、不可 inspect,或协议本身不可 inspect。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 模块全局查找而非常量快照: 协议改了,闸就跟着改(测试可据此机械验证)
|
||||||
|
protocol = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||||
|
except (TypeError, ValueError) as exc: # pragma: no cover - 协议一向可 inspect
|
||||||
|
raise ValueError(f"TelemetryRecorder.record_llm_call 签名不可读取: {exc}") from exc
|
||||||
|
sentinels = {name: None for name in protocol if name != "self"}
|
||||||
|
label = type(recorder).__name__
|
||||||
|
method = getattr(recorder, "record_llm_call", None)
|
||||||
|
if method is None:
|
||||||
|
# 连方法都没有: 比旧签名更明确的配置错误。不让它以裸 AttributeError
|
||||||
|
# 逆流而上——那不属错误四分类,且现场离"注错了东西"这个真因很远
|
||||||
|
raise ValueError(
|
||||||
|
f"注入的遥测 recorder {label} 没有 record_llm_call 方法,不满足 TelemetryRecorder 端口"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
signature = inspect.signature(method)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"遥测 recorder {label} 的 record_llm_call 不可 inspect(如 C 实现),"
|
||||||
|
"无法在装配期确认它接受当前字段形状;请换成 Python 实现或包一层"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
signature.bind(**sentinels)
|
||||||
|
except TypeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"遥测 recorder {label} 的 record_llm_call 签名与 TelemetryRecorder 不符"
|
||||||
|
f"(当前 {len(sentinels)} 个字段): {exc}。"
|
||||||
|
"这一条故意在装配期报错——放行的后果是每行遥测都被降级成 warning 后丢弃"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
class TelemetryEmitter:
|
class TelemetryEmitter:
|
||||||
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
|
"""从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
recorder: TelemetryRecorder,
|
recorder: TelemetryRecorder,
|
||||||
*,
|
*,
|
||||||
|
scope: str,
|
||||||
pricing: PricingTable | None = None,
|
pricing: PricingTable | None = None,
|
||||||
text_cap: int | None,
|
text_cap: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。
|
"""`text_cap` 与 `scope` 无默认值是有意的: 两者都是关键行为参数。
|
||||||
|
|
||||||
|
`text_cap` 漏传即静默改变落库正文;`scope` 漏传则三类行都失去池名
|
||||||
|
——终态失败可能根本没选出源,但 scope 始终已知,不拿 `source_name` 顶替。
|
||||||
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
|
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
|
||||||
同理,值域校验也放在这一处: 三个 Client 的 `text_cap` 全部汇流到这里,
|
|
||||||
|
同理,值域校验与**装配闸**都放在这一处: 三个 Client 全部汇流到这里,
|
||||||
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
|
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
|
||||||
条公共装配路——`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
|
条公共装配路(`text_cap=0` 会让每条正文只剩一个省略标记;P5 不得静默)。
|
||||||
"""
|
"""
|
||||||
if text_cap is not None and text_cap <= 0:
|
if text_cap is not None and text_cap <= 0:
|
||||||
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
|
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
|
||||||
|
_assert_recorder_shape(recorder)
|
||||||
self._recorder = recorder
|
self._recorder = recorder
|
||||||
|
self._scope = scope
|
||||||
self._pricing = pricing
|
self._pricing = pricing
|
||||||
self._text_cap = text_cap
|
self._text_cap = text_cap
|
||||||
|
|
||||||
@@ -162,9 +401,24 @@ class TelemetryEmitter:
|
|||||||
call_id: str,
|
call_id: str,
|
||||||
latency_ms: int,
|
latency_ms: int,
|
||||||
response: LLMResponse | None,
|
response: LLMResponse | None,
|
||||||
error: str | None,
|
error: PolyGatewayError | str | None,
|
||||||
|
reasoning_applies: bool,
|
||||||
|
operation: CallOperation,
|
||||||
|
class_prefixed_error: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。"""
|
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
|
||||||
|
|
||||||
|
`reasoning_applies` 声明**这条调用路径有没有推理语义**: chat 路径为
|
||||||
|
`True`,embedding / OCR 路径为 `False`。它不能由 emitter 自己推断——三条路径
|
||||||
|
共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源
|
||||||
|
会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的
|
||||||
|
档。**不设默认值**: 与 `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传
|
||||||
|
当场 TypeError,好过被静默当成"没表态"。`operation` 同理且另有一层:
|
||||||
|
它只能由调用点给定,**绝不读 `exc.operation`**(后者是 HTTP 子操作)。
|
||||||
|
|
||||||
|
`error` 收**领域异常对象**而非预先 `str()` 压平的文本: 状态码/底层异常类型/
|
||||||
|
网关正文在此提取成四列(设计 §5)。取消路径仍传既有字符串 `"cancelled"`。
|
||||||
|
"""
|
||||||
usage = _AttemptUsage.of(response)
|
usage = _AttemptUsage.of(response)
|
||||||
await self._record(
|
await self._record(
|
||||||
request=request,
|
request=request,
|
||||||
@@ -181,18 +435,31 @@ class TelemetryEmitter:
|
|||||||
ttft_ms=usage.ttft_ms,
|
ttft_ms=usage.ttft_ms,
|
||||||
max_inter_token_ms=usage.max_inter_token_ms,
|
max_inter_token_ms=usage.max_inter_token_ms,
|
||||||
cache_hit=False,
|
cache_hit=False,
|
||||||
error=error,
|
errors=_error_fields(error, event_kind="attempt", class_prefixed=class_prefixed_error),
|
||||||
cached_prompt_tokens=usage.cached_prompt_tokens,
|
cached_prompt_tokens=usage.cached_prompt_tokens,
|
||||||
model_reported=usage.model_reported,
|
model_reported=usage.model_reported,
|
||||||
reasoning_tokens=usage.reasoning_tokens,
|
reasoning_tokens=usage.reasoning_tokens,
|
||||||
|
thinking_observation=usage.thinking_observation,
|
||||||
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
||||||
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
||||||
tenant_id=request.tenant_id,
|
tenant_id=request.tenant_id,
|
||||||
meta=request.meta,
|
meta=request.meta,
|
||||||
|
reasoning_effort=_attempt_effort(
|
||||||
|
request=request, source=source, response=response, applies=reasoning_applies
|
||||||
|
),
|
||||||
|
operation=operation,
|
||||||
|
event_kind="attempt",
|
||||||
|
attempts=None,
|
||||||
|
total_latency_ms=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
async def emit_cache_hit(
|
||||||
"""缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。"""
|
self, *, request: ChatRequest, response: LLMResponse, operation: CallOperation
|
||||||
|
) -> None:
|
||||||
|
"""缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。
|
||||||
|
|
||||||
|
逻辑计数两列恒 NULL: 本行描述的是"一次命中",不是一次逻辑调用的终态。
|
||||||
|
"""
|
||||||
await self._record(
|
await self._record(
|
||||||
request=request,
|
request=request,
|
||||||
call_id=response.call_id,
|
call_id=response.call_id,
|
||||||
@@ -208,12 +475,14 @@ class TelemetryEmitter:
|
|||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
cache_hit=True,
|
cache_hit=True,
|
||||||
error=None,
|
errors=_ErrorFields(),
|
||||||
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
|
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
|
||||||
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
|
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
|
||||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||||
model_reported=response.model_reported,
|
model_reported=response.model_reported,
|
||||||
reasoning_tokens=response.reasoning_tokens,
|
reasoning_tokens=response.reasoning_tokens,
|
||||||
|
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
|
||||||
|
thinking_observation=response.thinking_observation,
|
||||||
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
||||||
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
||||||
sampling=canonical_sampling_json(request.sampling),
|
sampling=canonical_sampling_json(request.sampling),
|
||||||
@@ -222,12 +491,31 @@ class TelemetryEmitter:
|
|||||||
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
|
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
|
||||||
tenant_id=request.tenant_id,
|
tenant_id=request.tenant_id,
|
||||||
meta=request.meta,
|
meta=request.meta,
|
||||||
|
# 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射
|
||||||
|
# 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的)
|
||||||
|
reasoning_effort=_normalize_effort(request.reasoning_effort),
|
||||||
|
operation=operation,
|
||||||
|
event_kind="cache_hit",
|
||||||
|
attempts=None,
|
||||||
|
total_latency_ms=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_terminal_failure(
|
async def emit_terminal_failure(
|
||||||
self, *, request: ChatRequest, call_id: str, latency_ms: int, error: str
|
self,
|
||||||
|
*,
|
||||||
|
request: ChatRequest,
|
||||||
|
call_id: str,
|
||||||
|
error: PolyGatewayError | str,
|
||||||
|
operation: CallOperation,
|
||||||
|
stats: CallStats,
|
||||||
|
class_prefixed_error: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""scope 级失败/取消记录: 无具体源,溯源字段置空标记。"""
|
"""一次**逻辑调用**的失败终态: 无具体源,溯源字段置空标记。
|
||||||
|
|
||||||
|
`latency_ms` 与 `total_latency_ms` 同取**同一份冻结快照**,避免双时钟微差;
|
||||||
|
故本方法不再收 `latency_ms`。token 与 cost 一律不从 attempt 行复制
|
||||||
|
(费用聚合仍只由 attempt / cache_hit 行决定,口径不变)。
|
||||||
|
"""
|
||||||
await self._record(
|
await self._record(
|
||||||
request=request,
|
request=request,
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
@@ -239,19 +527,29 @@ class TelemetryEmitter:
|
|||||||
prompt_tokens=0,
|
prompt_tokens=0,
|
||||||
completion_tokens=0,
|
completion_tokens=0,
|
||||||
usage_source="unavailable",
|
usage_source="unavailable",
|
||||||
latency_ms=latency_ms,
|
latency_ms=stats.total_latency_ms,
|
||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
cache_hit=False,
|
cache_hit=False,
|
||||||
error=error,
|
errors=_error_fields(
|
||||||
|
error, event_kind="terminal_failure", class_prefixed=class_prefixed_error
|
||||||
|
),
|
||||||
cached_prompt_tokens=None,
|
cached_prompt_tokens=None,
|
||||||
model_reported=None,
|
model_reported=None,
|
||||||
reasoning_tokens=None,
|
reasoning_tokens=None,
|
||||||
|
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
|
||||||
|
thinking_observation=ThinkingObservation.UNKNOWN,
|
||||||
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
||||||
sampling=canonical_sampling_json(request.sampling),
|
sampling=canonical_sampling_json(request.sampling),
|
||||||
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
|
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
|
||||||
tenant_id=request.tenant_id,
|
tenant_id=request.tenant_id,
|
||||||
meta=request.meta,
|
meta=request.meta,
|
||||||
|
# 可能根本没选出源,故与 sampling 同样只取请求档
|
||||||
|
reasoning_effort=_normalize_effort(request.reasoning_effort),
|
||||||
|
operation=operation,
|
||||||
|
event_kind="terminal_failure",
|
||||||
|
attempts=stats.attempts,
|
||||||
|
total_latency_ms=stats.total_latency_ms,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _record(
|
async def _record(
|
||||||
@@ -271,14 +569,29 @@ class TelemetryEmitter:
|
|||||||
ttft_ms: float | None,
|
ttft_ms: float | None,
|
||||||
max_inter_token_ms: float | None,
|
max_inter_token_ms: float | None,
|
||||||
cache_hit: bool,
|
cache_hit: bool,
|
||||||
error: str | None,
|
# 1.3.5: 错误四列已由 `_error_fields` 定型(三种入参形态 × 两类行的唯一规则所有者),
|
||||||
|
# 本方法只搬运——拆成五个平铺参数就是把"一处定型"换回"三处各自拼"
|
||||||
|
errors: _ErrorFields,
|
||||||
cached_prompt_tokens: int | None,
|
cached_prompt_tokens: int | None,
|
||||||
model_reported: str | None,
|
model_reported: str | None,
|
||||||
sampling: str | None,
|
sampling: str | None,
|
||||||
reasoning_tokens: int | None,
|
reasoning_tokens: int | None,
|
||||||
|
# issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。
|
||||||
|
# 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation`
|
||||||
|
# 仍按外部输入防御——违约的代价不该是丢掉整行遥测
|
||||||
|
thinking_observation: ThinkingObservation,
|
||||||
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
|
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: Mapping[str, Any],
|
meta: Mapping[str, Any],
|
||||||
|
# issue #20: 已由各入口按自己的口径定型成裸 str/None(口径差别见三个入口的
|
||||||
|
# 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把
|
||||||
|
# "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制
|
||||||
|
reasoning_effort: str | None,
|
||||||
|
# —— 1.3.5: 行形态与逻辑调用快照 ——
|
||||||
|
operation: CallOperation,
|
||||||
|
event_kind: EventKind,
|
||||||
|
attempts: int | None,
|
||||||
|
total_latency_ms: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||||
@@ -289,7 +602,7 @@ class TelemetryEmitter:
|
|||||||
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
|
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
|
||||||
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
||||||
cost = None
|
cost = None
|
||||||
elif error is None and model and self._pricing is not None:
|
elif errors.error is None and model and self._pricing is not None:
|
||||||
cost = self._pricing.cost(
|
cost = self._pricing.cost(
|
||||||
model, prompt_tokens, completion_tokens, cached_prompt_tokens
|
model, prompt_tokens, completion_tokens, cached_prompt_tokens
|
||||||
)
|
)
|
||||||
@@ -301,6 +614,7 @@ class TelemetryEmitter:
|
|||||||
_cap_messages(digest_messages(request.messages), self._text_cap),
|
_cap_messages(digest_messages(request.messages), self._text_cap),
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
context = request.call_context
|
||||||
await self._recorder.record_llm_call(
|
await self._recorder.record_llm_call(
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
parent_call_id=request.parent_call_id,
|
parent_call_id=request.parent_call_id,
|
||||||
@@ -318,7 +632,7 @@ class TelemetryEmitter:
|
|||||||
ttft_ms=ttft_ms,
|
ttft_ms=ttft_ms,
|
||||||
max_inter_token_ms=max_inter_token_ms,
|
max_inter_token_ms=max_inter_token_ms,
|
||||||
cache_hit=cache_hit,
|
cache_hit=cache_hit,
|
||||||
error=error,
|
error=errors.error,
|
||||||
cost=cost,
|
cost=cost,
|
||||||
cached_prompt_tokens=cached_prompt_tokens,
|
cached_prompt_tokens=cached_prompt_tokens,
|
||||||
model_reported=model_reported,
|
model_reported=model_reported,
|
||||||
@@ -328,6 +642,24 @@ class TelemetryEmitter:
|
|||||||
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
|
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
|
||||||
tenant_id=tenant_id or "",
|
tenant_id=tenant_id or "",
|
||||||
meta=_canonical_meta_json(meta),
|
meta=_canonical_meta_json(meta),
|
||||||
|
# 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不
|
||||||
|
# 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让
|
||||||
|
# Postgres 那一路悄悄少一列数据
|
||||||
|
thinking_observation=_normalize_observation(thinking_observation),
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
|
# —— 1.3.5 十列 ——
|
||||||
|
scope=self._scope,
|
||||||
|
# 调用点给定的公开方法四值,**绝不读 `exc.operation`**(设计 §5 I1/I2)
|
||||||
|
operation=operation,
|
||||||
|
# 上下文缺席(库内现场构造的 ChatRequest)→ NULL,**不造 ID**(I5)
|
||||||
|
logical_call_id=None if context is None else context.logical_call_id,
|
||||||
|
event_kind=event_kind,
|
||||||
|
http_status_code=errors.http_status_code,
|
||||||
|
error_type=errors.error_type,
|
||||||
|
cause_type=errors.cause_type,
|
||||||
|
error_body=errors.error_body,
|
||||||
|
attempts=attempts,
|
||||||
|
total_latency_ms=total_latency_ms,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
@@ -335,36 +667,69 @@ class TelemetryEmitter:
|
|||||||
logger.warning("遥测记录失败(降级不冒泡): {}", exc)
|
logger.warning("遥测记录失败(降级不冒泡): {}", exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def emit_terminal_once(
|
||||||
|
emitter: TelemetryEmitter | None,
|
||||||
|
*,
|
||||||
|
request: ChatRequest,
|
||||||
|
context: _CallContext,
|
||||||
|
error: PolyGatewayError | str,
|
||||||
|
operation: CallOperation,
|
||||||
|
class_prefixed_error: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""三个 client 共用的**终态唯一出口**: 去重 + 同步冻结快照 + best effort 写入。
|
||||||
|
|
||||||
|
去重由 `claim_terminal()` 承担(每逻辑调用至多一条终态行);`emitter is None`
|
||||||
|
或已写过 → 直接返回。
|
||||||
|
|
||||||
|
**降级范围包含诊断字段的提取与构建**,不只是写入那一步: `_record` 内的
|
||||||
|
`except Exception` 只兜住落库,而 `_error_fields` / `canonical_sampling_json`
|
||||||
|
在它**之外**求值——下游经公共端口(自实现 `StructuredOutputStrategy` 或
|
||||||
|
transport)构造出的 `ResultInvalidError(validation_errors=(非 str,))` 会让提取期
|
||||||
|
抛 `TypeError` 顶替调用方本该收到的领域异常,错误四分类被击穿且终态行照样丢。
|
||||||
|
故在此整段兜底,与 RetryMW 的 attempt 出口(`retry.py::_emit`)同款写法。
|
||||||
|
终态行按 best effort: 兜底命中时本次逻辑调用**没有**终态行(`claim_terminal()`
|
||||||
|
已消耗,不补写、不重试——重写一遍只会把同一个提取期异常再抛一次)。
|
||||||
|
|
||||||
|
**`CancelledError` 原样传播**(取消优先,不 shield、不开后台任务): 这一次
|
||||||
|
`await` 本身就是新的取消点,外部取消落在它上时调用方会看到 `CancelledError`
|
||||||
|
而非领域错误——与 TelemetryMW 的历史行为同款,已经人类批准(设计 §6/§10)。
|
||||||
|
快照冻结是**同步**动作,故终态行不含它自身的写入耗时。
|
||||||
|
"""
|
||||||
|
if emitter is None or not context.claim_terminal():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
stats = context.snapshot()
|
||||||
|
await emitter.emit_terminal_failure(
|
||||||
|
request=request,
|
||||||
|
call_id=str(uuid.uuid4()),
|
||||||
|
error=error,
|
||||||
|
operation=operation,
|
||||||
|
stats=stats,
|
||||||
|
class_prefixed_error=class_prefixed_error,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("终态遥测记录失败(降级不冒泡): {}", exc)
|
||||||
|
|
||||||
|
|
||||||
class TelemetryMW:
|
class TelemetryMW:
|
||||||
"""洋葱最外层: 观测尝试层看不见的路径,任何路径都留痕(遥测必录)。"""
|
"""洋葱最外层: 只观测尝试层看不见的**缓存命中**。
|
||||||
|
|
||||||
|
1.3.5 起不再在此写终态失败行: 终态由 `GatewayClient.chat` 的公开边界经
|
||||||
|
`emit_terminal_once` 统一写出。两处同时写会让同一次失败出两条终态行,
|
||||||
|
而下游正是按 `event_kind = 'terminal_failure'` 计失败调用次数的。
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic
|
self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic
|
||||||
) -> None:
|
) -> None:
|
||||||
self._emitter = emitter
|
self._emitter = emitter
|
||||||
self._now = now
|
# `now` 自 1.3.5 起本类不再读取(终态行迁到公开边界后无耗时可测),但形参保留:
|
||||||
|
# 删它会平白打断 `TelemetryMW(emitter, now=...)` 这一既有装配写法
|
||||||
|
|
||||||
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
|
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
|
||||||
started = self._now()
|
response = await call_next(request)
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
except (GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError) as exc:
|
|
||||||
await self._emitter.emit_terminal_failure(
|
|
||||||
request=request,
|
|
||||||
call_id=str(uuid.uuid4()),
|
|
||||||
latency_ms=int((self._now() - started) * 1000),
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# 尽力而为: 取消也留痕(§5.1 约定④);随后立即重抛
|
|
||||||
await self._emitter.emit_terminal_failure(
|
|
||||||
request=request,
|
|
||||||
call_id=str(uuid.uuid4()),
|
|
||||||
latency_ms=int((self._now() - started) * 1000),
|
|
||||||
error="cancelled",
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
if response.cache_hit:
|
if response.cache_hit:
|
||||||
await self._emitter.emit_cache_hit(request=request, response=response)
|
await self._emitter.emit_cache_hit(request=request, response=response, operation="chat")
|
||||||
return response
|
return response
|
||||||
|
|||||||
+190
-38
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.client import _aclose_component, _telemetry_status_of
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
GovernanceBackendError,
|
GovernanceBackendError,
|
||||||
@@ -36,14 +37,17 @@ from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
|||||||
from polygateway.middleware.breaker import BreakerGate
|
from polygateway.middleware.breaker import BreakerGate
|
||||||
from polygateway.middleware.ratelimit import QuotaGate
|
from polygateway.middleware.ratelimit import QuotaGate
|
||||||
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
|
||||||
from polygateway.ports import OutcomeAwareSelector
|
from polygateway.ports import OutcomeAwareSelector
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
|
CallStats,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
OcrLayoutResult,
|
OcrLayoutResult,
|
||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
|
TelemetryStatus,
|
||||||
Usage,
|
Usage,
|
||||||
|
_CallContext,
|
||||||
strip_unsupported_extra_body,
|
strip_unsupported_extra_body,
|
||||||
validate_caller_dimensions,
|
validate_caller_dimensions,
|
||||||
)
|
)
|
||||||
@@ -63,6 +67,7 @@ if TYPE_CHECKING:
|
|||||||
)
|
)
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
|
CallOperation,
|
||||||
OcrLayoutTransportResult,
|
OcrLayoutTransportResult,
|
||||||
OcrTextTransportResult,
|
OcrTextTransportResult,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
@@ -124,8 +129,19 @@ class OcrClient:
|
|||||||
self._breaker = BreakerGate(breaker, scope=self._scope)
|
self._breaker = BreakerGate(breaker, scope=self._scope)
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._retry = retry
|
self._retry = retry
|
||||||
self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None
|
self._emitter = (
|
||||||
|
TelemetryEmitter(telemetry, scope=self._scope, text_cap=text_cap) if telemetry else None
|
||||||
|
)
|
||||||
self._telemetry = telemetry
|
self._telemetry = telemetry
|
||||||
|
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
|
||||||
|
# 引用才关得到自建的 redis 客户端(设计 §3.4)
|
||||||
|
self._limiter_backend = limiter
|
||||||
|
self._breaker_backend = breaker
|
||||||
|
# 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True
|
||||||
|
self._owns_transport = False
|
||||||
|
self._owns_telemetry = False
|
||||||
|
self._owns_limiter = False
|
||||||
|
self._owns_breaker = False
|
||||||
self._now = now
|
self._now = now
|
||||||
self._sleep = sleep
|
self._sleep = sleep
|
||||||
self._rng = rng
|
self._rng = rng
|
||||||
@@ -165,8 +181,14 @@ class OcrClient:
|
|||||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||||
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
|
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
|
||||||
)
|
)
|
||||||
outcome = await self._call(
|
outcome, call_stats = await self._call(
|
||||||
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
|
"text",
|
||||||
|
"recognize_text",
|
||||||
|
image,
|
||||||
|
session_id,
|
||||||
|
parent_call_id,
|
||||||
|
dimension_tenant_id,
|
||||||
|
dimensions,
|
||||||
)
|
)
|
||||||
result = outcome.result
|
result = outcome.result
|
||||||
return OcrTextResult(
|
return OcrTextResult(
|
||||||
@@ -176,6 +198,7 @@ class OcrClient:
|
|||||||
latency_ms=outcome.latency_ms,
|
latency_ms=outcome.latency_ms,
|
||||||
call_id=outcome.call_id,
|
call_id=outcome.call_id,
|
||||||
raw=result.raw,
|
raw=result.raw,
|
||||||
|
call_stats=call_stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def parse_layout(
|
async def parse_layout(
|
||||||
@@ -195,8 +218,14 @@ class OcrClient:
|
|||||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||||
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
|
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
|
||||||
)
|
)
|
||||||
outcome = await self._call(
|
outcome, call_stats = await self._call(
|
||||||
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
|
"layout",
|
||||||
|
"parse_layout",
|
||||||
|
image,
|
||||||
|
session_id,
|
||||||
|
parent_call_id,
|
||||||
|
dimension_tenant_id,
|
||||||
|
dimensions,
|
||||||
)
|
)
|
||||||
result = outcome.result
|
result = outcome.result
|
||||||
return OcrLayoutResult(
|
return OcrLayoutResult(
|
||||||
@@ -207,6 +236,7 @@ class OcrClient:
|
|||||||
latency_ms=outcome.latency_ms,
|
latency_ms=outcome.latency_ms,
|
||||||
call_id=outcome.call_id,
|
call_id=outcome.call_id,
|
||||||
raw=result.raw,
|
raw=result.raw,
|
||||||
|
call_stats=call_stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def check_health(self) -> dict[str, bool]:
|
async def check_health(self) -> dict[str, bool]:
|
||||||
@@ -226,16 +256,60 @@ class OcrClient:
|
|||||||
async def _call(
|
async def _call(
|
||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
|
operation: CallOperation,
|
||||||
image: bytes,
|
image: bytes,
|
||||||
session_id: str | None,
|
session_id: str | None,
|
||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
) -> _AttemptOutcome:
|
) -> tuple[_AttemptOutcome, CallStats]:
|
||||||
if not isinstance(image, bytes):
|
if not isinstance(image, bytes):
|
||||||
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
||||||
if not image:
|
if not image:
|
||||||
raise ValueError("image 不能为空")
|
raise ValueError("image 不能为空")
|
||||||
|
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
|
||||||
|
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
|
||||||
|
context = _CallContext(now=self._now)
|
||||||
|
try:
|
||||||
|
return await self._run(
|
||||||
|
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context
|
||||||
|
)
|
||||||
|
except PolyGatewayError as exc:
|
||||||
|
await self._emit_terminal(
|
||||||
|
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context, exc
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# 三条链路同一口径尽力写一条(允许 0 条);取消优先,不 shield
|
||||||
|
await self._emit_terminal(
|
||||||
|
kind,
|
||||||
|
operation,
|
||||||
|
image,
|
||||||
|
session_id,
|
||||||
|
parent_call_id,
|
||||||
|
tenant_id,
|
||||||
|
meta,
|
||||||
|
context,
|
||||||
|
"cancelled",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
self,
|
||||||
|
kind: _OcrKind,
|
||||||
|
operation: CallOperation,
|
||||||
|
image: bytes,
|
||||||
|
session_id: str | None,
|
||||||
|
parent_call_id: str | None,
|
||||||
|
tenant_id: str | None,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
|
) -> tuple[_AttemptOutcome, CallStats]:
|
||||||
|
"""选源与重试循环。
|
||||||
|
|
||||||
|
无源的 raise 必须在本方法内(而非循环之前的调用方): 它得被 `_call` 的
|
||||||
|
`try` 包住,否则无源终态行根本写不出来(设计 §3.5)。
|
||||||
|
"""
|
||||||
if not self._sources:
|
if not self._sources:
|
||||||
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
|
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
|
||||||
fails = 0
|
fails = 0
|
||||||
@@ -249,10 +323,19 @@ class OcrClient:
|
|||||||
continue
|
continue
|
||||||
async with clock.attempting():
|
async with clock.attempting():
|
||||||
outcome = await self._attempt(
|
outcome = await self._attempt(
|
||||||
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
|
kind,
|
||||||
|
operation,
|
||||||
|
image,
|
||||||
|
*picked,
|
||||||
|
reasons,
|
||||||
|
session_id,
|
||||||
|
parent_call_id,
|
||||||
|
tenant_id,
|
||||||
|
meta,
|
||||||
|
context,
|
||||||
)
|
)
|
||||||
if isinstance(outcome, _AttemptOutcome):
|
if isinstance(outcome, _AttemptOutcome):
|
||||||
return outcome
|
return outcome, context.snapshot()
|
||||||
fails += 1
|
fails += 1
|
||||||
if fails >= self._retry.max_attempts:
|
if fails >= self._retry.max_attempts:
|
||||||
raise AllSourcesExhausted(
|
raise AllSourcesExhausted(
|
||||||
@@ -267,6 +350,7 @@ class OcrClient:
|
|||||||
async def _attempt(
|
async def _attempt(
|
||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
|
operation: CallOperation,
|
||||||
image: bytes,
|
image: bytes,
|
||||||
source: SourceConfig,
|
source: SourceConfig,
|
||||||
permit: Permit,
|
permit: Permit,
|
||||||
@@ -276,11 +360,14 @@ class OcrClient:
|
|||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
) -> _AttemptOutcome | _FailedAttempt:
|
) -> _AttemptOutcome | _FailedAttempt:
|
||||||
call_id = str(uuid.uuid4())
|
call_id = str(uuid.uuid4())
|
||||||
started = self._now()
|
started = self._now()
|
||||||
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
|
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
|
||||||
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
|
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
|
||||||
|
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
|
||||||
|
context.register_attempt()
|
||||||
try:
|
try:
|
||||||
result = await self._invoke(kind, image, source, call_id)
|
result = await self._invoke(kind, image, source, call_id)
|
||||||
await self._record_quietly(self._breaker.record_success(entry))
|
await self._record_quietly(self._breaker.record_success(entry))
|
||||||
@@ -289,6 +376,7 @@ class OcrClient:
|
|||||||
latency_ms = int((self._now() - started) * 1000)
|
latency_ms = int((self._now() - started) * 1000)
|
||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
|
operation,
|
||||||
image,
|
image,
|
||||||
source,
|
source,
|
||||||
call_id,
|
call_id,
|
||||||
@@ -297,6 +385,7 @@ class OcrClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
context,
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
return _AttemptOutcome(result, source, call_id, latency_ms)
|
return _AttemptOutcome(result, source, call_id, latency_ms)
|
||||||
@@ -304,6 +393,7 @@ class OcrClient:
|
|||||||
await self._gate_on_terminal(exc, entry)
|
await self._gate_on_terminal(exc, entry)
|
||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
|
operation,
|
||||||
image,
|
image,
|
||||||
source,
|
source,
|
||||||
call_id,
|
call_id,
|
||||||
@@ -312,6 +402,7 @@ class OcrClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
context,
|
||||||
error=exc,
|
error=exc,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
@@ -320,6 +411,7 @@ class OcrClient:
|
|||||||
await self._record_quietly(self._breaker.release_probe(entry))
|
await self._record_quietly(self._breaker.release_probe(entry))
|
||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
|
operation,
|
||||||
image,
|
image,
|
||||||
source,
|
source,
|
||||||
call_id,
|
call_id,
|
||||||
@@ -328,6 +420,7 @@ class OcrClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
context,
|
||||||
error="cancelled",
|
error="cancelled",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
@@ -339,6 +432,7 @@ class OcrClient:
|
|||||||
self._feed_outcome(source.name, ok=False)
|
self._feed_outcome(source.name, ok=False)
|
||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
|
operation,
|
||||||
image,
|
image,
|
||||||
source,
|
source,
|
||||||
call_id,
|
call_id,
|
||||||
@@ -347,6 +441,7 @@ class OcrClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
context,
|
||||||
error=exc,
|
error=exc,
|
||||||
)
|
)
|
||||||
return _FailedAttempt(exc, immediate=dead)
|
return _FailedAttempt(exc, immediate=dead)
|
||||||
@@ -388,9 +483,60 @@ class OcrClient:
|
|||||||
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
|
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
|
||||||
logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc)
|
logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc)
|
||||||
|
|
||||||
|
def _request_for(
|
||||||
|
self,
|
||||||
|
kind: _OcrKind,
|
||||||
|
image: bytes,
|
||||||
|
session_id: str | None,
|
||||||
|
parent_call_id: str | None,
|
||||||
|
tenant_id: str | None,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
|
) -> ChatRequest:
|
||||||
|
"""OCR 行的现场 ChatRequest: 占位摘要,**图像 bytes 永不入库**。
|
||||||
|
|
||||||
|
尝试行与终态行共用同一个构造点: 占位字面量复制成两份就会漂移。
|
||||||
|
调用方维度与上下文必须显式填回(OCR 不走 chat 洋葱),否则 OCR 行的
|
||||||
|
维度恒为空、`logical_call_id` 恒为 NULL。
|
||||||
|
"""
|
||||||
|
return 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,
|
||||||
|
call_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _emit_terminal(
|
||||||
|
self,
|
||||||
|
kind: _OcrKind,
|
||||||
|
operation: CallOperation,
|
||||||
|
image: bytes,
|
||||||
|
session_id: str | None,
|
||||||
|
parent_call_id: str | None,
|
||||||
|
tenant_id: str | None,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
|
error: PolyGatewayError | str,
|
||||||
|
) -> None:
|
||||||
|
"""终态行: 沿用 `<ocr:{kind} image_bytes=…>` 占位,错误文本保留类名前缀。"""
|
||||||
|
await emit_terminal_once(
|
||||||
|
self._emitter,
|
||||||
|
request=self._request_for(
|
||||||
|
kind, image, session_id, parent_call_id, tenant_id, meta, context
|
||||||
|
),
|
||||||
|
context=context,
|
||||||
|
error=error,
|
||||||
|
operation=operation,
|
||||||
|
# metric ocr-call-success 的注册口径是按类名归组,终态行同款保留
|
||||||
|
class_prefixed_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def _emit(
|
async def _emit(
|
||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
|
operation: CallOperation,
|
||||||
image: bytes,
|
image: bytes,
|
||||||
source: SourceConfig,
|
source: SourceConfig,
|
||||||
call_id: str,
|
call_id: str,
|
||||||
@@ -399,20 +545,15 @@ class OcrClient:
|
|||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
|
context: _CallContext,
|
||||||
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
|
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
|
||||||
error: object | None = None,
|
error: PolyGatewayError | str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
|
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
|
||||||
if self._emitter is None:
|
if self._emitter is None:
|
||||||
return
|
return
|
||||||
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
|
request = self._request_for(
|
||||||
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
|
kind, image, session_id, parent_call_id, tenant_id, meta, context
|
||||||
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)
|
latency_ms = int((self._now() - started) * 1000)
|
||||||
response = None
|
response = None
|
||||||
@@ -432,18 +573,19 @@ class OcrClient:
|
|||||||
source_name=source.name,
|
source_name=source.name,
|
||||||
usage_source="measured",
|
usage_source="measured",
|
||||||
)
|
)
|
||||||
# 错误带异常类名前缀(metric ocr-call-success 注册口径: 按类名归组)
|
# 错误文本的类名前缀现由出口的显式策略参数承担(设计 §6 I7):
|
||||||
if error is None or isinstance(error, str):
|
# 三处各拼一遍才是下一次漂移的种子,而取消行传的是字符串,不受前缀影响
|
||||||
error_text = error
|
|
||||||
else:
|
|
||||||
error_text = f"{type(error).__name__}: {error}"
|
|
||||||
await self._emitter.emit_attempt(
|
await self._emitter.emit_attempt(
|
||||||
request=request,
|
request=request,
|
||||||
source=source,
|
source=source,
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
response=response,
|
response=response,
|
||||||
error=error_text,
|
error=error,
|
||||||
|
# OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding)
|
||||||
|
reasoning_applies=False,
|
||||||
|
operation=operation,
|
||||||
|
class_prefixed_error=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -454,21 +596,28 @@ class OcrClient:
|
|||||||
|
|
||||||
# —— 生命周期 ——
|
# —— 生命周期 ——
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_status(self) -> TelemetryStatus | None:
|
||||||
|
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
|
||||||
|
|
||||||
|
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
|
||||||
|
的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。
|
||||||
|
"""
|
||||||
|
return _telemetry_status_of(self._telemetry)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""幂等释放 transport 连接池与遥测连接(与 EmbeddingClient 对称)。"""
|
"""幂等释放**自建**资源(与 EmbeddingClient 对称);注入的组件一律不碰。"""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
transport_aclose = getattr(self._transport, "aclose", None)
|
if self._owns_transport:
|
||||||
if transport_aclose is not None:
|
await _aclose_component(self._transport)
|
||||||
await transport_aclose()
|
if self._owns_telemetry:
|
||||||
telemetry_aclose = getattr(self._telemetry, "aclose", None)
|
await _aclose_component(self._telemetry)
|
||||||
if telemetry_aclose is not None:
|
if self._owns_limiter:
|
||||||
await telemetry_aclose()
|
await _aclose_component(self._limiter_backend)
|
||||||
else:
|
if self._owns_breaker:
|
||||||
telemetry_close = getattr(self._telemetry, "close", None)
|
await _aclose_component(self._breaker_backend)
|
||||||
if telemetry_close is not None:
|
|
||||||
telemetry_close()
|
|
||||||
|
|
||||||
async def __aenter__(self) -> OcrClient:
|
async def __aenter__(self) -> OcrClient:
|
||||||
return self
|
return self
|
||||||
@@ -493,6 +642,7 @@ class OcrClient:
|
|||||||
_build_limiter,
|
_build_limiter,
|
||||||
_build_selector,
|
_build_selector,
|
||||||
_build_telemetry,
|
_build_telemetry,
|
||||||
|
_mark_owned_components,
|
||||||
)
|
)
|
||||||
from polygateway.transports.monkey_ocr import MonkeyOcrTransport
|
from polygateway.transports.monkey_ocr import MonkeyOcrTransport
|
||||||
|
|
||||||
@@ -503,12 +653,12 @@ class OcrClient:
|
|||||||
alien = sorted({s.provider for s in sources if s.provider != "monkey"})
|
alien = sorted({s.provider for s in sources if s.provider != "monkey"})
|
||||||
if alien:
|
if alien:
|
||||||
raise ValueError(f"OCR 装配仅支持 provider=monkey(D9 其余后端预留未实现): 发现 {alien}")
|
raise ValueError(f"OCR 装配仅支持 provider=monkey(D9 其余后端预留未实现): 发现 {alien}")
|
||||||
return cls(
|
client = cls(
|
||||||
scope=gw.scope,
|
scope=gw.scope,
|
||||||
sources=sources,
|
sources=sources,
|
||||||
selector=_build_selector(gw.selector),
|
selector=_build_selector(gw.selector),
|
||||||
limiter=limiter or _build_limiter(gw, sources),
|
limiter=limiter if limiter is not None else _build_limiter(gw, sources),
|
||||||
breaker=breaker or _build_breaker(gw),
|
breaker=breaker if breaker is not None else _build_breaker(gw),
|
||||||
transport=MonkeyOcrTransport(),
|
transport=MonkeyOcrTransport(),
|
||||||
retry=gw.retry,
|
retry=gw.retry,
|
||||||
backpressure=gw.backpressure,
|
backpressure=gw.backpressure,
|
||||||
@@ -519,6 +669,8 @@ class OcrClient:
|
|||||||
# 一半不受控(issue #12)
|
# 一半不受控(issue #12)
|
||||||
text_cap=gw.telemetry_text_cap,
|
text_cap=gw.telemetry_text_cap,
|
||||||
)
|
)
|
||||||
|
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||||
|
return client
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any, Protocol, runtime_checkable
|
|||||||
|
|
||||||
from .types import (
|
from .types import (
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
|
Effort,
|
||||||
EmbeddingTransportResult,
|
EmbeddingTransportResult,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
OcrLayoutResult,
|
OcrLayoutResult,
|
||||||
@@ -20,6 +21,7 @@ from .types import (
|
|||||||
OcrTextTransportResult,
|
OcrTextTransportResult,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
SourceStats,
|
SourceStats,
|
||||||
|
TelemetryStatus,
|
||||||
TransportResult,
|
TransportResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +37,16 @@ class Middleware(Protocol):
|
|||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class Transport(Protocol):
|
class Transport(Protocol):
|
||||||
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。"""
|
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
|
||||||
|
|
||||||
|
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
|
||||||
|
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
|
||||||
|
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
|
||||||
|
|
||||||
|
该参数**不设默认值**,与 `TelemetryRecorder.record_llm_call` 同一既有约定:
|
||||||
|
库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的
|
||||||
|
"调用方没表态"——一次本该报错的漏配就此变成一次悄悄涨价的调用。
|
||||||
|
"""
|
||||||
|
|
||||||
async def complete(
|
async def complete(
|
||||||
self,
|
self,
|
||||||
@@ -45,6 +56,7 @@ class Transport(Protocol):
|
|||||||
stream: bool,
|
stream: bool,
|
||||||
overlay: dict[str, Any],
|
overlay: dict[str, Any],
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
reasoning_effort: Effort | None,
|
||||||
) -> TransportResult: ...
|
) -> TransportResult: ...
|
||||||
|
|
||||||
|
|
||||||
@@ -243,17 +255,46 @@ class StructuredOutputStrategy(Protocol):
|
|||||||
def parse(self, text: str) -> Any: ...
|
def parse(self, text: str) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TelemetryStatusProvider(Protocol):
|
||||||
|
"""可自述可写状态的遥测后端;`TelemetryRecorder` 的**可选**伴生端口(issue #15)。
|
||||||
|
|
||||||
|
与 `TelemetryRecorder` 分开而不是给它加成员,是因为后者是 `@runtime_checkable`
|
||||||
|
而运行时检查按属性存在性做: 加一个属性会让所有只实现 `record_llm_call` 的
|
||||||
|
实现**当场不再是** `TelemetryRecorder`,下游若有同款 isinstance 断言,升级即断
|
||||||
|
(设计 §3.3)。消费方一律先 isinstance 再取值,取不到就当没有状态可报。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_status(self) -> TelemetryStatus: ...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class TelemetryRecorder(Protocol):
|
class TelemetryRecorder(Protocol):
|
||||||
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。
|
"""遥测后端;36 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5),唯一调用点是 TelemetryEmitter。
|
||||||
|
|
||||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||||
|
|
||||||
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
|
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
|
||||||
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
|
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
|
||||||
|
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
|
||||||
|
的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受,
|
||||||
|
遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。
|
||||||
|
`reasoning_effort` 同一先例(issue #20): emitter 已把 `Effort` 取成 `.value`
|
||||||
|
的裸 `str`,`None` 表示调用方没表态——它与 `'none'`(明确要求不推理)不可折叠。
|
||||||
|
|
||||||
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
|
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
|
||||||
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
|
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
|
||||||
|
|
||||||
|
1.3.5 新增十列同理已在 emitter 侧定型: `operation` 是**公开方法**四值之一
|
||||||
|
(与 `PolyGatewayError.operation` 这个 HTTP 子操作是两个语义);`event_kind` 区分
|
||||||
|
attempt / cache_hit / terminal_failure 三类行;`attempts` 与 `total_latency_ms`
|
||||||
|
只在终态行非空;`error_body` 沿用 transport 侧 `summarize_body` 的上限,
|
||||||
|
**不进 `PGW_TELEMETRY_TEXT_CAP` 的覆盖面**。
|
||||||
|
|
||||||
|
**本签名是装配闸的唯一事实源**: `TelemetryEmitter.__init__` 按它派生参数名做
|
||||||
|
一次 `signature.bind` 形状校验(设计 §7),改本签名即改闸的判据。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def record_llm_call(
|
async def record_llm_call(
|
||||||
@@ -283,4 +324,16 @@ class TelemetryRecorder(Protocol):
|
|||||||
reasoning_tokens: int | None,
|
reasoning_tokens: int | None,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
meta: str,
|
meta: str,
|
||||||
|
thinking_observation: str,
|
||||||
|
reasoning_effort: str | None,
|
||||||
|
scope: str,
|
||||||
|
operation: str,
|
||||||
|
logical_call_id: str | None,
|
||||||
|
event_kind: str,
|
||||||
|
http_status_code: int | None,
|
||||||
|
error_type: str | None,
|
||||||
|
cause_type: str | None,
|
||||||
|
error_body: str | None,
|
||||||
|
attempts: int | None,
|
||||||
|
total_latency_ms: int | None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|||||||
+115
-180
@@ -3,6 +3,9 @@
|
|||||||
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
|
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
|
||||||
**精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享
|
**精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享
|
||||||
状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。
|
状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。
|
||||||
|
|
||||||
|
**本模块只存放声明,不做判断**: 拿这些声明去决定注入什么、响应算不算推理,
|
||||||
|
全部在 `thinking.py`(P7 决策逻辑与状态存储分离)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -10,221 +13,153 @@ from dataclasses import dataclass
|
|||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingWire:
|
||||||
|
"""一个 provider 表达"开/关/多深"的请求体形态(设计 §3.3)。
|
||||||
|
|
||||||
|
三个字段各自的 `None` **语义互不重叠**,混淆任意两个都会退回 issue #5 修掉的
|
||||||
|
那种静默失效:
|
||||||
|
|
||||||
|
============== ==========================================================
|
||||||
|
``on_base=None`` **形态未知**: 本库不知道该 provider 如何表达"开",配了开关
|
||||||
|
即装配期报错并指路 `register_provider`/`extra_body`
|
||||||
|
``off=None`` 已知开启形态,但**没有关闭形态**(该 provider 关不掉)
|
||||||
|
``effort_key`` ``None`` = 该 provider 只有开关、没有档位(qwen 系靠
|
||||||
|
``=None`` ``thinking_budget`` 调深度,不是档位)
|
||||||
|
============== ==========================================================
|
||||||
|
|
||||||
|
`on_base={}` 与 `on_base=None` 不可混: 前者是协议无需额外开启字节,
|
||||||
|
是否满足 AUTO 由模型能力清单决定;后者是“不知道怎么表达”。
|
||||||
|
|
||||||
|
**为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat /
|
||||||
|
openai-responses / anthropic-messages / google-generate-content 四种端点协议,
|
||||||
|
故需要 closed operation 集合与 `budgetWire` 代际变体。本库只有一个 OpenAI 兼容
|
||||||
|
transport,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent),
|
||||||
|
一个协议一层形态即够(P1 YAGNI)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
off: Mapping[str, Any] | None
|
||||||
|
on_base: Mapping[str, Any] | None
|
||||||
|
effort_key: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProviderProfile:
|
class ProviderProfile:
|
||||||
"""单个 provider 的能力与差异声明。
|
"""单个 provider 的能力与差异声明。
|
||||||
|
|
||||||
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为
|
`thinking` 声明推理参数的**形态**(按 provider 变,数年不变一次);
|
||||||
True/False 时并入请求体的参数片段(`enable_thinking` 为 None 时二者都不
|
`strip_think_tags` 声明响应 content 需剥离 ``<think>`` 标签(qwen 系);
|
||||||
注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ``<think>``
|
`supports_native_schema` 供 D14 阶梯选择原生 response_format。
|
||||||
标签(qwen 系);supports_native_schema 供 D14 阶梯选择原生 response_format。
|
|
||||||
|
|
||||||
两档各有三种取值,**语义互不重叠**(issue #5):
|
注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型支持哪些
|
||||||
|
档位属**能力**(按 model 变),见 `thinking.ThinkingCapability`。二者合一在
|
||||||
========== ==========================================================
|
provider 级表达不了代际差异——glm-5.2 能关而 glm-5.3 不能,形态却完全相同。
|
||||||
``{...}`` 已知的注入片段
|
|
||||||
``{}`` 已知**无需注入**任何参数即处于该档
|
|
||||||
``None`` **未知**: 本库不知道该 provider 如何表达这一档
|
|
||||||
========== ==========================================================
|
|
||||||
|
|
||||||
`None` 与 `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False`
|
|
||||||
对 minimax/openai 源静默失效——调用方以为关掉了推理,实际什么都没发生。
|
|
||||||
现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`。
|
|
||||||
|
|
||||||
注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型能否
|
|
||||||
关闭推理属**能力**(按 model 变),见 `ThinkingCapability`。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
thinking_on: Mapping[str, Any] | None
|
thinking: ThinkingWire
|
||||||
thinking_off: Mapping[str, Any] | None
|
|
||||||
strip_think_tags: bool
|
strip_think_tags: bool
|
||||||
supports_native_schema: bool = False
|
supports_native_schema: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# 经 new-api 中转的口径。四家参考实现(cherry-studio / OpenRouter / LiteLLM /
|
||||||
|
# new-api 自身)一致的结论: OpenAI 兼容端点上,档位一律走标准的 `reasoning_effort`,
|
||||||
|
# 跨协议转换(→ Claude 的 thinking、Gemini 的 thinkingConfig)由网关服务端完成。
|
||||||
DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
|
DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
|
||||||
{
|
{
|
||||||
# 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态)
|
# 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态)
|
||||||
"qwen": ProviderProfile(
|
"qwen": ProviderProfile(
|
||||||
name="qwen",
|
name="qwen",
|
||||||
thinking_on={"enable_thinking": True},
|
thinking=ThinkingWire(
|
||||||
thinking_off={"enable_thinking": False},
|
off={"enable_thinking": False},
|
||||||
|
on_base={"enable_thinking": True},
|
||||||
|
# 百炼的深度控制是 `thinking_budget`(token 预算)而非档位;
|
||||||
|
# 预算型控制本库当前不支持(设计 §11 明确不做)
|
||||||
|
effort_key=None,
|
||||||
|
),
|
||||||
strip_think_tags=True,
|
strip_think_tags=True,
|
||||||
),
|
),
|
||||||
|
# 官方 thinking_mode 文档: thinking:{type} 是开关,reasoning_effort 是深度,
|
||||||
|
# V4 一代两者并用(deepseek-v4-* 的档位见能力表)
|
||||||
"deepseek": ProviderProfile(
|
"deepseek": ProviderProfile(
|
||||||
name="deepseek",
|
name="deepseek",
|
||||||
thinking_on={"thinking": {"type": "enabled"}},
|
thinking=ThinkingWire(
|
||||||
thinking_off={"thinking": {"type": "disabled"}},
|
off={"thinking": {"type": "disabled"}},
|
||||||
|
on_base={"thinking": {"type": "enabled"}},
|
||||||
|
effort_key="reasoning_effort",
|
||||||
|
),
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
),
|
),
|
||||||
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把
|
# issue #20。智谱官方迁移建议原文: 原先用 {"type":"disabled"} 的应改为
|
||||||
# kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给
|
# {"type":"enabled"} + reasoning_effort="low"——GLM-5.3 起 thinking.type
|
||||||
# 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在
|
# 不再接受 disabled,故"关"这一档由能力表按型号裁定(5.2 能关,5.3 不能)
|
||||||
# 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider
|
"zhipu": ProviderProfile(
|
||||||
"openai": ProviderProfile(
|
name="zhipu",
|
||||||
name="openai",
|
thinking=ThinkingWire(
|
||||||
thinking_on=None,
|
off={"thinking": {"type": "disabled"}},
|
||||||
thinking_off=None,
|
on_base={"thinking": {"type": "enabled"}},
|
||||||
|
effort_key="reasoning_effort",
|
||||||
|
),
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
),
|
),
|
||||||
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2),
|
# kimi-k3 的档位是 low/high/max;thinking.type 为月之暗面的开关形态
|
||||||
# **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被
|
"moonshot": ProviderProfile(
|
||||||
# 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。
|
name="moonshot",
|
||||||
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的
|
thinking=ThinkingWire(
|
||||||
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近
|
off={"thinking": {"type": "disabled"}},
|
||||||
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。
|
on_base={"thinking": {"type": "enabled"}},
|
||||||
# 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段)
|
effort_key="reasoning_effort",
|
||||||
|
),
|
||||||
|
strip_think_tags=False,
|
||||||
|
),
|
||||||
|
# 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。
|
||||||
|
# enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线
|
||||||
|
# 194),reasoning_effort 才是真开关——本段形态据此成立。
|
||||||
|
# 开启片段不代选强度;AUTO 可满足性由具体模型能力清单决定。
|
||||||
"minimax": ProviderProfile(
|
"minimax": ProviderProfile(
|
||||||
name="minimax",
|
name="minimax",
|
||||||
thinking_on={"reasoning_effort": "medium"},
|
thinking=ThinkingWire(
|
||||||
thinking_off={"reasoning_effort": "none"},
|
off={"reasoning_effort": "none"},
|
||||||
|
on_base={},
|
||||||
|
effort_key="reasoning_effort",
|
||||||
|
),
|
||||||
|
strip_think_tags=False,
|
||||||
|
),
|
||||||
|
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底。两档此前标
|
||||||
|
# None(未知),因为当时无法区分"厂商方言"与"标准字段";`reasoning_effort`
|
||||||
|
# 是 OpenAI **官方**字段而非方言,发给经网关的兼容端点不会打到不认识它的
|
||||||
|
# 厂商,故 2026-09-04 起给出标准形态。真正形态未知的 provider 仍走
|
||||||
|
# register_provider 注册,而不是挂在本段下
|
||||||
|
"openai": ProviderProfile(
|
||||||
|
name="openai",
|
||||||
|
thinking=ThinkingWire(
|
||||||
|
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
|
||||||
|
),
|
||||||
|
strip_think_tags=False,
|
||||||
|
),
|
||||||
|
# Claude 5 系原生是 thinking.type=adaptive + output_config.effort,Gemini 3 系
|
||||||
|
# 原生是 thinkingConfig.thinkingLevel;两者的代际方言(Claude ≤4.5 的
|
||||||
|
# budget_tokens、Gemini 2.x 的 thinkingBudget)由 new-api 的 canonical intent
|
||||||
|
# 层吸收,本库只发 OpenAI 形态(设计 §3.4)
|
||||||
|
"anthropic": ProviderProfile(
|
||||||
|
name="anthropic",
|
||||||
|
thinking=ThinkingWire(
|
||||||
|
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
|
||||||
|
),
|
||||||
|
strip_think_tags=False,
|
||||||
|
),
|
||||||
|
"google": ProviderProfile(
|
||||||
|
name="google",
|
||||||
|
thinking=ThinkingWire(
|
||||||
|
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
|
||||||
|
),
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ThinkingUnsupportedError(ValueError):
|
|
||||||
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
|
|
||||||
|
|
||||||
是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置**
|
|
||||||
不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期
|
|
||||||
捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让
|
|
||||||
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
|
|
||||||
错误误贴成"推理开关无法满足")。
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ThinkingCapability:
|
|
||||||
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
|
|
||||||
|
|
||||||
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
|
|
||||||
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
|
|
||||||
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
|
|
||||||
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
|
|
||||||
|
|
||||||
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
|
|
||||||
"""
|
|
||||||
|
|
||||||
can_disable: bool
|
|
||||||
evidence: str
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
|
|
||||||
{
|
|
||||||
"MiniMax-M3": ThinkingCapability(
|
|
||||||
can_disable=True,
|
|
||||||
evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变",
|
|
||||||
),
|
|
||||||
"MiniMax-M2.7": ThinkingCapability(
|
|
||||||
can_disable=False,
|
|
||||||
evidence=(
|
|
||||||
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
|
|
||||||
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
"MiniMax-M2.5": ThinkingCapability(
|
|
||||||
can_disable=False,
|
|
||||||
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理",
|
|
||||||
),
|
|
||||||
"qwen3.7-plus": ThinkingCapability(
|
|
||||||
can_disable=True,
|
|
||||||
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)",
|
|
||||||
),
|
|
||||||
"deepseek-v4-pro": ThinkingCapability(
|
|
||||||
can_disable=True,
|
|
||||||
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_capability(
|
|
||||||
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
|
||||||
) -> ThinkingCapability | None:
|
|
||||||
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
|
|
||||||
|
|
||||||
与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
|
|
||||||
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。
|
|
||||||
"""
|
|
||||||
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
|
|
||||||
|
|
||||||
|
|
||||||
def register_capability(
|
|
||||||
model: str,
|
|
||||||
capability: ThinkingCapability,
|
|
||||||
*,
|
|
||||||
base: Mapping[str, ThinkingCapability] | None = None,
|
|
||||||
) -> dict[str, ThinkingCapability]:
|
|
||||||
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
|
||||||
table = dict(DEFAULT_CAPABILITIES if base is None else base)
|
|
||||||
table[model] = capability
|
|
||||||
return table
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_thinking(
|
|
||||||
profile: ProviderProfile,
|
|
||||||
capability: ThinkingCapability | None,
|
|
||||||
enable_thinking: bool | None,
|
|
||||||
*,
|
|
||||||
model: str,
|
|
||||||
warn_unregistered: bool = True,
|
|
||||||
) -> Mapping[str, Any]:
|
|
||||||
"""三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。
|
|
||||||
|
|
||||||
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
|
|
||||||
`RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时
|
|
||||||
无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有
|
|
||||||
`can_disable` 可读,故 Phase 3 必须先于 Phase 4。
|
|
||||||
|
|
||||||
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
|
||||||
`capability` 为 None(未登记)时无从从别处取得模型名。
|
|
||||||
|
|
||||||
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次
|
|
||||||
调用再喊只会刷屏。判定结果不受此参数影响。
|
|
||||||
"""
|
|
||||||
# Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档
|
|
||||||
if enable_thinking is None:
|
|
||||||
return {}
|
|
||||||
slot = profile.thinking_on if enable_thinking else profile.thinking_off
|
|
||||||
direction = "thinking_on" if enable_thinking else "thinking_off"
|
|
||||||
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方
|
|
||||||
if slot is None:
|
|
||||||
raise ThinkingUnsupportedError(
|
|
||||||
f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): "
|
|
||||||
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态,"
|
|
||||||
f"或改用 SourceConfig.extra_body 直接下发供应商参数"
|
|
||||||
)
|
|
||||||
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
|
|
||||||
if capability is None:
|
|
||||||
if warn_unregistered:
|
|
||||||
_warn_unregistered(model, profile, slot)
|
|
||||||
return slot
|
|
||||||
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说
|
|
||||||
if enable_thinking is False and not capability.can_disable:
|
|
||||||
raise ThinkingUnsupportedError(
|
|
||||||
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: "
|
|
||||||
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
|
|
||||||
f"需要关闭思维链请换用支持关闭的模型"
|
|
||||||
)
|
|
||||||
return slot
|
|
||||||
|
|
||||||
|
|
||||||
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
|
|
||||||
logger.warning(
|
|
||||||
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};"
|
|
||||||
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
|
|
||||||
model,
|
|
||||||
profile.name,
|
|
||||||
dict(slot),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_provider(
|
def get_provider(
|
||||||
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
|
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
|
||||||
) -> ProviderProfile:
|
) -> ProviderProfile:
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, TypeVar
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
|
||||||
|
|
||||||
|
|
||||||
class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公共名
|
class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公共名
|
||||||
"""流活性超时异常。
|
"""流活性超时异常。
|
||||||
@@ -38,14 +36,14 @@ class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公
|
|||||||
super().__init__(f"流活性超时({kind}, elapsed={elapsed_s:.1f}s)")
|
super().__init__(f"流活性超时({kind}, elapsed={elapsed_s:.1f}s)")
|
||||||
|
|
||||||
|
|
||||||
async def _anext_within(
|
async def _anext_within[T](
|
||||||
it: AsyncIterator[_T],
|
it: AsyncIterator[T],
|
||||||
timeout_s: float,
|
timeout_s: float,
|
||||||
*,
|
*,
|
||||||
kind: str,
|
kind: str,
|
||||||
start: float,
|
start: float,
|
||||||
first: bool,
|
first: bool,
|
||||||
) -> _T:
|
) -> T:
|
||||||
"""限时取下一项;本层 deadline 触发抛 StreamLivenessTimeout(kind)。
|
"""限时取下一项;本层 deadline 触发抛 StreamLivenessTimeout(kind)。
|
||||||
|
|
||||||
上游自抛的 TimeoutError 用 cm.expired() 区分,原样上抛不误吞。
|
上游自抛的 TimeoutError 用 cm.expired() 区分,原样上抛不误吞。
|
||||||
@@ -59,13 +57,13 @@ async def _anext_within(
|
|||||||
raise StreamLivenessTimeout(kind, time.monotonic() - start, not first) from None
|
raise StreamLivenessTimeout(kind, time.monotonic() - start, not first) from None
|
||||||
|
|
||||||
|
|
||||||
async def stream_with_liveness_timeouts(
|
async def stream_with_liveness_timeouts[T](
|
||||||
source: AsyncIterator[_T],
|
source: AsyncIterator[T],
|
||||||
*,
|
*,
|
||||||
ttft_s: float,
|
ttft_s: float,
|
||||||
inter_token_s: float,
|
inter_token_s: float,
|
||||||
total_s: float,
|
total_s: float,
|
||||||
) -> AsyncIterator[_T]:
|
) -> AsyncIterator[T]:
|
||||||
"""逐项产出 source,并施加三层活性超时。
|
"""逐项产出 source,并施加三层活性超时。
|
||||||
|
|
||||||
关键实现: 超时**只包裹单次 __anext__**,绝不包裹 yield——否则总时长
|
关键实现: 超时**只包裹单次 __anext__**,绝不包裹 yield——否则总时长
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 两级降级。
|
"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 按失败性质三分的降级。
|
||||||
|
|
||||||
参考仓无先例(三项目遥测全 SQLite);asyncpg 工程写法取 GovDoc
|
参考仓无先例(三项目遥测全 SQLite);asyncpg 工程写法取 GovDoc
|
||||||
`taskrun/postgres_store.py`($n 占位、`ON CONFLICT DO NOTHING`),但其
|
`taskrun/postgres_store.py`($n 占位、`ON CONFLICT DO NOTHING`),但其
|
||||||
"失败冒泡"方向按遥测铁律**有意反转**:
|
"失败冒泡"方向按遥测铁律**有意反转**: 遥测失败一律不冒泡,只降级。
|
||||||
① 结构性失败 → warning 一次后永久降级(所有写入短路);
|
|
||||||
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
|
||||||
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
|
||||||
构造不连库(lazy),24 列 schema 与 SQLite 版同名同序。
|
构造不连库(lazy),24 列 schema 与 SQLite 版同名同序。
|
||||||
|
|
||||||
**"结构性"的判据是「确定写不进去」,不是「初始化时出过错」**(issue #9):
|
**降级档位挂在"失败是什么性质",不挂"哪一步失败"**(issue #15)。挂步骤是
|
||||||
只有建池失败(重试要在业务路径上内联吞掉 connect 超时)与"表确定不存在
|
issue 的病灶: `min_size=10` 把"连接耗尽"这种瞬时错误逼到建池那一步,于是它被
|
||||||
且建不出来"(后续 INSERT 必然全败)才判死;探测失败、补列失败、取连接
|
一刀切成了永久判死,整进程从此一条遥测都不落,只有重启能恢复。判据两句:
|
||||||
失败一律只 warning,让写入照常尝试或下次调用重试。
|
|
||||||
|
1. **致命 = 失败原因完全在进程内部且不可变**。DSN 是构造期定死的字符串,是唯一
|
||||||
|
满足这条的东西;认证失败、库不存在、表建不出来一律不算——DBA 改完就该好。
|
||||||
|
2. **行级 vs 环境级看失败与"这一行的数据"有没有关系**: 只与本行数据有关(换一行
|
||||||
|
可能成功)= 行级,逐条丢弃;与数据无关、每一行都会同样失败 = 环境级,进冷却。
|
||||||
|
|
||||||
|
见 `_classify_failure`(全库唯一一处 PG 失败分类)与 `_handle_failure`(三个降级点
|
||||||
|
唯一一处处置)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -28,24 +33,116 @@ from polygateway.telemetry.schema import (
|
|||||||
insert_sql,
|
insert_sql,
|
||||||
missing_columns_warning,
|
missing_columns_warning,
|
||||||
)
|
)
|
||||||
|
from polygateway.telemetry.status import TelemetryStatusTracker
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
|
from polygateway.types import TelemetryStatus
|
||||||
|
|
||||||
# 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析
|
# 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析
|
||||||
_TABLE_EXISTS = "SELECT to_regclass('llm_calls')"
|
_TABLE_EXISTS = "SELECT to_regclass('llm_calls')"
|
||||||
|
|
||||||
|
# 归还连接的独立上限(issue #15)。**不**复用写入预算: 写入预算已经花在
|
||||||
|
# acquire+execute 上,归还再给它一个同样大的额度,等于允许业务路径上的一次遥测
|
||||||
|
# 写入吃掉 2 倍预算。归还是本地动作(reset 一次往返),1 秒足够;超时即断开,
|
||||||
|
# asyncpg 会在下次 acquire 时补一条新连接
|
||||||
|
_RELEASE_TIMEOUT_S = 1.0
|
||||||
|
|
||||||
|
# 关闭池的独立上限(issue #15)。**不**复用写入预算: 关闭跑在收尾路径而非业务
|
||||||
|
# 路径上,给它一个略宽的固定额度即可,但必须**有界**——asyncpg 的
|
||||||
|
# `Pool.close()` 会 await 每个 holder 的 `wait_until_released()`,in-flight
|
||||||
|
# 连接不归还就无限等(`pool.py:939-948, 961-972`,60s 只发一条 warning),
|
||||||
|
# 其 docstring 自己写着 "advisable to use asyncio.wait_for to set a timeout"
|
||||||
|
_CLOSE_TIMEOUT_S = 5.0
|
||||||
|
|
||||||
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
|
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
|
||||||
_EXISTING_COLUMNS = (
|
_EXISTING_COLUMNS = (
|
||||||
"SELECT attname FROM pg_attribute "
|
"SELECT attname FROM pg_attribute "
|
||||||
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
|
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 环境级降级的冷却期(issue #15)。**不暴露配置**: 它的取值只影响"多久重试一次"
|
||||||
|
# 这个内部节奏,任何取值都不改变对外承诺(降级可见、可自愈、有界成本),给出旋钮
|
||||||
|
# 只会多一个下游要理解却调不对的东西(设计 §3.5)
|
||||||
|
_DEGRADE_COOLDOWN_S = 60.0
|
||||||
|
|
||||||
|
_FATAL = "fatal"
|
||||||
|
"""配置级致命: 原因完全在进程内部且不可变 → 永久 no-op + 一条 error。"""
|
||||||
|
|
||||||
|
_UNAVAILABLE = "unavailable"
|
||||||
|
"""环境级不可用: 与本行数据无关、每行都会同样失败 → 冷却降级,到期重试一次。"""
|
||||||
|
|
||||||
|
_ROW = "row"
|
||||||
|
"""行级拒绝: 只与本行数据有关 → 逐条 warning 丢弃,不降级。"""
|
||||||
|
|
||||||
|
# 环境级的 SQLSTATE 类(前两位): 08 连接、53 资源不足(含 53300 too many
|
||||||
|
# connections)、57 管理干预、28 认证、3D 库不存在。共同点是"与这一行的数据无关,
|
||||||
|
# 换一行照样失败",且都能被外部修好
|
||||||
|
_UNAVAILABLE_SQLSTATE_CLASSES = frozenset({"08", "53", "57", "28", "3D"})
|
||||||
|
|
||||||
|
# 类 42 整体归行级(见 `_classify_failure` 的默认档),但这两个码与本行数据无关:
|
||||||
|
# 42501 = 账号被收走 INSERT 权限,42P01 = 表被迁走/删掉。它们是持续性的环境状态,
|
||||||
|
# 按类归行级会让每次 LLM 调用都内联付一次往返、刷一条 warning,且永不自愈
|
||||||
|
_UNAVAILABLE_SQLSTATES = frozenset({"42501", "42P01"})
|
||||||
|
|
||||||
|
# **判据的唯一具名例外**(issue #13 的更高优先级承诺): 42703 = 缺列。按判据第 2 句
|
||||||
|
# 它本该是环境级(缺列时每一行都失败),归行级是因为 manual 档会按现有列裁剪 INSERT
|
||||||
|
# 继续写——"部分列写进去了 + 缺列逐行 warning 暴露"本身有价值,是下游发现 schema
|
||||||
|
# 漂移的唯一信号,不该被冷却掉。**新增例外必须同款论证**: 说清它为什么值得违反判据
|
||||||
|
_ROW_SQLSTATES = frozenset({"42703"})
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_failure(exc: BaseException) -> str:
|
||||||
|
"""按**失败的性质**分档(全库唯一一处 PG 失败分类);判据见模块 docstring。
|
||||||
|
|
||||||
|
分类只认 SQLSTATE 与异常类型,不认"在哪一步失败"——后者正是 issue #15 的病灶。
|
||||||
|
SQLSTATE 而非 asyncpg 异常类白名单: 前者是 PG 标准,不随驱动版本漂移。
|
||||||
|
|
||||||
|
**认不出来的失败一律给最轻的一档**(`_ROW`): 升档(冷却 60s)要有依据,没依据就
|
||||||
|
宁可每次调用多付一次内联往返,也不拿 60 秒的遥测去赌一个猜测。issue #9 定下的
|
||||||
|
"探测抖动只跳过本次、下次重试"正是靠这条默认保住的。
|
||||||
|
"""
|
||||||
|
if isinstance(exc, ValueError | TypeError):
|
||||||
|
# DSN 不可解析(实测: 端口写成非数字 → 裸 ValueError;scheme 不对 →
|
||||||
|
# ClientConfigurationError,它本身就是 ValueError 子类)与建池参数非法。
|
||||||
|
# 这些是构造期就定死的进程内部事实,重试在任何时刻都不可能成功
|
||||||
|
return _FATAL
|
||||||
|
sqlstate = getattr(exc, "sqlstate", None)
|
||||||
|
if isinstance(sqlstate, str):
|
||||||
|
if sqlstate in _ROW_SQLSTATES:
|
||||||
|
return _ROW
|
||||||
|
if sqlstate[:2] in _UNAVAILABLE_SQLSTATE_CLASSES or sqlstate in _UNAVAILABLE_SQLSTATES:
|
||||||
|
return _UNAVAILABLE
|
||||||
|
# 其余 PostgresError(22 数据异常、23 约束冲突等)都是这一行的数据问题
|
||||||
|
return _ROW
|
||||||
|
# 没有 SQLSTATE = 话还没说到 PG 就断了: OSError(含 ConnectionError 与
|
||||||
|
# TimeoutError)与 asyncpg 自己的 InterfaceError,都与本行数据无关
|
||||||
|
return _UNAVAILABLE if isinstance(exc, OSError | _interface_error()) else _ROW
|
||||||
|
|
||||||
|
|
||||||
|
def _interface_error() -> type[BaseException]:
|
||||||
|
"""asyncpg 的 `InterfaceError` 类型;延迟取用以免模块导入期硬依赖 extra。"""
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
return asyncpg.InterfaceError
|
||||||
|
|
||||||
|
|
||||||
class PostgresRecorder:
|
class PostgresRecorder:
|
||||||
"""TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。"""
|
"""TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。"""
|
||||||
|
|
||||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
dsn: str,
|
||||||
|
*,
|
||||||
|
pool: asyncpg.Pool | None = None,
|
||||||
|
auto_migrate: bool,
|
||||||
|
pool_max: int,
|
||||||
|
write_timeout_s: float,
|
||||||
|
now: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
"""记下装配参数(不连库);列与 INSERT 语句在首次准备期定型。
|
"""记下装配参数(不连库);列与 INSERT 语句在首次准备期定型。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -56,6 +153,12 @@ class PostgresRecorder:
|
|||||||
锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
|
锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
|
||||||
await。keyword-only **必填**: 缺省规则只写在 config 一处,不与本类
|
await。keyword-only **必填**: 缺省规则只写在 config 一处,不与本类
|
||||||
签名漂移(设计 D-c)。
|
签名漂移(设计 D-c)。
|
||||||
|
pool_max: 自建池的连接数上限(issue #15)。稳态吞吐**按实测折算,不要按
|
||||||
|
`pool_max / RTT` 估**(那会乐观一倍): RTT ≈ 123ms 上 `pool_max=4`
|
||||||
|
实测约 15.6 行/秒(50 行并发批 3.2s)。与 `auto_migrate` 同一纪律:
|
||||||
|
必填,缺省只写在 config 一处。
|
||||||
|
write_timeout_s: 单次写入的硬预算,同时用作 connect 与 acquire 的上限。
|
||||||
|
now: 单调时钟,注入给降级 tracker(测试可推进冷却与节流窗口)。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import asyncpg # noqa: F401 - 仅探测 extra 是否安装
|
import asyncpg # noqa: F401 - 仅探测 extra 是否安装
|
||||||
@@ -67,21 +170,40 @@ class PostgresRecorder:
|
|||||||
self._pool: asyncpg.Pool | None = pool
|
self._pool: asyncpg.Pool | None = pool
|
||||||
self._external_pool = pool is not None
|
self._external_pool = pool is not None
|
||||||
self._auto_migrate = auto_migrate
|
self._auto_migrate = auto_migrate
|
||||||
|
self._pool_max = pool_max
|
||||||
|
self._write_timeout_s = write_timeout_s
|
||||||
# 先按全量列定型: 准备期探测失败时保守沿用全量(今天的行为)
|
# 先按全量列定型: 准备期探测失败时保守沿用全量(今天的行为)
|
||||||
self._columns: tuple[str, ...] = COLUMNS
|
self._columns: tuple[str, ...] = COLUMNS
|
||||||
self._insert = insert_sql("postgres", COLUMNS)
|
self._insert = insert_sql("postgres", COLUMNS)
|
||||||
self._schema_ready = False
|
self._schema_ready = False
|
||||||
self._failed = False # 结构性降级标志: 置位后所有写入短路
|
self._closed = False # 关了就是关了: 置位后写入短路且**不重建池**
|
||||||
|
# 降级状态**只此一份**: 是否短路写入、多久重试一次、下游查到什么,
|
||||||
|
# 全由 tracker 回答。两份状态(曾经的 `_failed` 布尔 + tracker)必然漂移
|
||||||
|
self._status = TelemetryStatusTracker(backend="postgres", now=now)
|
||||||
self._init_lock = asyncio.Lock()
|
self._init_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_status(self) -> TelemetryStatus:
|
||||||
|
"""当前可写状态快照(ports.TelemetryStatusProvider)。"""
|
||||||
|
return self._status.snapshot()
|
||||||
|
|
||||||
async def _ensure_ready(self) -> asyncpg.Pool | None:
|
async def _ensure_ready(self) -> asyncpg.Pool | None:
|
||||||
"""lazy 建池+备表;判死只认「确定写不进去」(issue #9),其余失败都留活路。"""
|
"""lazy 建池+备表;降级期间**零成本短路**,冷却到期放行一次重新准备。
|
||||||
if self._failed:
|
|
||||||
|
`should_retry()` 是纯时间比较,不触库: 降级期间的调用因此既不内联吞
|
||||||
|
connect 超时(`postgres.py` 老注释担心的正是这个),也不需要重启进程——
|
||||||
|
成本变成"每 60s 一次、上界一个写入预算",有界且可解释。
|
||||||
|
|
||||||
|
`_closed` 在锁内**必须复查**: 等锁期间发生的 `aclose` 否则会被这次
|
||||||
|
等待"绕过",等到锁时照旧建出一个没人负责关的池(注入档更隐蔽——
|
||||||
|
注入方以为自己管着全部连接,实际早已不是)。
|
||||||
|
"""
|
||||||
|
if self._closed or not self._status.should_retry():
|
||||||
return None
|
return None
|
||||||
if self._schema_ready:
|
if self._schema_ready:
|
||||||
return self._pool
|
return self._pool
|
||||||
async with self._init_lock:
|
async with self._init_lock:
|
||||||
if self._failed:
|
if self._closed or not self._status.should_retry():
|
||||||
return None
|
return None
|
||||||
if self._schema_ready:
|
if self._schema_ready:
|
||||||
return self._pool
|
return self._pool
|
||||||
@@ -91,37 +213,59 @@ class PostgresRecorder:
|
|||||||
return await self._prepare_schema(pool)
|
return await self._prepare_schema(pool)
|
||||||
|
|
||||||
async def _open_pool(self) -> asyncpg.Pool | None:
|
async def _open_pool(self) -> asyncpg.Pool | None:
|
||||||
"""建池;失败即永久降级(唯一一处「无条件判死」)。"""
|
"""建池;失败按性质分档处置(见 `_handle_failure`),不再一律判死。
|
||||||
|
|
||||||
|
**池的资源占用由本库显式声明**(issue #15): `min_size=0` 的语义是"不预
|
||||||
|
连接"(asyncpg `pool.py:457` 为 0 时只造 holder 对象,一条连接都不连),
|
||||||
|
建池因此从"要么拿到 10 条、要么失败"的重资源动作变成零成本、不触库的
|
||||||
|
动作;连接失败自然落到 acquire 那条本来就正确的"丢一行、池自恢复"路径。
|
||||||
|
`max_size` 是库对自己占用的表态——继承第三方默认值等于不表态(P4),而
|
||||||
|
那正是共享实例余量紧张时先倒下的原因。
|
||||||
|
"""
|
||||||
if self._pool is not None:
|
if self._pool is not None:
|
||||||
return self._pool
|
return self._pool
|
||||||
try:
|
try:
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
|
self._pool = await asyncpg.create_pool(
|
||||||
|
self._dsn,
|
||||||
|
min_size=0,
|
||||||
|
max_size=self._pool_max,
|
||||||
|
timeout=self._write_timeout_s,
|
||||||
|
command_timeout=self._write_timeout_s,
|
||||||
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# 池建不出来 = 确定写不进去;且每次调用重试都要内联吞掉 connect
|
self._handle_failure(exc, stage="建池")
|
||||||
# 超时,而遥测是业务路径上的 await —— 此处必须永久降级
|
|
||||||
self._failed = True
|
|
||||||
logger.warning("Postgres 遥测建池失败,后续记录降级为 no-op: {}", exc)
|
|
||||||
return None
|
return None
|
||||||
return self._pool
|
return self._pool
|
||||||
|
|
||||||
async def _prepare_schema(self, pool: asyncpg.Pool) -> asyncpg.Pool | None:
|
async def _prepare_schema(self, pool: asyncpg.Pool) -> asyncpg.Pool | None:
|
||||||
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。"""
|
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。
|
||||||
|
|
||||||
|
取连接走显式 acquire/release(理由见 `_release`): 准备期同样跑在调用方的
|
||||||
|
写入预算里,`async with` 那条路的归还会把真实上界撑到 ≈2× 预算。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
conn = await pool.acquire(timeout=self._write_timeout_s)
|
||||||
|
try:
|
||||||
columns = await self._prepare_table(conn)
|
columns = await self._prepare_table(conn)
|
||||||
|
finally:
|
||||||
|
await self._release(pool, conn)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# 池已在手,取连接/探测失败多为瞬时抖动: 不判死也不标就绪,
|
self._handle_failure(exc, stage="建表探测")
|
||||||
# 只跳过本次记录,下次调用重新准备
|
|
||||||
logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc)
|
|
||||||
return None
|
return None
|
||||||
if columns is None:
|
if columns is None:
|
||||||
self._failed = True
|
# 表确定不存在且建不出来: 与本行数据无关(每行都会同样失败)且能被
|
||||||
|
# 外部修好(DBA 建了表就该自愈)—— 判据第 2 句下的环境级
|
||||||
|
self._status.enter_degraded(
|
||||||
|
"表 llm_calls 不存在且建不出来(记录无处可落)",
|
||||||
|
fatal=False,
|
||||||
|
cooldown_s=_DEGRADE_COOLDOWN_S,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
|
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
|
||||||
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
|
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
|
||||||
@@ -133,7 +277,7 @@ class PostgresRecorder:
|
|||||||
async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
|
async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
|
||||||
"""备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
|
"""备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
|
||||||
|
|
||||||
返回 None 仅表示表确定不存在且建不出来(唯一允许判死的情形)。
|
返回 None 仅表示表确定不存在且建不出来(调用方据此进环境级冷却降级)。
|
||||||
|
|
||||||
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
|
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
|
||||||
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
|
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
|
||||||
@@ -206,11 +350,11 @@ class PostgresRecorder:
|
|||||||
return effective
|
return effective
|
||||||
|
|
||||||
async def _backfill_columns(self, conn: object, existing: set[str]) -> None:
|
async def _backfill_columns(self, conn: object, existing: set[str]) -> None:
|
||||||
"""auto 档: 给已存在的旧表补新列(issue #3);**失败绝不置 `_failed`**。
|
"""auto 档: 给已存在的旧表补新列(issue #3);**失败绝不让 recorder 降级**。
|
||||||
|
|
||||||
不置 `_failed` 的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的
|
不降级的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的
|
||||||
ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。降级会让
|
||||||
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
整个 recorder 停写(环境级还要停满一个冷却期),与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||||
(SQLite 侧同款守卫,两侧必须对称)。补列失败后写入沿用全量列(今天的行为):
|
(SQLite 侧同款守卫,两侧必须对称)。补列失败后写入沿用全量列(今天的行为):
|
||||||
auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入
|
auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入
|
||||||
请显式选 manual。
|
请显式选 manual。
|
||||||
@@ -224,28 +368,151 @@ class PostgresRecorder:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
|
def _handle_failure(self, exc: BaseException, *, stage: str) -> None:
|
||||||
|
"""按分档处置一次遥测失败;三个降级点(建池/建表探测/写入)共用这一处。
|
||||||
|
|
||||||
|
收敛成一处不只是去重: 三处各写一遍处置,就是三处各自漂移一次判据的机会,
|
||||||
|
而判据漂移正是 issue #15 的病灶(注释写着"确定写不进去",代码做的是别的事)。
|
||||||
|
|
||||||
|
`stage` 只进日志文案,**不参与分档**——挂步骤分档正是要被拆掉的错法。
|
||||||
|
"""
|
||||||
|
verdict = _classify_failure(exc)
|
||||||
|
if verdict == _FATAL:
|
||||||
|
# 这里**不再**另发一条 error: 级别由 tracker 按 `fatal` 决定(致命档发
|
||||||
|
# error——人配错了,本进程内不会自愈)。此处复制一条只会让同一个事实出
|
||||||
|
# 两条语义重复的日志,并给"级别"这个决策造出第二个源头
|
||||||
|
self._status.enter_degraded(
|
||||||
|
f"{stage}失败(配置有误): {exc}", fatal=True, cooldown_s=None
|
||||||
|
)
|
||||||
|
elif verdict == _UNAVAILABLE:
|
||||||
|
# 每一行都会同样失败 → 冷却期内不再内联重试;`_schema_ready` 一并作废,
|
||||||
|
# 到期那次要重新走准备(表被删/权限被收回都得靠重新准备才能发现已修好)
|
||||||
|
self._schema_ready = False
|
||||||
|
self._status.enter_degraded(
|
||||||
|
f"{stage}失败: {exc}", fatal=False, cooldown_s=_DEGRADE_COOLDOWN_S
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 行级不进降级: 换一行可能就成了。逐条出声是 issue #13 的承诺
|
||||||
|
# (缺列靠这条 warning 暴露 schema 漂移),不因刷屏而节流掉
|
||||||
|
logger.warning("Postgres 遥测{}失败(丢弃该行,下次调用照常重试): {}", stage, exc)
|
||||||
|
|
||||||
|
def _drop_reason(self) -> str:
|
||||||
|
"""写不进去时说清是**哪一种**写不进去: 关了 / 降级中 / 本次没准备好。
|
||||||
|
|
||||||
|
三者的处置完全不同(一个是调用方自己关了却还在写、一个等自愈、一个下次
|
||||||
|
就会重试),混成一句话会让对账的人分不清该等还是该修。
|
||||||
|
"""
|
||||||
|
if self._closed:
|
||||||
|
return "遥测已关闭"
|
||||||
|
if self._status.snapshot().degraded:
|
||||||
|
return "遥测降级中"
|
||||||
|
return "后端本次未准备好(下次调用重试)"
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。
|
"""写一行遥测;整次写入受硬预算约束,失败逐条丢弃(两级降级之二),绝不冒泡。
|
||||||
|
|
||||||
|
**硬预算**(issue #15): 准备 + 取连接 + 执行合计不得超过 `write_timeout_s`。
|
||||||
|
这把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"变成一条可陈述、可测试的
|
||||||
|
保证——此前 `pool.acquire()` 无超时(asyncpg 缺省 `timeout=None` = 无限等),
|
||||||
|
池满时会无限期挂在业务路径上。
|
||||||
|
|
||||||
|
外部取消照常穿透: `asyncio.timeout` 只把**自己**触发的 cancel 转成
|
||||||
|
TimeoutError,故 `CancelledError` 分支必须排在最前且原样 re-raise(铁律)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(self._write_timeout_s):
|
||||||
|
await self._write_row(fields)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Postgres 遥测写入超预算 {}s(丢弃该行);后端慢不得拖垮业务调用",
|
||||||
|
self._write_timeout_s,
|
||||||
|
)
|
||||||
|
self._status.record_drop("写入超预算")
|
||||||
|
except Exception as exc:
|
||||||
|
# 遥测铁律: 丢一条 < 拖垮调用。这一行无论如何都没了,区别只在于
|
||||||
|
# **下一行还试不试**——那由失败的性质决定,不由这里决定
|
||||||
|
self._handle_failure(exc, stage="写入")
|
||||||
|
self._status.record_drop("写入失败")
|
||||||
|
|
||||||
|
async def _write_row(self, fields: dict[str, object]) -> None:
|
||||||
|
"""预算内的写入本体: 准备 → 取连接 → 执行 → 归还。
|
||||||
|
|
||||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||||
"""
|
"""
|
||||||
pool = await self._ensure_ready()
|
pool = await self._ensure_ready()
|
||||||
if pool is None:
|
if pool is None:
|
||||||
|
# 降级期间静默 return 就是 issue #15 的破口: 丢行必须计数且节流出声
|
||||||
|
self._status.record_drop(self._drop_reason())
|
||||||
return
|
return
|
||||||
row = tuple(fields[col] for col in self._columns)
|
row = tuple(fields[col] for col in self._columns)
|
||||||
|
conn = await pool.acquire(timeout=self._write_timeout_s)
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
await conn.execute(self._insert, *row)
|
||||||
await conn.execute(self._insert, *row)
|
finally:
|
||||||
|
await self._release(pool, conn)
|
||||||
|
# **恢复的唯一权威证据是一次真正写成功**(未降级时是廉价 no-op)。放在这里
|
||||||
|
# 而不是准备期: 准备通过不代表写得进去(权限只到 SELECT 时正是如此)
|
||||||
|
self._status.recover()
|
||||||
|
|
||||||
|
async def _release(self, pool: asyncpg.Pool, conn: object) -> None:
|
||||||
|
"""归还连接;归还路径独立有界,失败即断开(下次 acquire 会补一条新的)。
|
||||||
|
|
||||||
|
**不用 `async with pool.acquire()`**(设计 §3.1,已核实): asyncpg 的
|
||||||
|
`Pool.release` 是 `await asyncio.shield(ch.release(timeout))`,且那个
|
||||||
|
timeout 默认复用 acquire 时记录的 `ch._timeout`(`pool.py:886-889,
|
||||||
|
930-937`)。写入预算到期时 cancel 在 execute 处抛出,异常传播中执行
|
||||||
|
`__aexit__`,此时没有新的 cancel 投递——那次 shielded release 会**正常
|
||||||
|
等到完成**,业务路径的真实上界因此变成 ≈2 × 预算。显式归还才能给它一个
|
||||||
|
独立的小上限,承诺才精确成立: 主写入尝试 ≤ 预算,归还路径独立有界。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await pool.release(conn, timeout=_RELEASE_TIMEOUT_S)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# 遥测铁律: 丢一条 < 拖垮调用;仅记 warning(非 pass),池自恢复
|
# 含 TimeoutError: 归还超时与归还出错的处置相同——断开而不是留一条
|
||||||
logger.warning("Postgres 遥测写入失败(丢弃该行): {}", exc)
|
# 状态不明的连接在池里(asyncpg 的 reset 失败路径也是这么做的)
|
||||||
|
logger.warning("Postgres 遥测连接归还失败(强制断开): {}", exc)
|
||||||
|
self._terminate(conn, label="连接")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _terminate(target: object, *, label: str) -> None:
|
||||||
|
"""强制断开一条连接或整个池;断开本身再失败也只记 warning(遥测绝不冒泡)。
|
||||||
|
|
||||||
|
`label` 必填(不给默认值): 两个调用点的诊断价值全在"拆的是哪一层",
|
||||||
|
默认值只会让其中一处悄悄报错成另一处。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
target.terminate() # type: ignore[attr-defined]
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Postgres 遥测{}断开失败(交给上层自行回收): {}", label, exc)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""幂等关闭自建池;注入的池归注入方管理。"""
|
"""幂等关闭自建池;**关了就是关了**,此后写入短路且不复活。注入的池归注入方管理。
|
||||||
|
|
||||||
|
取消"关完还能自己重建池"的灰色状态(设计 §3.2 第 4 点): 关闭是所有权的
|
||||||
|
终结,而恢复是运行时行为(冷却重试),不该是关闭动作的副作用。
|
||||||
|
|
||||||
|
**关闭动作本身也有界**: `Pool.close()` 会 await 每个 holder 的
|
||||||
|
`wait_until_released()`,in-flight 连接不归还就无限等——收尾路径上照样是
|
||||||
|
"遥测拖垮业务"。超时即 `terminate()` 强拆: 关闭已在进行,留着一个关不掉的池
|
||||||
|
既不会自愈也没人再来收。外部取消照常穿透(铁律),不当成一次关闭超时。
|
||||||
|
"""
|
||||||
|
self._closed = True
|
||||||
pool, self._pool = self._pool, None
|
pool, self._pool = self._pool, None
|
||||||
self._schema_ready = False
|
self._schema_ready = False
|
||||||
if pool is not None and not self._external_pool:
|
if pool is None or self._external_pool:
|
||||||
await pool.close()
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(pool.close(), timeout=_CLOSE_TIMEOUT_S)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
# 含 TimeoutError: 关不掉与关出错的处置相同——强拆
|
||||||
|
logger.warning("Postgres 遥测池关闭失败(强制断开): {}", exc)
|
||||||
|
self._terminate(pool, label="池")
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||||
|
|
||||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
||||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 24 个 INSERT 字段 +
|
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 36 个 INSERT 字段 +
|
||||||
`created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处。
|
`created_at` = 37;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||||
|
|
||||||
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
||||||
互不依赖(import-linter 契约执法)。
|
互不依赖(import-linter 契约执法)。
|
||||||
@@ -50,7 +50,19 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
sampling TEXT,
|
sampling TEXT,
|
||||||
reasoning_tokens INTEGER,
|
reasoning_tokens INTEGER,
|
||||||
tenant_id TEXT NOT NULL DEFAULT '',
|
tenant_id TEXT NOT NULL DEFAULT '',
|
||||||
meta TEXT NOT NULL DEFAULT '{}'
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
|
thinking_observation TEXT,
|
||||||
|
reasoning_effort TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
operation TEXT,
|
||||||
|
logical_call_id TEXT,
|
||||||
|
event_kind TEXT,
|
||||||
|
http_status_code INTEGER,
|
||||||
|
error_type TEXT,
|
||||||
|
cause_type TEXT,
|
||||||
|
error_body TEXT,
|
||||||
|
attempts INTEGER,
|
||||||
|
total_latency_ms INTEGER
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -80,7 +92,19 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
sampling TEXT,
|
sampling TEXT,
|
||||||
reasoning_tokens INTEGER,
|
reasoning_tokens INTEGER,
|
||||||
tenant_id TEXT NOT NULL DEFAULT '',
|
tenant_id TEXT NOT NULL DEFAULT '',
|
||||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
thinking_observation TEXT,
|
||||||
|
reasoning_effort TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
operation TEXT,
|
||||||
|
logical_call_id TEXT,
|
||||||
|
event_kind TEXT,
|
||||||
|
http_status_code INTEGER,
|
||||||
|
error_type TEXT,
|
||||||
|
cause_type TEXT,
|
||||||
|
error_body TEXT,
|
||||||
|
attempts INTEGER,
|
||||||
|
total_latency_ms INTEGER
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -95,6 +119,24 @@ SQLITE_BACKFILL = (
|
|||||||
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
|
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
|
||||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||||
("meta", "TEXT NOT NULL DEFAULT '{}'"),
|
("meta", "TEXT NOT NULL DEFAULT '{}'"),
|
||||||
|
# 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事",
|
||||||
|
# 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同
|
||||||
|
("thinking_observation", "TEXT"),
|
||||||
|
# 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none'
|
||||||
|
# (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事
|
||||||
|
("reasoning_effort", "TEXT"),
|
||||||
|
# 1.3.5 十列: 全部可空且无默认值——旧行的 NULL 表达的是"补列之前根本没记过
|
||||||
|
# 这件事",与任何哨兵值都不是一回事,故不回填(设计 §5)
|
||||||
|
("scope", "TEXT"),
|
||||||
|
("operation", "TEXT"),
|
||||||
|
("logical_call_id", "TEXT"),
|
||||||
|
("event_kind", "TEXT"),
|
||||||
|
("http_status_code", "INTEGER"),
|
||||||
|
("error_type", "TEXT"),
|
||||||
|
("cause_type", "TEXT"),
|
||||||
|
("error_body", "TEXT"),
|
||||||
|
("attempts", "INTEGER"),
|
||||||
|
("total_latency_ms", "INTEGER"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
||||||
@@ -107,6 +149,20 @@ _PG_BACKFILL_DECLS = (
|
|||||||
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
|
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
|
||||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||||
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
|
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
|
||||||
|
# 可空,理由同 SQLITE_BACKFILL 同名项
|
||||||
|
("thinking_observation", "TEXT"),
|
||||||
|
("reasoning_effort", "TEXT"),
|
||||||
|
# 1.3.5 十列,列序与 SQLITE_BACKFILL 逐项对齐(两条路径的物理列序不许分叉)
|
||||||
|
("scope", "TEXT"),
|
||||||
|
("operation", "TEXT"),
|
||||||
|
("logical_call_id", "TEXT"),
|
||||||
|
("event_kind", "TEXT"),
|
||||||
|
("http_status_code", "INTEGER"),
|
||||||
|
("error_type", "TEXT"),
|
||||||
|
("cause_type", "TEXT"),
|
||||||
|
("error_body", "TEXT"),
|
||||||
|
("attempts", "INTEGER"),
|
||||||
|
("total_latency_ms", "INTEGER"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
||||||
@@ -143,6 +199,19 @@ COLUMNS = (
|
|||||||
"reasoning_tokens",
|
"reasoning_tokens",
|
||||||
"tenant_id",
|
"tenant_id",
|
||||||
"meta",
|
"meta",
|
||||||
|
"thinking_observation",
|
||||||
|
"reasoning_effort",
|
||||||
|
# —— 1.3.5 逻辑调用统计与结构化失败诊断(issue #19/#23)——
|
||||||
|
"scope",
|
||||||
|
"operation",
|
||||||
|
"logical_call_id",
|
||||||
|
"event_kind",
|
||||||
|
"http_status_code",
|
||||||
|
"error_type",
|
||||||
|
"cause_type",
|
||||||
|
"error_body",
|
||||||
|
"attempts",
|
||||||
|
"total_latency_ms",
|
||||||
)
|
)
|
||||||
|
|
||||||
_COLUMN_SET = frozenset(COLUMNS)
|
_COLUMN_SET = frozenset(COLUMNS)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import asyncio
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -29,6 +30,10 @@ from polygateway.telemetry.schema import (
|
|||||||
insert_sql,
|
insert_sql,
|
||||||
missing_columns_warning,
|
missing_columns_warning,
|
||||||
)
|
)
|
||||||
|
from polygateway.telemetry.status import TelemetryStatusTracker
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from polygateway.types import TelemetryStatus
|
||||||
|
|
||||||
|
|
||||||
class SQLiteRecorder:
|
class SQLiteRecorder:
|
||||||
@@ -45,6 +50,9 @@ class SQLiteRecorder:
|
|||||||
config 一处,不与本类签名漂移(设计 D-c)。
|
config 一处,不与本类签名漂移(设计 D-c)。
|
||||||
"""
|
"""
|
||||||
self._auto_migrate = auto_migrate
|
self._auto_migrate = auto_migrate
|
||||||
|
# SQLite 侧本次只做可见性: 它的失败模式(目录不可写、文件损坏)在装配期
|
||||||
|
# 就暴露给下游,不是"跑到一半悄悄断",故降级恒为 fatal,不做冷却重连
|
||||||
|
self._status = TelemetryStatusTracker(backend="sqlite")
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._conn: sqlite3.Connection | None = None
|
self._conn: sqlite3.Connection | None = None
|
||||||
# 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为)
|
# 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为)
|
||||||
@@ -60,9 +68,14 @@ class SQLiteRecorder:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
self._conn = conn
|
self._conn = conn
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
self._status.enter_degraded(f"初始化失败: {exc}", fatal=True, cooldown_s=None)
|
||||||
self._prepare_columns()
|
self._prepare_columns()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_status(self) -> TelemetryStatus:
|
||||||
|
"""当前可写状态快照(ports.TelemetryStatusProvider)。"""
|
||||||
|
return self._status.snapshot()
|
||||||
|
|
||||||
def _prepare_columns(self) -> None:
|
def _prepare_columns(self) -> None:
|
||||||
"""探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。
|
"""探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。
|
||||||
|
|
||||||
@@ -130,18 +143,34 @@ class SQLiteRecorder:
|
|||||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。
|
"""写一行遥测;字段集合即 36 字段冻结签名(ports.TelemetryRecorder)。
|
||||||
|
|
||||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||||
"""
|
"""
|
||||||
if self._conn is None:
|
if self._conn is None:
|
||||||
|
# 改前这里是**裸 return**: 初始化失败后每一行都无声消失,长跑进程里
|
||||||
|
# 与"遥测正常"外观上完全一致(设计 §1.4 的直接钉子)
|
||||||
|
self._status.record_drop(self._drop_reason())
|
||||||
return
|
return
|
||||||
row = tuple(fields[col] for col in self._columns)
|
row = tuple(fields[col] for col in self._columns)
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self._write, row)
|
await asyncio.to_thread(self._write, row)
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
logger.warning("SQLite 遥测写入失败(降级不冒泡): {}", exc)
|
logger.warning("SQLite 遥测写入失败(降级不冒泡): {}", exc)
|
||||||
|
# 计数与出声是两件事,少了计数可见性在这条路径上就是假的: 磁盘满 /
|
||||||
|
# database is locked / 文件被外部改坏时行真的丢了,而 `dropped_rows`
|
||||||
|
# 恒 0、`degraded` 恒 False,下游读快照对账完全看不见(PG 侧两件都做)
|
||||||
|
self._status.record_drop("写入失败")
|
||||||
|
|
||||||
|
def _drop_reason(self) -> str:
|
||||||
|
"""连接为 None 时说清是**哪一种**写不进去: 降级中 / 调用方自己关了。
|
||||||
|
|
||||||
|
不能写死为"已降级": `close()` 之后 `degraded` 是 False,固定文案会与
|
||||||
|
下游读到的快照互相矛盾,对账的人分不清该等自愈还是修自己的关闭时序。
|
||||||
|
本侧只有这两态(初始化失败必置降级,此外只剩关闭),故不照抄 PG 的三分。
|
||||||
|
"""
|
||||||
|
return "遥测已降级" if self._status.snapshot().degraded else "遥测已关闭"
|
||||||
|
|
||||||
def _write(self, row: tuple) -> None:
|
def _write(self, row: tuple) -> None:
|
||||||
assert self._conn is not None # 内部不变量: 调用方已判空
|
assert self._conn is not None # 内部不变量: 调用方已判空
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""遥测降级状态机(issue #15 C 组): 两个 recorder 共用的降级事实源。
|
||||||
|
|
||||||
|
存在的理由(设计 §1.4): 遥测降级过去只有**一条** warning,长跑进程里等同于
|
||||||
|
静默——issue 是手工对账(日志里的完成里程碑条数 vs `llm_calls` 行数)才发现的,
|
||||||
|
期间 19 次调用一行未落。"遥测必录"铁律的实质要求是: 库做不到必录时,必须
|
||||||
|
**持续、可编程地**让下游知道。故降级升格为一等对象,两条出路各走一边:
|
||||||
|
|
||||||
|
- 人看: 进入/恢复各一条日志,降级期间按行数与时间**双阈值节流复述**(不刷屏,
|
||||||
|
也不静默);
|
||||||
|
- 程序看: `snapshot()` 给只读 `TelemetryStatus`,下游可据此对账或告警。
|
||||||
|
|
||||||
|
本模块**不含任何后端知识**(不 import asyncpg/sqlite3,也不判失败性质): 失败
|
||||||
|
分类是各 recorder 的事,tracker 只接受"降级了/恢复了/丢了一行"三个事实。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.types import TelemetryStatus
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
_DROP_REPEAT_EVERY_ROWS = 100
|
||||||
|
"""降级期间每丢这么多行复述一次;首行必报。"""
|
||||||
|
|
||||||
|
_DROP_REPEAT_EVERY_S = 300.0
|
||||||
|
"""降级期间距上次复述超过这么久就再报一次——低频调用的进程不能因行数不够而静默。"""
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryStatusTracker:
|
||||||
|
"""单个 recorder 的降级状态;非线程安全,由持有它的 recorder 在自己的时序内使用。
|
||||||
|
|
||||||
|
时钟经构造参数注入(与 `GatewayClient(now=...)` 同款): 冷却窗口与节流窗口
|
||||||
|
都必须能用假时钟测,否则这些行为只能靠真睡验证,而真睡的用例是间歇红的源头。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, backend: str, now: Callable[[], float] = time.monotonic) -> None:
|
||||||
|
"""记下后端名(只用于日志前缀)与时钟;构造后即"未降级"。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backend: 后端名(如 `postgres`/`sqlite`),仅进日志文案。
|
||||||
|
now: 单调时钟;测试可注入假时钟推进冷却与节流窗口。
|
||||||
|
"""
|
||||||
|
self._backend = backend
|
||||||
|
self._now = now
|
||||||
|
self._degraded_since: float | None = None
|
||||||
|
self._fatal = False
|
||||||
|
self._reason: str | None = None
|
||||||
|
self._retry_at: float | None = None
|
||||||
|
self._dropped_rows = 0
|
||||||
|
# 节流窗口: 本段降级里"自上次复述以来"丢了多少行、上次复述在什么时候
|
||||||
|
self._dropped_since_report = 0
|
||||||
|
self._last_report_at: float | None = None
|
||||||
|
self._dropped_at_entry = 0
|
||||||
|
|
||||||
|
def enter_degraded(self, reason: str, *, fatal: bool, cooldown_s: float | None) -> None:
|
||||||
|
"""进入(或续期)降级;同一原因只讲一次,只刷新冷却窗口。
|
||||||
|
|
||||||
|
不重复打日志是刚需而非优化: 冷却到期重试再失败会反复走到这里,每次都讲
|
||||||
|
就把"降级中"刷成噪音。原因变了才算新事实,值得再讲一遍。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reason: 降级原因(已含具体异常文本);同值视为同一次降级的续期。
|
||||||
|
fatal: True = 本进程内不可恢复,此后 `should_retry()` 恒 False;
|
||||||
|
**同时决定日志级别**(见下方发日志处)。
|
||||||
|
cooldown_s: 距下次允许重新准备的秒数;None 表示不自动重试。
|
||||||
|
"""
|
||||||
|
if self._fatal:
|
||||||
|
return # 永久档不可被后来的失败覆盖,也不再刷屏
|
||||||
|
now = self._now()
|
||||||
|
first_of_this_episode = self._degraded_since is None
|
||||||
|
announce = first_of_this_episode or reason != self._reason
|
||||||
|
if first_of_this_episode:
|
||||||
|
self._degraded_since = now
|
||||||
|
self._dropped_at_entry = self._dropped_rows
|
||||||
|
self._dropped_since_report = 0
|
||||||
|
self._last_report_at = None
|
||||||
|
self._reason = reason
|
||||||
|
self._fatal = fatal
|
||||||
|
self._retry_at = None if fatal or cooldown_s is None else now + cooldown_s
|
||||||
|
if announce:
|
||||||
|
# 级别由 `fatal` 决定,且**只在这一处**决定(设计 §3.2): 致命档是"人把
|
||||||
|
# 配置写错了、本进程内不会自愈",运维必须看见 → error;其余都是外部
|
||||||
|
# 状态、会自愈 → warning。recorder 侧一度各自再发一条 error,同一个
|
||||||
|
# 事实因此出两条语义重复的日志,"级别"这个决策也就有了两个源头——两个
|
||||||
|
# 源头必然漂移,正是本 issue 反复踩的那类错
|
||||||
|
emit = logger.error if fatal else logger.warning
|
||||||
|
emit(
|
||||||
|
"{} 遥测降级(后续记录将被丢弃): {};恢复条件: {}",
|
||||||
|
self._backend,
|
||||||
|
reason,
|
||||||
|
self._recovery_hint(cooldown_s, fatal=fatal),
|
||||||
|
)
|
||||||
|
|
||||||
|
def recover(self) -> None:
|
||||||
|
"""退出降级并报告本段期间丢了多少行;未降级时是 no-op。
|
||||||
|
|
||||||
|
`dropped_rows` **不清零**: 它是进程生命周期内的累计量,下游靠它对账。
|
||||||
|
"""
|
||||||
|
if self._degraded_since is None:
|
||||||
|
return
|
||||||
|
dropped = self._dropped_rows - self._dropped_at_entry
|
||||||
|
logger.info(
|
||||||
|
"{} 遥测已恢复(降级持续 {:.1f}s,期间丢弃 {} 行)",
|
||||||
|
self._backend,
|
||||||
|
self._now() - self._degraded_since,
|
||||||
|
dropped,
|
||||||
|
)
|
||||||
|
self._degraded_since = None
|
||||||
|
self._fatal = False
|
||||||
|
self._reason = None
|
||||||
|
self._retry_at = None
|
||||||
|
self._dropped_since_report = 0
|
||||||
|
self._last_report_at = None
|
||||||
|
|
||||||
|
def record_drop(self, reason: str) -> None:
|
||||||
|
"""记一行被丢弃;按行数与时间双阈值节流复述。
|
||||||
|
|
||||||
|
双阈值缺一不可: 只按行数,低频调用的进程会长时间完全静默;只按时间,
|
||||||
|
高频进程在窗口内丢几万行也只有一条日志,看不出量级。
|
||||||
|
"""
|
||||||
|
self._dropped_rows += 1
|
||||||
|
self._dropped_since_report += 1
|
||||||
|
if not self._should_report():
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"{} 遥测丢弃记录(累计 {} 行): {}",
|
||||||
|
self._backend,
|
||||||
|
self._dropped_rows,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
self._dropped_since_report = 0
|
||||||
|
self._last_report_at = self._now()
|
||||||
|
|
||||||
|
def should_retry(self) -> bool:
|
||||||
|
"""现在是否允许(重新)准备后端: 纯查询,不触库也不改状态。
|
||||||
|
|
||||||
|
未降级 → True(本就该正常走准备路径);fatal → False;冷却未到 → False;
|
||||||
|
非 fatal 但没给冷却 → False(调用方没安排自动重试,tracker 不替它决定)。
|
||||||
|
"""
|
||||||
|
if self._fatal:
|
||||||
|
return False
|
||||||
|
if self._degraded_since is None:
|
||||||
|
return True
|
||||||
|
if self._retry_at is None:
|
||||||
|
return False
|
||||||
|
return self._now() >= self._retry_at
|
||||||
|
|
||||||
|
def snapshot(self) -> TelemetryStatus:
|
||||||
|
"""当前状态的只读快照(公共出口 `client.telemetry_status` 的取值点)。"""
|
||||||
|
now = self._now()
|
||||||
|
since = self._degraded_since
|
||||||
|
retry_after_s: float | None = None
|
||||||
|
if since is not None and self._retry_at is not None:
|
||||||
|
retry_after_s = max(0.0, self._retry_at - now) # 到期后钳到 0,不给负数
|
||||||
|
return TelemetryStatus(
|
||||||
|
degraded=since is not None,
|
||||||
|
fatal=self._fatal,
|
||||||
|
reason=self._reason,
|
||||||
|
degraded_for_s=None if since is None else now - since,
|
||||||
|
dropped_rows=self._dropped_rows,
|
||||||
|
retry_after_s=retry_after_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _should_report(self) -> bool:
|
||||||
|
"""本次丢弃是否该出声: 本段降级的第一行、满行数阈值、或超时间阈值。"""
|
||||||
|
if self._last_report_at is None:
|
||||||
|
return True
|
||||||
|
if self._dropped_since_report >= _DROP_REPEAT_EVERY_ROWS:
|
||||||
|
return True
|
||||||
|
return self._now() - self._last_report_at >= _DROP_REPEAT_EVERY_S
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _recovery_hint(cooldown_s: float | None, *, fatal: bool) -> str:
|
||||||
|
"""把恢复条件写进日志: 运维看到降级后第一个问题就是"它自己会好吗"。"""
|
||||||
|
if fatal:
|
||||||
|
return "需修正配置后重启进程(本进程内不会自愈)"
|
||||||
|
if cooldown_s is None:
|
||||||
|
return "下次调用时重试"
|
||||||
|
return f"约 {cooldown_s:.0f}s 后自动重试"
|
||||||
@@ -0,0 +1,827 @@
|
|||||||
|
"""推理这件事的全部**决策**: 请求侧注入形态、响应侧结果裁定、二者的对账。
|
||||||
|
|
||||||
|
与 `providers.py` 的分工: 那里是**注册表**(provider 长什么样,静态声明的存放
|
||||||
|
与查找),这里是**决策**(拿声明和响应做判断)。P7"决策逻辑与状态存储分离"。
|
||||||
|
|
||||||
|
本模块**不定义** `ThinkingObservation` —— 它是 `LLMResponse` 的字段类型,归最
|
||||||
|
内层 `types.py`;定义在这里会让 `types.py` 反向 import 决策模块(依赖铁律)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.providers import ProviderProfile, ThinkingWire
|
||||||
|
from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation, coerce_effort
|
||||||
|
|
||||||
|
|
||||||
|
def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation:
|
||||||
|
"""由多信号裁定推理是否发生;判据按**证据硬度**排序(issue #16/#17)。
|
||||||
|
|
||||||
|
推理正文是事实本身,`reasoning_tokens` 是对事实的转述——转述缺失时事实仍然
|
||||||
|
作数。2026-08-25 实测: MiniMax 这一路已不再返回
|
||||||
|
`usage.completion_tokens_details`,而同一次调用里库拿得到 185 字符推理正文;
|
||||||
|
只认 token 数的判据会把这种情形误判成"没推理"。
|
||||||
|
|
||||||
|
正文判据取 `strip()` 而非 truthy: 网关响应是外部输入,纯空白串不是证据(P5)。
|
||||||
|
|
||||||
|
判不出来时返回 `UNKNOWN` 而非 `ABSENT`——**不许把"没看见"说成"没发生"**。
|
||||||
|
"""
|
||||||
|
if thinking.strip():
|
||||||
|
return ThinkingObservation.OBSERVED
|
||||||
|
# 负数与 None 同档: `ABSENT` 是"上游明确上报未推理"这个最强的正面结论,坏
|
||||||
|
# 数据给不出它。当前 transport 已在边界把负数归 None,这里仍要自己闭合——本
|
||||||
|
# 函数对外承诺"外部输入校验后使用",第二个 transport 直接填该值时,漏判会
|
||||||
|
# 给出一个方向相反的强结论(P5)
|
||||||
|
if reasoning_tokens is None or reasoning_tokens < 0:
|
||||||
|
return ThinkingObservation.UNKNOWN
|
||||||
|
return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingUnsupportedError(ValueError):
|
||||||
|
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
|
||||||
|
|
||||||
|
是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置**
|
||||||
|
不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期
|
||||||
|
捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让
|
||||||
|
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
|
||||||
|
错误误贴成"推理开关无法满足")。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
"""某个**具体模型**支持哪些推理档位(设计 §3.2);登记必须附证据与日期。
|
||||||
|
|
||||||
|
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
|
||||||
|
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
|
||||||
|
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
|
||||||
|
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
|
||||||
|
|
||||||
|
**档位清单而非布尔**(2026-09-04): 旧版是 `can_disable: bool`,表达不了
|
||||||
|
"关不掉但能调到最低档"这第三种情况——而 GLM-5.3 系与 Gemini 3 Pro 都是它。
|
||||||
|
现在"能不能关"就是 `Effort.NONE` 在不在清单里,是派生量而非独立字段;三个
|
||||||
|
派生量一律不存字段,存了必与清单漂移。
|
||||||
|
|
||||||
|
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
|
||||||
|
文档推定与实测必须在 evidence 里说清楚是哪种——前者会被 new-api 中转改写
|
||||||
|
(LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下三档、`perplexity/` 下六档)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
supported_efforts: tuple[Effort, ...]
|
||||||
|
evidence: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""构造期校验: 空清单与重复档都是登记错误,不能等到请求期才炸。"""
|
||||||
|
if not self.supported_efforts:
|
||||||
|
raise ValueError("supported_efforts 至少要有一档: 空清单表达不了任何能力")
|
||||||
|
if len(set(self.supported_efforts)) != len(self.supported_efforts):
|
||||||
|
raise ValueError(f"supported_efforts 有重复档: {self.supported_efforts}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_disable(self) -> bool:
|
||||||
|
"""能否关闭推理 = `none` 在不在清单里(旧 `can_disable` 字段的等价物)。"""
|
||||||
|
return Effort.NONE in self.supported_efforts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cheapest_effort(self) -> Effort | None:
|
||||||
|
"""除 `none` 外最省的一档;关不掉时作为**可执行替代**推荐给调用方。
|
||||||
|
|
||||||
|
`AUTO` 参与候选(纯开关型模型只有它可推荐),但因不在 `EFFORT_ORDER` 中,
|
||||||
|
仅当没有任何强度档时才被选中。全清单只有 `none` 时返回 None——那种模型
|
||||||
|
没有"最省的开启档"可言。
|
||||||
|
"""
|
||||||
|
tiers = [e for e in EFFORT_ORDER if e is not Effort.NONE and e in self.supported_efforts]
|
||||||
|
if tiers:
|
||||||
|
return tiers[0]
|
||||||
|
return Effort.AUTO if Effort.AUTO in self.supported_efforts else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_tiered(self) -> bool:
|
||||||
|
"""是否档位型(除 `none`/`auto` 外仍有强度档)。
|
||||||
|
|
||||||
|
用途是**告警文案**: 对纯开关型模型说"可选档位: ..."是错的,它没有档位。
|
||||||
|
"""
|
||||||
|
return any(e not in (Effort.NONE, Effort.AUTO) for e in self.supported_efforts)
|
||||||
|
|
||||||
|
|
||||||
|
# 证据分三类,evidence 里必须自报家门:
|
||||||
|
# 实测 = 经 new-api 中转打过真实请求(最硬,不得被文档推定覆盖);
|
||||||
|
# 文档推定 = 官方文档 / OpenRouter / cherry-studio / LiteLLM 四方交叉;
|
||||||
|
# 实测未覆盖 = T10 试过但拿不到数据(渠道限额/上游报错/被路由到别的模型),
|
||||||
|
# 此时**必须写明原因**——"没测到"与"测了没问题"是两回事,T9 之类的下游
|
||||||
|
# 文档任务不得把前者写成后者。
|
||||||
|
_MEASURED = "2026-08-02 经 new-api 中转实测"
|
||||||
|
_T10 = "2026-09-05 经 new-api 中转实测(T10: 短提示词 N=5,声称可关的再加长上下文 N=3 复核)"
|
||||||
|
_DOC = "2026-09-04 文档推定(官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉)"
|
||||||
|
|
||||||
|
# T10 的三条判据(报告见 tests/outputs/thinking/,用例见 tests/e2e/test_thinking_live.py):
|
||||||
|
# ① 关闭方向要求**每轮**未观测到推理,任一轮观测到即证伪;
|
||||||
|
# ② 短提示词下的"关掉了"必须过长上下文复核——glm-5.3-flash 正是短提示词 5/5
|
||||||
|
# 未观测到推理、5000 token 长上下文下 2/3 轮露馅(issue #20 的原始现象);
|
||||||
|
# ③ 上游整片不回传推理信号(kimi/MiniMax/qwen/gpt 这几路的关闭档都是)时,
|
||||||
|
# "没看见"不算"没发生",另取一个无魔数锚点: 关闭档的 completion_tokens
|
||||||
|
# 必须严格小于 max 档。
|
||||||
|
|
||||||
|
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
|
||||||
|
{
|
||||||
|
# —— MiniMax ——
|
||||||
|
"MiniMax-M3": ThinkingCapability(
|
||||||
|
supported_efforts=(
|
||||||
|
Effort.NONE,
|
||||||
|
Effort.MINIMAL,
|
||||||
|
Effort.LOW,
|
||||||
|
Effort.MEDIUM,
|
||||||
|
Effort.HIGH,
|
||||||
|
Effort.XHIGH,
|
||||||
|
Effort.MAX,
|
||||||
|
),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: reasoning_effort=none 关闭成立(短 5/5 + 长上下文 3/3 未观测到推理,"
|
||||||
|
"completion 恒 3 token,且与 max 档 completion 57-173 锚点可分);六个强度值各 N=5 "
|
||||||
|
"全部观测到推理,rt 分布完全重叠(minimal 64-124 / low 55-112 / medium 51-104 / "
|
||||||
|
"high 62-128 / xhigh 58-118 / max 57-170)——**它们是'开'的六种写法,不是六个深度档**,"
|
||||||
|
"MiniMax 官方只有开/关两态,配哪一个都一样贵。"
|
||||||
|
"**`auto` 已从清单移除**: 实测当时 minimax 的「开」在 wire 上是 on_base={}"
|
||||||
|
"(什么参数都不注入),而 M3 的默认档实测不推理,故 auto 在这条路上表达不了「开」"
|
||||||
|
"(N=5 全部未观测到推理)。`resolve_thinking` 的 Phase 5 无条件放行 auto,能力表"
|
||||||
|
"堵不住这条,故 2026-09-05 由 wire 侧兜住: on_base 改回 {'reasoning_effort': 'medium'},"
|
||||||
|
"存量 ENABLE_THINKING=true 恢复真开推理(权宜之计,正解见 issue #21)。"
|
||||||
|
"本清单仍不含 auto——它记的是实测结论,不随 wire 的权宜之计变动。"
|
||||||
|
f"历史: {_MEASURED} N=10 同样成立;enable_thinking / thinking:{{type}} 两种写法对本模型"
|
||||||
|
"无效,reasoning_effort 才是真开关(findings 2026-08-25 §3.1/§5)。"
|
||||||
|
"另: 2026-08-25 记录的'MiniMax 这一路已停报 completion_tokens_details'本次**不再成立**"
|
||||||
|
"——开启档 rt 有值,只有关闭档整片缺 details"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"MiniMax-M2.7": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.AUTO,),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 100-161、推理正文 274-482 字符),"
|
||||||
|
"**关不掉**成立;auto 档 5/5 观测到推理。"
|
||||||
|
f"历史({_MEASURED}): reasoning_effort=none / thinking:{{disabled}} / thinking:{{adaptive}} "
|
||||||
|
"各 N=3 全部无效;OpenRouter 登记 mandatory:true,models.dev 登记无控制手段"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"MiniMax-M2.5": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.AUTO,),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 104-158),**关不掉**成立;"
|
||||||
|
"auto 档 5/5 观测到推理(rt 121-245)。"
|
||||||
|
f"历史({_MEASURED}): 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# —— qwen(百炼系,开关型) ——
|
||||||
|
"qwen3.7-plus": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.AUTO),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,且与 auto 档 completion 锚点可分);"
|
||||||
|
"auto 档 5/5 观测到推理。无强度档: 该 provider 的 wire 没有 effort_key,请求 max 当场被库"
|
||||||
|
"拒(百炼靠 thinking_budget 调深度,预算型控制本库不支持)。"
|
||||||
|
f"历史({_MEASURED}): enable_thinking=false 关闭(completion 5 token)"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"qwen3.7-max": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.AUTO),
|
||||||
|
evidence=f"{_T10}: 同 qwen3.7-plus——none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
|
||||||
|
),
|
||||||
|
"qwen3.6-plus": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.AUTO),
|
||||||
|
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
|
||||||
|
),
|
||||||
|
"qwen3.5-flash": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.AUTO),
|
||||||
|
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
|
||||||
|
),
|
||||||
|
"qwen-plus-latest": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.AUTO),
|
||||||
|
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
|
||||||
|
),
|
||||||
|
# —— deepseek ——
|
||||||
|
"deepseek-v4-pro": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,与 max 档锚点可分);"
|
||||||
|
"high / max 各 N=5 全部观测到推理(rt 59-73 / 56-69,推理正文 max 档明显更长: "
|
||||||
|
"135-186 vs 101-124 字符)。默认档按官方 thinking_mode 文档为 high"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"deepseek-v4-flash": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理"
|
||||||
|
"(rt 16-36 / 12-42)。与 v4-pro 同档,印证官方'与 deepseek-v4-pro 一致'的说法"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"deepseek-v4-flash-vision-exp": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理"
|
||||||
|
"(rt 12-17 / 17-34)"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# —— 智谱 ——
|
||||||
|
"glm-5.3": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: **推理不可关闭已实测坐实**——请求 none(注入 thinking:{{type:disabled}})后 "
|
||||||
|
"5 轮里 4 轮仍观测到推理(rt 7、推理正文 12 字符),只有 1 轮 rt=0;"
|
||||||
|
"low / high / max 各 N=5 全部观测到推理(rt 55-77 / 47-63 / 48-60,三档分不出深浅)。"
|
||||||
|
"这一条了结了 issue #20 的核心争议: 当时短提示词下 rt≈1.2 看着像关掉了,实为采样噪声。"
|
||||||
|
f"文档侧三源一致({_DOC}): 智谱官方 thinking.type 只接受 enabled、迁移建议改用 "
|
||||||
|
"enabled + reasoning_effort=low,cherry-studio 标 toggle:false,OpenRouter 标 mandatory:true。"
|
||||||
|
"默认 max。**注意本渠道不校验档位值**: 未登记的 medium 也会被照单接受(实测 rt 62),"
|
||||||
|
"故'网关没报错'在这一路上不构成'该档受支持'的证据"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"glm-5.3-flash": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: **推理不可关闭,且是判据②唯一的现役样本**——请求 none 时短提示词 5/5 轮"
|
||||||
|
"未观测到推理(看着完全像关掉了),换成 5000 token 长上下文后 3 轮里 2 轮露馅"
|
||||||
|
"(rt=2、有推理正文)。只跑短提示词的实测会在这个模型上得出相反结论。"
|
||||||
|
"low / high / max 各 N=5 全部观测到推理(rt 8-60 / 27-91 / 27-70)。默认 max"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"glm-5.2": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
|
||||||
|
evidence=(
|
||||||
|
f"{_DOC}: cherry-studio 登记 none/high/max(官方端点默认 max,百炼上默认 high)。"
|
||||||
|
"**T10 实测未覆盖——该渠道把本型号路由到了别的模型**: 请求 glm-5.2 时 5/5 轮回报 "
|
||||||
|
"model=glm-5.3(issue #20 记录的 6/6 复现),拿到的行为不属于本型号,故整组数据作废、"
|
||||||
|
"本行仍是文档推定。**下游风险**: 在本渠道上给 glm-5.2 配 none,库会照本行放行,"
|
||||||
|
"而真正服务请求的 glm-5.3 关不掉推理——运行期 reconcile 会喊,但那是事后"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"glm-5": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.AUTO),
|
||||||
|
evidence=(
|
||||||
|
f"{_DOC}: OpenRouter 登记只支持 reasoning 开关、无 reasoning_effort;cherry-studio 标 toggle:true。"
|
||||||
|
"**T10 实测未覆盖**: 与 glm-5.2 同因——5/5 轮回报 model=glm-5.3,数据不属于本型号"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"glm-5.1": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.AUTO),
|
||||||
|
evidence=(
|
||||||
|
f"{_DOC}: 同 glm-5(OpenRouter reasoning.mandatory=false 且无 supported_efforts)。"
|
||||||
|
"**T10 实测未覆盖**: 5/5 轮回报 model=glm-5.3,数据不属于本型号"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"glm-4.6v": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.AUTO),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: none 关闭成立,且是全表**证据最硬**的一条——短 5/5 + 长 3/3 全部裁定 ABSENT"
|
||||||
|
"(上游明确上报 reasoning_tokens=0,不是'看不见'),无需锚点旁证;"
|
||||||
|
"auto 档 5/5 观测到推理(rt 57-153)。model_reported 与请求一致,未被路由"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# —— 月之暗面 ——
|
||||||
|
"kimi-k3": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: **可关闭——推翻 T1 的保守登记**。请求 none(注入 thinking:{{type:disabled}})后"
|
||||||
|
"短 5/5 + 长上下文 3/3 轮无任何推理信号,completion 恒 9 token;同一模型 max 档 "
|
||||||
|
"completion 明显更大且带推理正文(rt 33-146),锚点可分——故'没看见'这次有正面证据支撑。"
|
||||||
|
"两源分歧由此了结: OpenRouter 的 mandatory:false 是对的,官方档位表没列 none 只是没列。"
|
||||||
|
"low / high / max 各 N=5 全部观测到推理(rt 21-53 / 38-60 / 33-146)。"
|
||||||
|
"**model_reported 是 `k3`**(别名,非串台)。官方提示切换档位会使 prefix cache 失效,"
|
||||||
|
"不宜在会话中途改档"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"kimi-for-coding": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: 本型号在 T1 时因'档位清单无直接证据'走 Phase 3 不登记(设计 §8 第三档),"
|
||||||
|
"现有它自己的实测证据故补登。none: 短 5/5 + 长 3/3 无推理信号、completion 恒 2 token,"
|
||||||
|
"与开启档锚点可分;low / high / max 各 N=3 全部观测到推理(rt 8-40 / 25-75 / 62-85)。"
|
||||||
|
"档位词汇沿用月之暗面官方的 low/high/max: 本渠道对 moonshot **不校验档位值**"
|
||||||
|
"(minimal/medium/xhigh 照样返回 200 并推理),故'没被拒'不构成'受支持',"
|
||||||
|
"登记一个厂商没声明的档等于替它做承诺"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# —— OpenAI ——
|
||||||
|
"gpt-5.4": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH),
|
||||||
|
evidence=(
|
||||||
|
f"{_DOC}: OpenRouter 登记 none/low/medium/high/xhigh,默认 medium;LiteLLM 登记 minimal 不支持。"
|
||||||
|
"**T10 实测未覆盖**: 该渠道本型号所有账号限流(429 All available accounts are "
|
||||||
|
"currently rate-limited),5/5 轮失败。同代的 gpt-5.5 已实测且与本清单逐字相符"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"gpt-5.5": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH),
|
||||||
|
evidence=(
|
||||||
|
f"{_T10}: 清单**逐条对上**,是全表验证最完整的一行。none 关闭成立(短 5/5 + 长 3/3,"
|
||||||
|
"锚点可分);low/medium/high/xhigh 各 N=5 全部观测到推理,且 rt 随档位单调上升"
|
||||||
|
"(18-21 / 18-22 / 22-34 / 35-65)——本渠道上少见的、档位真的分得开的模型;"
|
||||||
|
"清单外的 max 与 minimal 各 N=3 全部被上游 400 拒("
|
||||||
|
"Unsupported value),说明这一路**会校验档位值**,与 zhipu/moonshot 的照单全收相反"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# —— Anthropic ——
|
||||||
|
"claude-opus-5": ThinkingCapability(
|
||||||
|
supported_efforts=(
|
||||||
|
Effort.NONE,
|
||||||
|
Effort.LOW,
|
||||||
|
Effort.MEDIUM,
|
||||||
|
Effort.HIGH,
|
||||||
|
Effort.XHIGH,
|
||||||
|
Effort.MAX,
|
||||||
|
),
|
||||||
|
evidence=(
|
||||||
|
f"{_DOC}: Anthropic 官方 adaptive thinking + output_config.effort 五档(low/medium/high/"
|
||||||
|
"xhigh/max),默认 high;OpenRouter 标 mandatory:false 故可关。"
|
||||||
|
"**T10 实测未覆盖**: 该渠道 claude 全系返回 429「api key 7天限额已用完」,5/5 轮失败。"
|
||||||
|
"关闭档仍依赖 new-api 把 reasoning_effort=none 转成 thinking 关闭形态,未经验证"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"claude-sonnet-5": ThinkingCapability(
|
||||||
|
supported_efforts=(
|
||||||
|
Effort.NONE,
|
||||||
|
Effort.LOW,
|
||||||
|
Effort.MEDIUM,
|
||||||
|
Effort.HIGH,
|
||||||
|
Effort.XHIGH,
|
||||||
|
Effort.MAX,
|
||||||
|
),
|
||||||
|
evidence=(
|
||||||
|
f"{_DOC}: 同 claude-opus-5(OpenRouter supported_efforts 与默认档一致)。"
|
||||||
|
"**T10 实测未覆盖**: 同因 429「api key 7天限额已用完」"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# —— Google ——
|
||||||
|
"gemini-3.1-pro": ThinkingCapability(
|
||||||
|
supported_efforts=(Effort.LOW, Effort.MEDIUM, Effort.HIGH),
|
||||||
|
evidence=(
|
||||||
|
f"{_DOC}: **推理不可关闭**——Google 官方文档明确 Gemini 3 Pro / 3.1 Pro 无法关闭思考,"
|
||||||
|
"OpenRouter 亦标 mandatory:true。thinking_level 三档;默认档两源打架"
|
||||||
|
"(官方文档说 HIGH,OpenRouter 说 medium)。"
|
||||||
|
"**T10 实测未覆盖**: 该渠道本型号上游报错(bad_response_status_code / openai_error),"
|
||||||
|
"5/5 轮失败,连默认档基线都没取到,两源分歧仍悬着"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_capability(
|
||||||
|
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
||||||
|
) -> ThinkingCapability | None:
|
||||||
|
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
|
||||||
|
|
||||||
|
与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
|
||||||
|
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。
|
||||||
|
"""
|
||||||
|
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
|
||||||
|
|
||||||
|
|
||||||
|
def register_capability(
|
||||||
|
model: str,
|
||||||
|
capability: ThinkingCapability,
|
||||||
|
*,
|
||||||
|
base: Mapping[str, ThinkingCapability] | None = None,
|
||||||
|
) -> dict[str, ThinkingCapability]:
|
||||||
|
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
||||||
|
table = dict(DEFAULT_CAPABILITIES if base is None else base)
|
||||||
|
table[model] = capability
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingResolution:
|
||||||
|
"""请求体注入片段 + 本次**实际**生效的档位(设计 §4.1)。
|
||||||
|
|
||||||
|
返回 dataclass 而非裸 Mapping,是因为 `nearest` 映射后"请求的档"与"真正发出
|
||||||
|
去的档"会分叉(请求 `medium`、模型只有 low/high/max → 实际发 `low`)。遥测必
|
||||||
|
须记后者: 记请求档会让按档位分组的压测把整行数据挂在一个从未真正发出过的档
|
||||||
|
下,而那种数据错得看不出来。
|
||||||
|
|
||||||
|
`applied_effort is None` 只出现在 Phase 1(调用方不表态): 库既不注入,也不
|
||||||
|
去推定模型自己的默认档——"没看见"不许说成"发生了"。
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
applied_effort: Effort | None
|
||||||
|
|
||||||
|
|
||||||
|
def effective_effort(
|
||||||
|
*,
|
||||||
|
request_effort: Effort | None,
|
||||||
|
source_effort: Effort | None,
|
||||||
|
enable_thinking: bool | None,
|
||||||
|
) -> Effort | None:
|
||||||
|
"""求本次生效的档位: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。
|
||||||
|
|
||||||
|
**收口成一个纯函数**是本函数存在的全部理由: 装配守卫(`client._guard_thinking`)
|
||||||
|
与请求热路径(`openai_compat._build_payload`)必须给出**同一个**判定,两处各写
|
||||||
|
一份就地转换迟早会分叉,而分叉的形态是"装配期放行、运行期报错"——最难查的那种。
|
||||||
|
|
||||||
|
**一律用 `is None` 判有没有表态,不靠真值性**: `Effort.NONE`(要求不推理)与
|
||||||
|
`enable_thinking=False` 都是**表态**而非缺省,`x or y` 式的回落会把后者当成没配
|
||||||
|
从而跳到下一层——那正是本次要消灭的静默失效。
|
||||||
|
|
||||||
|
语法糖排在最末且 `True → AUTO`(开启但不指定强度,不依赖能力表),不是旧版那个
|
||||||
|
硬编码的 `medium`: 那是库替下游做的档位判断,而 `medium` 在 GLM/kimi/deepseek 的
|
||||||
|
档位表里根本不存在(设计 §4.2 声明过的有意变更)。
|
||||||
|
|
||||||
|
同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾,已由
|
||||||
|
`SourceConfig.__post_init__` 在构造期报错,故这里不再判——两个字段说同一件事时,
|
||||||
|
矛盾是配置错误,不是优先级问题。
|
||||||
|
"""
|
||||||
|
if request_effort is not None:
|
||||||
|
return request_effort
|
||||||
|
if source_effort is not None:
|
||||||
|
return source_effort
|
||||||
|
if enable_thinking is None:
|
||||||
|
return None
|
||||||
|
return Effort.AUTO if enable_thinking else Effort.NONE
|
||||||
|
|
||||||
|
|
||||||
|
_RAW_THINKING_ROOTS = frozenset(
|
||||||
|
{
|
||||||
|
"reasoning_effort",
|
||||||
|
"enable_thinking",
|
||||||
|
"thinking",
|
||||||
|
"thinking_budget",
|
||||||
|
"reasoning",
|
||||||
|
"thinkingConfig",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_output_effort(raw: Mapping[str, Any]) -> bool:
|
||||||
|
"""标准嵌套强度只检查明确的路径,不猜私有方言。"""
|
||||||
|
output = raw.get("output_config")
|
||||||
|
return isinstance(output, Mapping) and "effort" in output
|
||||||
|
|
||||||
|
|
||||||
|
def validate_thinking_wire(wire: ThinkingWire, *, model: str) -> None:
|
||||||
|
"""拒绝开启片段代选强度,未知形态仍交由请求方向检查。"""
|
||||||
|
base = wire.on_base
|
||||||
|
if base is not None and (
|
||||||
|
"reasoning_effort" in base
|
||||||
|
or (wire.effort_key is not None and wire.effort_key in base)
|
||||||
|
or _has_output_effort(base)
|
||||||
|
):
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"模型 {model!r} 的 on_base 不得包含强度档位;请移除强度并显式传 reasoning_effort"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_thinking_raw(
|
||||||
|
raw: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
effort: Effort | None,
|
||||||
|
wire: ThinkingWire | None,
|
||||||
|
origin: str,
|
||||||
|
) -> None:
|
||||||
|
"""受管推理只有一个来源;同值或被遮蔽的 raw 控制也拒绝。"""
|
||||||
|
if effort is None:
|
||||||
|
return
|
||||||
|
roots = set(_RAW_THINKING_ROOTS)
|
||||||
|
if wire is not None:
|
||||||
|
for fragment in (wire.on_base, wire.off):
|
||||||
|
if fragment is not None:
|
||||||
|
roots.update(fragment)
|
||||||
|
if wire.effort_key is not None:
|
||||||
|
roots.add(wire.effort_key)
|
||||||
|
if roots.intersection(raw) or _has_output_effort(raw):
|
||||||
|
# 不打印 raw 或自定义键名,防配置中夹带秘密。
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"{origin} 与受管 reasoning_effort={effort.value!r} 冲突;"
|
||||||
|
"请删除 raw 推理控制,或移除源级/请求级推理表态后仅用 raw"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_thinking(
|
||||||
|
profile: ProviderProfile,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
effort: Effort | str | None,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
fallback: str = "error",
|
||||||
|
warn_unregistered: bool = True,
|
||||||
|
) -> ThinkingResolution:
|
||||||
|
"""档位 + 两层声明(形态/能力)→ 注入片段;不可满足时 `ThinkingUnsupportedError`。
|
||||||
|
|
||||||
|
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
|
||||||
|
`RequestRejectedError`(四分类之一)。**判定顺序即语义,不可调换**:
|
||||||
|
|
||||||
|
========== ================================================================
|
||||||
|
Phase 1 不表态 → 不注入。与 `Effort.NONE` 严格区分: 前者是"随模型默认",
|
||||||
|
后者是"要求不推理"
|
||||||
|
Phase 2 **该请求档所需的**形态未知 → 报错(判据见 `_wire_unknown_for`)。
|
||||||
|
无从注入时,模型能力如何都无关紧要,故必须先于 4/5
|
||||||
|
Phase 3 能力未登记 → 尽力注入且**不校验档位**。没有清单可比对,拿空清单
|
||||||
|
去拒绝档位就是凭空报错;新模型上线不该被库挡住(设计 §5 R4)
|
||||||
|
Phase 4 请求 `none` 而模型关不掉 → 报错并给出 `cheapest_effort`
|
||||||
|
Phase 5 其余档位打空 → 报错(或按 `fallback` 映射)
|
||||||
|
========== ================================================================
|
||||||
|
|
||||||
|
**4 必须先于 5**: `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就
|
||||||
|
退化成"不支持 none,可选 low/high/max"——丢掉"这个模型根本关不掉"这个关键
|
||||||
|
信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是
|
||||||
|
issue #20 的成因。
|
||||||
|
|
||||||
|
**已登记的 AUTO 同样受清单约束**: 开启形态不证明模型支持不指定强度。
|
||||||
|
AUTO 不在强弱轴上,不允许 nearest 静默代选付费档位;未知模型仍尽力并告警。
|
||||||
|
|
||||||
|
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
||||||
|
`capability` 为 None(未登记)时无从从别处取得模型名。
|
||||||
|
|
||||||
|
`fallback="nearest"` 是 Phase 5 的逃生口,**默认关闭的理由是钱**: 一次静默的
|
||||||
|
`medium → max` 在 GLM-5.3 上是数倍账单(P5"严禁默认值掩盖错误")。
|
||||||
|
|
||||||
|
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次调用
|
||||||
|
再喊只会刷屏。判定结果不受此参数影响。
|
||||||
|
|
||||||
|
**裸字符串也收**(设计 §4.4 第 4 条入口): 本函数在 `__all__` 里,下游直调时
|
||||||
|
传的天然是从 JSON/配置读出来的 `"low"`,而第三参数本次由 `bool` 换成 `Effort`
|
||||||
|
正是这条入口冒出来的时机。签名照实写 `Effort | str`——下面每一关的判据都是
|
||||||
|
`is Effort.X` 的身份比较,`"none" is Effort.NONE` 恒假,不归一的后果不是报错
|
||||||
|
而是**静默判否**: Phase 2 按开启方向取字段、Phase 4 整条被绕过,最后在拼错误
|
||||||
|
文案时才以 `AttributeError` 现形(一个未文档化、也不属四分类的异常)。
|
||||||
|
"""
|
||||||
|
validate_thinking_wire(profile.thinking, model=model)
|
||||||
|
# Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举
|
||||||
|
if effort is not None:
|
||||||
|
effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})")
|
||||||
|
# Phase 1: 调用方不表态 —— 与 Effort.NONE 严格区分,用模型自己的默认档
|
||||||
|
if effort is None:
|
||||||
|
return ThinkingResolution({}, None)
|
||||||
|
wire = profile.thinking
|
||||||
|
# Phase 2: 形态未知 —— 给了档位却不知道怎么发,静默放行就是欺骗调用方
|
||||||
|
if _wire_unknown_for(wire, effort):
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"provider {profile.name!r} 的推理形态未知(模型 {model!r},请求档位 "
|
||||||
|
f"{effort.value!r}): 本库不知道该 provider 如何表达推理。请用 "
|
||||||
|
f"register_provider 注册形态,或改用 SourceConfig.extra_body 直接下发供应商参数"
|
||||||
|
)
|
||||||
|
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
|
||||||
|
if capability is None:
|
||||||
|
payload = _inject(profile, effort, model=model)
|
||||||
|
if warn_unregistered:
|
||||||
|
_warn_unregistered(model, profile, effort, payload)
|
||||||
|
return ThinkingResolution(payload, effort)
|
||||||
|
# Phase 4: 明确关不掉 —— 调用方要的是"不推理"的语义保证,给不了必须说,且必须
|
||||||
|
# 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由)
|
||||||
|
if effort is Effort.NONE and not capability.can_disable:
|
||||||
|
raise ThinkingUnsupportedError(_cannot_disable(model, capability))
|
||||||
|
# Phase 5: 已登记选择必须可满足;AUTO 不允许按强度距离映射
|
||||||
|
applied = _settle_tier(effort, capability, model=model, fallback=fallback)
|
||||||
|
return ThinkingResolution(_inject(profile, applied, model=model), applied)
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_unknown_for(wire: ThinkingWire, effort: Effort) -> bool:
|
||||||
|
"""Phase 2 的判据: **按请求档取相关字段**,不是一律看 `on_base`。
|
||||||
|
|
||||||
|
旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此。只看
|
||||||
|
`on_base` 会让"关闭形态已知、开启形态未知"的自定义 provider 在请求 `none` 时
|
||||||
|
被误拒,且指向它已经做过的 `register_provider`(设计 §2 处置表第 2 条)。
|
||||||
|
|
||||||
|
请求 `none` 时判据是**两者皆 None**,而不是单看 `off`: `ThinkingWire` 的三个
|
||||||
|
`None` 语义互不重叠——`off is None` 而 `on_base` 已知是"该 provider 关不掉"
|
||||||
|
(由 `_inject` 说清是缺了哪半边),只有两者皆 None 才是"整个形态未知",此时
|
||||||
|
指路 `register_provider` 才是对的方向。
|
||||||
|
"""
|
||||||
|
if effort is not Effort.NONE:
|
||||||
|
return wire.on_base is None
|
||||||
|
return wire.off is None and wire.on_base is None
|
||||||
|
|
||||||
|
|
||||||
|
def _settle_tier(
|
||||||
|
effort: Effort, capability: ThinkingCapability, *, model: str, fallback: str
|
||||||
|
) -> Effort:
|
||||||
|
"""Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。
|
||||||
|
|
||||||
|
AUTO 与强度档统一检查成员,但不参与最近强度映射。
|
||||||
|
"""
|
||||||
|
if effort in capability.supported_efforts:
|
||||||
|
return effort
|
||||||
|
mapped = (
|
||||||
|
_nearest_effort(effort, capability)
|
||||||
|
if fallback == "nearest" and effort is not Effort.AUTO
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if mapped is None:
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
_tier_unsupported(model, effort, capability, fallback=fallback)
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"模型 {} 不支持 reasoning_effort={},按 effort_fallback=nearest 改用最近的 {} 档;"
|
||||||
|
"本次真正发出去的、以及遥测成功行记的都是后者,但**缓存 key 记的是前者**"
|
||||||
|
"(CacheMW 在洋葱里比 transport 更外,查缓存时映射尚未发生,拿不到实发档)",
|
||||||
|
model,
|
||||||
|
effort.value,
|
||||||
|
mapped.value,
|
||||||
|
)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def _inject(profile: ProviderProfile, effort: Effort, *, model: str) -> Mapping[str, Any]:
|
||||||
|
"""按 wire 把档位写成请求体片段;wire 表达不了这一档时报错。
|
||||||
|
|
||||||
|
自己重读 `wire` 而不由调用方传 `on_base`: Phase 2 的判据按请求档取相关字段
|
||||||
|
(`none` 看 `off`,其余档看 `on_base`)之后,"on_base 一定不是 None"这条前提
|
||||||
|
只对非 `none` 档成立,写进签名反而是句假话。
|
||||||
|
|
||||||
|
三种 `None` 的语义在此**各自兑现**(ThinkingWire 的 docstring 定义了它们):
|
||||||
|
`off is None` = 该 provider 关不掉,`effort_key is None` = 它只有开关没有档位。
|
||||||
|
两者都不是"形态未知",故都不指向 `register_provider`——指错了排查方向比不指
|
||||||
|
还糟。
|
||||||
|
"""
|
||||||
|
wire = profile.thinking
|
||||||
|
if effort is Effort.NONE:
|
||||||
|
if wire.off is None:
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"provider {profile.name!r} 没有关闭形态(模型 {model!r}): "
|
||||||
|
f"本库知道它如何表达开启,但该 provider 没有可用的关闭参数。"
|
||||||
|
f"需要不推理请换用支持关闭的 provider 或模型"
|
||||||
|
)
|
||||||
|
return wire.off
|
||||||
|
# 非 none 档的开启形态由 Phase 2 保证已知(内部不变量,不承担生产校验)
|
||||||
|
assert wire.on_base is not None
|
||||||
|
if effort is Effort.AUTO:
|
||||||
|
# auto = 开启但不指定强度: 逐字节等于升级前的 `thinking_on`
|
||||||
|
return wire.on_base
|
||||||
|
if wire.effort_key is None:
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"provider {profile.name!r} 只有推理开关、没有档位键(模型 {model!r}),"
|
||||||
|
f"表达不了 reasoning_effort={effort.value!r}: 请改用 auto/none 两档,"
|
||||||
|
f"或用 register_provider 给该 provider 注册 effort_key"
|
||||||
|
)
|
||||||
|
return {**wire.on_base, wire.effort_key: effort.value}
|
||||||
|
|
||||||
|
|
||||||
|
def _cannot_disable(model: str, capability: ThinkingCapability) -> str:
|
||||||
|
"""Phase 4 的文案: 报错必须带一条能立刻照做的替代,否则等于把用户推回起点。
|
||||||
|
|
||||||
|
只报"关不掉"而不给出路,下游就会去找 `extra_body` 那条绕过库的路——issue #20
|
||||||
|
的成因正是如此。故文案必须含 `cheapest_effort` 的值与 env 键名两样东西。
|
||||||
|
"""
|
||||||
|
# Phase 4 只在 none 不在清单里时触发,而清单构造期保证非空,故必有一档可推荐
|
||||||
|
alternative = capability.cheapest_effort
|
||||||
|
assert alternative is not None
|
||||||
|
return (
|
||||||
|
f"模型 {model!r} 无法关闭推理,reasoning_effort='none' 无法满足: "
|
||||||
|
f"{capability.evidence}。最省的开启档是 {alternative.value!r}——请配 "
|
||||||
|
f"{{SCOPE}}__{{PROVIDER}}__{{N}}__REASONING_EFFORT={alternative.value},"
|
||||||
|
f"或调用时传 reasoning_effort=Effort.{alternative.name};"
|
||||||
|
f"真正需要不推理请换用支持关闭的模型"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_unsupported(
|
||||||
|
model: str, effort: Effort, capability: ThinkingCapability, *, fallback: str
|
||||||
|
) -> str:
|
||||||
|
"""Phase 5 的文案: 按 `is_tiered` 分叉,纯开关型模型不能被告知"可选档位"。
|
||||||
|
|
||||||
|
它没有档位——对它说"可选档位: none, auto"是把开关说成了强度轴,下游照着找
|
||||||
|
档位只会一无所获(设计 §3.2 第三个派生量的用途就是这一句话该怎么说)。
|
||||||
|
"""
|
||||||
|
listed = ", ".join(e.value for e in _ordered(capability.supported_efforts))
|
||||||
|
head = f"模型 {model!r} 不支持 reasoning_effort={effort.value!r}: {capability.evidence}。"
|
||||||
|
body = (
|
||||||
|
f"该模型的可选档位: {listed}"
|
||||||
|
if capability.is_tiered
|
||||||
|
else f"该模型只有开关、没有强度档位,可用: {listed}"
|
||||||
|
)
|
||||||
|
# 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话
|
||||||
|
hint = (
|
||||||
|
""
|
||||||
|
if fallback == "nearest" or effort is Effort.AUTO
|
||||||
|
else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest"
|
||||||
|
)
|
||||||
|
if effort is Effort.AUTO:
|
||||||
|
example = capability.cheapest_effort or Effort.NONE
|
||||||
|
hint = (
|
||||||
|
";请显式选择清单中的档位,例如 "
|
||||||
|
f"{{SCOPE}}__{{PROVIDER}}__{{N}}__REASONING_EFFORT={example.value},"
|
||||||
|
f"或调用时传 reasoning_effort=Effort.{example.name};库不会自动应用此选择"
|
||||||
|
)
|
||||||
|
return f"{head}{body}{hint}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered(efforts: tuple[Effort, ...]) -> list[Effort]:
|
||||||
|
"""按由弱到强列出档位;`auto` 不在强弱轴上,排在末尾。"""
|
||||||
|
ordered = [e for e in EFFORT_ORDER if e in efforts]
|
||||||
|
if Effort.AUTO in efforts:
|
||||||
|
ordered.append(Effort.AUTO)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _nearest_effort(requested: Effort, capability: ThinkingCapability) -> Effort | None:
|
||||||
|
"""取距 `requested` 位序最近的**开启档**;等距取弱侧,无开启档时返回 None。
|
||||||
|
|
||||||
|
候选**剔除 `none`**: 把"想得浅一点"映射成"别想了"是方向反转而非省钱,正是
|
||||||
|
issue #20 那种静默失效的翻版。`none` 的领域归 Phase 4,它在那里已经被处理过,
|
||||||
|
走不到这里(能关就不会打空,不能关就已经报错)。
|
||||||
|
|
||||||
|
`auto` 不在强弱轴上(`EFFORT_ORDER` 不含它),故不参与距离计算,只在一个强度
|
||||||
|
档都没有时兜底——它恰好是纯开关型模型唯一能表达"开"的档。
|
||||||
|
|
||||||
|
**等距取弱**的理由是钱: 一次静默的 `medium → max` 在 GLM-5.3 上是数倍账单,
|
||||||
|
库不替下游涨价。
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
e for e in EFFORT_ORDER if e is not Effort.NONE and e in capability.supported_efforts
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return Effort.AUTO if Effort.AUTO in capability.supported_efforts else None
|
||||||
|
target = EFFORT_ORDER.index(requested)
|
||||||
|
# 排序键第二位是位序本身: 距离相同时位序小的(更省的)胜出
|
||||||
|
return min(
|
||||||
|
candidates, key=lambda e: (abs(EFFORT_ORDER.index(e) - target), EFFORT_ORDER.index(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unregistered(
|
||||||
|
model: str, profile: ProviderProfile, effort: Effort, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});"
|
||||||
|
"不保证开启、关闭或强度生效。实测后请用 register_capability 登记",
|
||||||
|
model,
|
||||||
|
profile.name,
|
||||||
|
dict(payload),
|
||||||
|
effort.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_thinking(
|
||||||
|
*,
|
||||||
|
effort: Effort | None,
|
||||||
|
observation: ThinkingObservation,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
model: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
|
||||||
|
|
||||||
|
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
|
||||||
|
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
|
||||||
|
|
||||||
|
**判据是档位而非布尔**(2026-09-05,设计 §4.3): `Effort.NONE` 走"要求关闭"
|
||||||
|
一支,其余任何档走"要求开启"一支,`None`(不表态)仍沉默。判据必须写成
|
||||||
|
`is Effort.NONE` 的**身份比较**——它的取值是非空串 `"none"`,任何靠真值性
|
||||||
|
的写法(`if not effort`)都恒为假,会把每个强度档送进关闭分支,告警方向整个
|
||||||
|
颠倒。传入的应是**实际发出去**的那一档(`nearest` 映射后与请求档分叉),
|
||||||
|
否则文案会说一个从未发出过的档。
|
||||||
|
|
||||||
|
**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3/§11 第 1
|
||||||
|
条): 二者没有可判定的函数关系(实测同一档 rt 在 8~56 之间跳),拿它报警必然
|
||||||
|
是噪声,而噪声等于没有告警。该问题归 §11 的压测,不进库。
|
||||||
|
|
||||||
|
**只判定、不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容,
|
||||||
|
而不必去解析日志格式;节流也才能留在握有实例状态的 transport 里。
|
||||||
|
|
||||||
|
**不抛错**: 一次观测不足以否决一次成功的调用;可观测性属遥测方向,降级即
|
||||||
|
warning(P5 的"报错而非放行"只约束限流/熔断)。矛盾结果已随 `LLMResponse`
|
||||||
|
与遥测落地,处置权归下游。
|
||||||
|
"""
|
||||||
|
# Phase 1: 调用方不表态 —— 没提要求就无从谈"违背"
|
||||||
|
if effort is None:
|
||||||
|
return None
|
||||||
|
# Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警
|
||||||
|
# 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警
|
||||||
|
if effort is Effort.NONE:
|
||||||
|
if observation is not ThinkingObservation.OBSERVED:
|
||||||
|
return None
|
||||||
|
return _off_but_observed(model, capability)
|
||||||
|
# Phase 3: 要求开启(含 auto 与各强度档)—— ABSENT 是正面证伪,UNKNOWN 是
|
||||||
|
# "看不见",两者文案不可混。文案写出**是哪一档**: transport 的节流键正按档
|
||||||
|
# 分离,文案不分档的话,两条告警长得一模一样,看的人分不出是哪一档出的问题
|
||||||
|
if observation is ThinkingObservation.ABSENT:
|
||||||
|
return (
|
||||||
|
f"模型 {model!r} 的 reasoning_effort={effort.value!r} 未生效: 已注入开启参数,"
|
||||||
|
f"上游却明确上报本次未推理(reasoning_tokens=0)"
|
||||||
|
)
|
||||||
|
if observation is ThinkingObservation.UNKNOWN:
|
||||||
|
return (
|
||||||
|
f"模型 {model!r} 的 reasoning_effort={effort.value!r} 无法确认是否生效: "
|
||||||
|
f"已注入开启参数,但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。"
|
||||||
|
f"若走的是非流式路径,推理内容可能已计费却不回传"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _off_but_observed(model: str, capability: ThinkingCapability | None) -> str:
|
||||||
|
"""关闭请求未被满足的两种说法;登记与否决定该说哪一句。
|
||||||
|
|
||||||
|
两者必须分开: `resolve_thinking` 对未登记模型的告警是**事前猜测**,这里是
|
||||||
|
**事后实证**。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。
|
||||||
|
"""
|
||||||
|
if capability is None:
|
||||||
|
return (
|
||||||
|
f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生,"
|
||||||
|
f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。"
|
||||||
|
f"请实测后用 register_capability 登记其真实能力"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生,"
|
||||||
|
f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。"
|
||||||
|
f"能力表可能已过期——请复测后用 register_capability 更新登记"
|
||||||
|
)
|
||||||
@@ -11,9 +11,11 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
PolyGatewayError,
|
PolyGatewayError,
|
||||||
@@ -22,17 +24,25 @@ from polygateway.errors import (
|
|||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
from polygateway.providers import (
|
from polygateway.providers import ProviderProfile, get_provider
|
||||||
ProviderProfile,
|
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||||
|
from polygateway.thinking import (
|
||||||
ThinkingCapability,
|
ThinkingCapability,
|
||||||
ThinkingUnsupportedError,
|
ThinkingUnsupportedError,
|
||||||
|
effective_effort,
|
||||||
get_capability,
|
get_capability,
|
||||||
get_provider,
|
observe_thinking,
|
||||||
|
reconcile_thinking,
|
||||||
resolve_thinking,
|
resolve_thinking,
|
||||||
|
validate_thinking_raw,
|
||||||
)
|
)
|
||||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
|
||||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||||
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
from polygateway.types import (
|
||||||
|
Effort,
|
||||||
|
EmbeddingTransportResult,
|
||||||
|
SourceConfig,
|
||||||
|
TransportResult,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Callable, Mapping
|
from collections.abc import AsyncIterator, Callable, Mapping
|
||||||
@@ -144,18 +154,28 @@ def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
|
|||||||
|
|
||||||
|
|
||||||
def _status_to_error(
|
def _status_to_error(
|
||||||
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
|
source: SourceConfig,
|
||||||
|
status: int,
|
||||||
|
body_text: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
) -> Exception:
|
) -> Exception:
|
||||||
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
|
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
|
||||||
|
|
||||||
摘要只算一次,message 与 `body_text` 共用同一份串: 两份不同长度会让"遥测里
|
摘要只算一次,message 与 `body_text` 共用同一份串: 两份不同长度会让"遥测里
|
||||||
看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
|
看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
|
||||||
|
|
||||||
|
`operation` 是 **HTTP 子操作**词表(`chat` / `embedding` / `ocr_text` / ...),由
|
||||||
|
调用点显式给定。它曾被硬编码成 `"chat"`,而 `embed()` 的非 200 分支也走它
|
||||||
|
——于是现存所有 embedding HTTP 失败的 `exc.operation` 都是错的(1.3.5 设计 §5 I1)。
|
||||||
|
注意它与遥测新列 `operation`(公开方法四值)是**两个语义**,不做自动转换。
|
||||||
"""
|
"""
|
||||||
summary = summarize_body(body_text)
|
summary = summarize_body(body_text)
|
||||||
ctx: dict[str, Any] = {
|
ctx: dict[str, Any] = {
|
||||||
"source_name": source.name,
|
"source_name": source.name,
|
||||||
"status_code": status,
|
"status_code": status,
|
||||||
"operation": "chat",
|
"operation": operation,
|
||||||
"body_text": summary,
|
"body_text": summary,
|
||||||
}
|
}
|
||||||
if status == 429:
|
if status == 429:
|
||||||
@@ -320,6 +340,12 @@ class OpenAICompatTransport:
|
|||||||
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
|
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
|
||||||
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
|
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
|
||||||
self._warned_models: set[str] = set()
|
self._warned_models: set[str] = set()
|
||||||
|
# 对账告警独立节流,**不复用** `_warned_models`: 两者语义不同(那个 set 记
|
||||||
|
# 的是"未登记能力已告警过",这个记的是"某源某方向的矛盾已告警过"),共用
|
||||||
|
# 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略,
|
||||||
|
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
|
||||||
|
# 分开维护的理由是语义,不是碰撞)
|
||||||
|
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
|
||||||
self._client_factory = client_factory or _default_client_factory
|
self._client_factory = client_factory or _default_client_factory
|
||||||
self._clients: dict[str, httpx.AsyncClient] = {}
|
self._clients: dict[str, httpx.AsyncClient] = {}
|
||||||
|
|
||||||
@@ -338,7 +364,14 @@ class OpenAICompatTransport:
|
|||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
stream: bool,
|
stream: bool,
|
||||||
overlay: dict[str, Any],
|
overlay: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
reasoning_effort: Effort | None,
|
||||||
|
) -> tuple[dict[str, Any], Effort | None]:
|
||||||
|
"""组装请求体,并交回本次**实际**发出去的档位(`None` = 未表态,不注入)。
|
||||||
|
|
||||||
|
返回二元组而非只返回 payload: 实际档在 `nearest` 映射后与请求档分叉,而
|
||||||
|
除本函数外没有第二处知道映射结果——不交出去,遥测就只能事后再算一遍,
|
||||||
|
算出来必是请求档。
|
||||||
|
"""
|
||||||
payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
|
payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
|
||||||
if stream:
|
if stream:
|
||||||
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
|
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
|
||||||
@@ -347,20 +380,31 @@ class OpenAICompatTransport:
|
|||||||
capability = get_capability(source.model, table=self._capabilities)
|
capability = get_capability(source.model, table=self._capabilities)
|
||||||
first_time = source.model not in self._warned_models
|
first_time = source.model not in self._warned_models
|
||||||
self._warned_models.add(source.model)
|
self._warned_models.add(source.model)
|
||||||
payload.update(
|
# 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。
|
||||||
resolve_thinking(
|
# 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错"
|
||||||
profile,
|
effort = effective_effort(
|
||||||
capability,
|
request_effort=reasoning_effort,
|
||||||
source.enable_thinking,
|
source_effort=source.reasoning_effort,
|
||||||
model=source.model,
|
enable_thinking=source.enable_thinking,
|
||||||
warn_unregistered=first_time,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
resolution = resolve_thinking(
|
||||||
|
profile,
|
||||||
|
capability,
|
||||||
|
effort,
|
||||||
|
model=source.model,
|
||||||
|
# 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确
|
||||||
|
# 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出)
|
||||||
|
fallback=source.effort_fallback,
|
||||||
|
warn_unregistered=first_time,
|
||||||
|
)
|
||||||
|
for raw, origin in ((source.extra_body, "source extra_body"), (overlay, "request overlay")):
|
||||||
|
validate_thinking_raw(raw, effort=effort, wire=profile.thinking, origin=origin)
|
||||||
|
payload.update(resolution.payload)
|
||||||
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
|
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
|
||||||
# overlay(含结构化注入)在后覆盖之。两行不可调换
|
# overlay(含结构化注入)在后覆盖之。两行不可调换
|
||||||
payload.update(source.extra_body)
|
payload.update(source.extra_body)
|
||||||
payload.update(overlay)
|
payload.update(overlay)
|
||||||
return payload
|
return payload, resolution.applied_effort
|
||||||
|
|
||||||
async def complete(
|
async def complete(
|
||||||
self,
|
self,
|
||||||
@@ -370,12 +414,22 @@ class OpenAICompatTransport:
|
|||||||
stream: bool,
|
stream: bool,
|
||||||
overlay: dict[str, Any],
|
overlay: dict[str, Any],
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
reasoning_effort: Effort | None,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。"""
|
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
|
||||||
|
|
||||||
|
`reasoning_effort` 是**请求级**档位(`None` = 不表态);它与源级配置的优先级
|
||||||
|
在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。
|
||||||
|
"""
|
||||||
profile = get_provider(source.provider, registry=self._registry)
|
profile = get_provider(source.provider, registry=self._registry)
|
||||||
try:
|
try:
|
||||||
payload = self._build_payload(
|
payload, applied_effort = self._build_payload(
|
||||||
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
|
messages=messages,
|
||||||
|
source=source,
|
||||||
|
profile=profile,
|
||||||
|
stream=stream,
|
||||||
|
overlay=overlay,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
)
|
)
|
||||||
except ThinkingUnsupportedError as exc:
|
except ThinkingUnsupportedError as exc:
|
||||||
# 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个
|
# 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个
|
||||||
@@ -390,8 +444,9 @@ class OpenAICompatTransport:
|
|||||||
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
||||||
try:
|
try:
|
||||||
if stream:
|
if stream:
|
||||||
return await self._complete_stream(client, url, payload, source, profile)
|
result = await self._complete_stream(client, url, payload, source, profile)
|
||||||
return await self._complete_once(client, url, payload, source, profile)
|
else:
|
||||||
|
result = await self._complete_once(client, url, payload, source, profile)
|
||||||
except StreamLivenessTimeout as exc:
|
except StreamLivenessTimeout as exc:
|
||||||
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
|
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
@@ -399,6 +454,50 @@ class OpenAICompatTransport:
|
|||||||
except httpx.TransportError as exc:
|
except httpx.TransportError as exc:
|
||||||
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
|
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
|
||||||
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
|
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
|
||||||
|
# 实际发出去的档只有 `_build_payload` 知道,而组装 TransportResult 的两条
|
||||||
|
# 路径都在更深一层。在此唯一汇合点补齐,好过给两条路径各加一个参数——那正是
|
||||||
|
# 遥测那边被明令禁止的"复制参数列表"形态,两条路径迟早只改一条
|
||||||
|
result = replace(result, applied_effort=applied_effort)
|
||||||
|
# 此处是唯一同时握有请求档位与响应结果的地方,对账只能落在这里
|
||||||
|
self._warn_on_thinking_mismatch(source, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None:
|
||||||
|
"""声明与观测矛盾即 warning;按 (source, model, 实际档位) 节流,同组合只喊一次。
|
||||||
|
|
||||||
|
三段缺一不可。**档位**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个
|
||||||
|
键会让第二个永久静音(旧版拿 `enable_thinking` 当第三段,而档位根本不经过
|
||||||
|
那个字段,于是同一模型的所有档共用一个键)。**源名**: 多源多账号是本库的
|
||||||
|
核心场景,同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,一个
|
||||||
|
源的行为不代表另一个——漏掉源名,5 个源里第一个出问题的喊完一次,其余四个
|
||||||
|
永久静音。逐次调用刷屏会把告警变成噪声,噪声等于没有告警。
|
||||||
|
|
||||||
|
档位取 `result.applied_effort`(真正发出去的那一档)而非请求档: `nearest`
|
||||||
|
映射后二者分叉,而对账问的是"我发出去的要求有没有被满足"——拿一个从未发出
|
||||||
|
过的档去对账,文案与键都指向了一次不存在的请求。被映射到同一档的两个请求
|
||||||
|
因此共用一个键,这正是它们该有的关系(同一条实际要求,同一个矛盾)。
|
||||||
|
|
||||||
|
**先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串,
|
||||||
|
而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。
|
||||||
|
"""
|
||||||
|
key = (source.name, source.model, result.applied_effort)
|
||||||
|
if key in self._warned_mismatches:
|
||||||
|
return
|
||||||
|
message = reconcile_thinking(
|
||||||
|
effort=result.applied_effort,
|
||||||
|
observation=result.thinking_observation,
|
||||||
|
capability=get_capability(source.model, table=self._capabilities),
|
||||||
|
model=source.model,
|
||||||
|
)
|
||||||
|
if message is None:
|
||||||
|
return
|
||||||
|
self._warned_mismatches.add(key)
|
||||||
|
# 源名拼在调用点而不是加进 `reconcile_thinking` 的签名: 那是纯判定函数,
|
||||||
|
# 输入只该含判定依据(声明/观测/能力/模型),源名是**定位信息**,进不了判据。
|
||||||
|
# 单参数传入 loguru: 文案里带 `thinking:{type:disabled}` 这类字面花括号
|
||||||
|
# (能力表 evidence),将来有人给这行加个格式化参数就会炸在成功调用的返回
|
||||||
|
# 路径上(与 telemetry/sqlite.py 的缺列告警同一先例)
|
||||||
|
logger.warning("源 {} —— {}", source.name, message)
|
||||||
|
|
||||||
async def embed(
|
async def embed(
|
||||||
self, *, texts: list[str], source: SourceConfig, call_id: str
|
self, *, texts: list[str], source: SourceConfig, call_id: str
|
||||||
@@ -420,7 +519,10 @@ class OpenAICompatTransport:
|
|||||||
except httpx.TransportError as exc:
|
except httpx.TransportError as exc:
|
||||||
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
|
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise _status_to_error(source, resp.status_code, resp.text, resp.headers)
|
# 历史误标修正: 本分支属 `embed()`,与上方 ctx 同为 `"embedding"`
|
||||||
|
raise _status_to_error(
|
||||||
|
source, resp.status_code, resp.text, resp.headers, operation="embedding"
|
||||||
|
)
|
||||||
return _parse_embedding_payload(resp, source, len(texts))
|
return _parse_embedding_payload(resp, source, len(texts))
|
||||||
|
|
||||||
async def _complete_stream(
|
async def _complete_stream(
|
||||||
@@ -435,7 +537,9 @@ class OpenAICompatTransport:
|
|||||||
async with client.stream("POST", url, json=payload) as resp:
|
async with client.stream("POST", url, json=payload) as resp:
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
body = (await resp.aread()).decode("utf-8", errors="replace")
|
body = (await resp.aread()).decode("utf-8", errors="replace")
|
||||||
raise _status_to_error(source, resp.status_code, body, resp.headers)
|
raise _status_to_error(
|
||||||
|
source, resp.status_code, body, resp.headers, operation="chat"
|
||||||
|
)
|
||||||
sink: dict[str, Any] = {}
|
sink: dict[str, Any] = {}
|
||||||
guarded = stream_with_liveness_timeouts(
|
guarded = stream_with_liveness_timeouts(
|
||||||
_iter_sse_deltas(resp.aiter_lines(), sink),
|
_iter_sse_deltas(resp.aiter_lines(), sink),
|
||||||
@@ -460,6 +564,7 @@ class OpenAICompatTransport:
|
|||||||
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
||||||
self._reject_empty_completion(content, source)
|
self._reject_empty_completion(content, source)
|
||||||
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
||||||
|
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content=content,
|
content=content,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
@@ -471,7 +576,12 @@ class OpenAICompatTransport:
|
|||||||
raw={"usage": sink.get("usage")},
|
raw={"usage": sink.get("usage")},
|
||||||
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
||||||
model_reported=_coerce_model_reported(sink.get("model")),
|
model_reported=_coerce_model_reported(sink.get("model")),
|
||||||
reasoning_tokens=_coerce_reasoning_tokens(sink.get("usage")),
|
reasoning_tokens=reasoning_tokens,
|
||||||
|
# 两条组装路径必须同口径裁定: 只在一条路径上给结论,下游就得靠
|
||||||
|
# "这次是不是流式"去猜可观测性,那正是 issue #16/#17 的根因形态
|
||||||
|
thinking_observation=observe_thinking(
|
||||||
|
thinking=thinking, reasoning_tokens=reasoning_tokens
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_done(
|
def _check_done(
|
||||||
@@ -527,7 +637,9 @@ class OpenAICompatTransport:
|
|||||||
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。"""
|
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。"""
|
||||||
resp = await client.post(url, json=payload)
|
resp = await client.post(url, json=payload)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise _status_to_error(source, resp.status_code, resp.text, resp.headers)
|
raise _status_to_error(
|
||||||
|
source, resp.status_code, resp.text, resp.headers, operation="chat"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
@@ -545,6 +657,7 @@ class OpenAICompatTransport:
|
|||||||
)
|
)
|
||||||
self._reject_empty_completion(content, source)
|
self._reject_empty_completion(content, source)
|
||||||
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
||||||
|
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content=content,
|
content=content,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
@@ -556,7 +669,12 @@ class OpenAICompatTransport:
|
|||||||
raw={"usage": body.get("usage")},
|
raw={"usage": body.get("usage")},
|
||||||
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
||||||
model_reported=_coerce_model_reported(body.get("model")),
|
model_reported=_coerce_model_reported(body.get("model")),
|
||||||
reasoning_tokens=_coerce_reasoning_tokens(body.get("usage")),
|
reasoning_tokens=reasoning_tokens,
|
||||||
|
# 本路径的裁定多半落 UNKNOWN(M3 实测: 推理已计费却正文与 details 双
|
||||||
|
# 缺)。如实标记"观测不到",好过让下游误读成"没推理"
|
||||||
|
thinking_observation=observe_thinking(
|
||||||
|
thinking=thinking, reasoning_tokens=reasoning_tokens
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
+357
-3
@@ -8,15 +8,20 @@ import dataclasses
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
import uuid
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
||||||
|
|
||||||
|
_EFFORT_FALLBACK_DOMAIN = frozenset({"error", "nearest"})
|
||||||
|
"""`SourceConfig.effort_fallback` 的值域: 请求档打空时报错,还是映射到最近的档。"""
|
||||||
|
|
||||||
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
|
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
|
||||||
{
|
{
|
||||||
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
|
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
|
||||||
@@ -166,6 +171,195 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
|
|||||||
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
|
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Effort(StrEnum):
|
||||||
|
"""推理强度档位的封闭词汇(设计 §3.1)。
|
||||||
|
|
||||||
|
取值直接写进请求体(`reasoning_effort` 等键),**改名即改变发出去的字节**,
|
||||||
|
且会进缓存 key 与遥测落库,历史数据会断层。
|
||||||
|
|
||||||
|
八档而非六档: `none`(不推理)与 `auto`(推理,档位由模型自定)必须同时存在。
|
||||||
|
`auto` 不可省——newapi 上 26 个可调用模型里有 9 个是**纯开关型**(qwen 五个、
|
||||||
|
MiniMax-M3、glm-5/5.1/4.6v),它们能开推理却没有强度档可填;没有 `auto` 就只
|
||||||
|
能拿某个强度档冒充"开",而那正是本次要修的病根(旧 `thinking_on` 硬编码
|
||||||
|
`medium`,可 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在)。
|
||||||
|
|
||||||
|
词汇取四家参考实现共同收敛的一套(cherry-studio 的 canonical selection、
|
||||||
|
OpenRouter 的 `supported_efforts`、LiteLLM 的 `reasoning_effort_levels`、
|
||||||
|
new-api 的 `relayconvert/reasoning`),不自创。
|
||||||
|
|
||||||
|
**枚举定义在最内层而非决策层**: 它是 `SourceConfig`/`ChatRequest`/
|
||||||
|
`LLMResponse` 的字段类型,放进 `thinking.py` 会让 `types.py` 反向 import
|
||||||
|
决策模块(P7 依赖铁律),与 `ThinkingObservation` 同一理由。
|
||||||
|
"""
|
||||||
|
|
||||||
|
NONE = "none"
|
||||||
|
AUTO = "auto"
|
||||||
|
MINIMAL = "minimal"
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
XHIGH = "xhigh"
|
||||||
|
MAX = "max"
|
||||||
|
|
||||||
|
|
||||||
|
EFFORT_ORDER: tuple[Effort, ...] = (
|
||||||
|
Effort.NONE,
|
||||||
|
Effort.MINIMAL,
|
||||||
|
Effort.LOW,
|
||||||
|
Effort.MEDIUM,
|
||||||
|
Effort.HIGH,
|
||||||
|
Effort.XHIGH,
|
||||||
|
Effort.MAX,
|
||||||
|
)
|
||||||
|
"""由弱到强的强度序;`AUTO` **不在其中**——它是"由模型自定",在强弱轴上没有位置。
|
||||||
|
|
||||||
|
供能力表求"最省的开启档"与 `nearest` 映射取最近档。公开(非 `_` 前缀)是因为
|
||||||
|
`thinking.py` 要跨模块消费它,跨模块引用私有名是坏味道。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_effort(raw: Any, *, origin: str) -> Effort:
|
||||||
|
"""把外部传入的档位**归一**成 `Effort`;非法值报 `ValueError` 并列全八档。
|
||||||
|
|
||||||
|
存在的理由是"归一化点必须在入口":库内一律用 `is Effort.NONE` 做身份比较
|
||||||
|
(枚举成员唯一,`is` 比 `==` 更能表达"就是这一档"),而 `Effort` 是 `StrEnum`
|
||||||
|
——下游从 JSON/配置/命令行读出来的天然是裸字符串,`"none" is Effort.NONE`
|
||||||
|
恒为假。不在入口归一,身份比较就会在**错误路径上**误判(把一致的配置判成
|
||||||
|
矛盾),随后拼错误文案时再 `.value` 抛 `AttributeError`,连承诺的 `ValueError`
|
||||||
|
都拿不到(2026-09-05 独立验证实测)。
|
||||||
|
|
||||||
|
故裸字符串**接受并归一**而非拒收: 拒收会把 `.env` 之外的两条装配路(工厂 /
|
||||||
|
构造函数全量注入,CLAUDE.md §4.5)口径劈成两半,而 `.env` 那条早已是"解析即
|
||||||
|
归一"。`strip().lower()` 与 `config._to_effort` 同口径,理由同样是配置里的
|
||||||
|
行尾空格与大写写法是常态,而档位取值本身没有大小写语义。
|
||||||
|
|
||||||
|
`origin` 指回具体的配置项或调用点: 档位在源级、请求级两处都能配,只说
|
||||||
|
"非法档位"要人自己去找是哪一处填错了。传空串表示调用方自己会补上下文
|
||||||
|
(`config._cast` 的 `配置 X 解析失败` 已经说了是哪个 env 键)。
|
||||||
|
"""
|
||||||
|
if isinstance(raw, Effort):
|
||||||
|
return raw
|
||||||
|
prefix = f"{origin}: " if origin else ""
|
||||||
|
listed = ", ".join(e.value for e in Effort)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
return Effort(raw.strip().lower())
|
||||||
|
except ValueError:
|
||||||
|
# 不 `from exc`: 枚举原生的 "'lowest' is not a valid Effort" 只是同一
|
||||||
|
# 件事的英文复述,链上去反而把可操作的那句挤到后面
|
||||||
|
raise ValueError(f"{prefix}非法推理档位 {raw!r};允许: {listed}") from None
|
||||||
|
raise ValueError(
|
||||||
|
f"{prefix}推理档位必须是 Effort 或其字面量字符串,"
|
||||||
|
f"收到 {type(raw).__name__}: {raw!r};允许: {listed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingObservation(StrEnum):
|
||||||
|
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
|
||||||
|
|
||||||
|
三态**不可折叠为布尔**: `UNKNOWN` 是"本次无任何信号,判不出来",与
|
||||||
|
`ABSENT`("上游明确上报了未推理")语义不同。把前者折叠进后者,正是
|
||||||
|
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",而实际
|
||||||
|
可能推理了且已计费(MiniMax-M3 非流式实测: completion 53 vs 关闭档 3,
|
||||||
|
推理正文与 usage 明细双双不回传)。
|
||||||
|
|
||||||
|
裁定由 `thinking.observe_thinking` 做,本类只是取值域。**枚举定义在最内层
|
||||||
|
而非决策层**: 它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让
|
||||||
|
`types.py` 反向 import 决策模块(P7 依赖铁律)。
|
||||||
|
|
||||||
|
取值进遥测落库,改名即造成历史数据断层。
|
||||||
|
"""
|
||||||
|
|
||||||
|
OBSERVED = "observed"
|
||||||
|
ABSENT = "absent"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
|
||||||
|
"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。
|
||||||
|
|
||||||
|
与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**,
|
||||||
|
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。"""
|
||||||
|
|
||||||
|
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
|
||||||
|
|
||||||
|
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
|
||||||
|
"""一行遥测描述的事件形态;旧行 NULL,不回填。
|
||||||
|
|
||||||
|
终态行与 attempt 行**不是重复事实**(前者描述逻辑终态,后者描述单次尝试),
|
||||||
|
故禁止按 `error IS NOT NULL` 跨两类直接计失败调用次数(设计 §6/§8)。"""
|
||||||
|
|
||||||
|
EVENT_KINDS: tuple[EventKind, ...] = ("attempt", "cache_hit", "terminal_failure")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CallStats:
|
||||||
|
"""一次**公开调用**(而非单次尝试)的统计快照(设计 §3)。
|
||||||
|
|
||||||
|
四种响应各平铺三字段会立刻漂移,故收敛成单一对象并由包根导出。
|
||||||
|
第三方合成响应的 `None` 表示**未知**,不得伪造 0。
|
||||||
|
"""
|
||||||
|
|
||||||
|
logical_call_id: str
|
||||||
|
"""每次公开调用一个 UUID;重试、结构化重问、embedding 分批共享同一个。
|
||||||
|
|
||||||
|
不占用既有 `parent_call_id`(后者是调用方的业务关联,语义不变)。"""
|
||||||
|
|
||||||
|
attempts: int
|
||||||
|
"""准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝。
|
||||||
|
|
||||||
|
**不是 HTTP 请求条数**: OCR layout 的 POST + ZIP GET 在同一次 transport
|
||||||
|
调用内,计 1 次。缓存命中与空输入是合法的零尝试。"""
|
||||||
|
|
||||||
|
total_latency_ms: int
|
||||||
|
"""从输入校验通过到返回/异常传播前的单调时钟快照。
|
||||||
|
|
||||||
|
含缓存 IO、退避等待、准入等待、重问、分批与内联记账。
|
||||||
|
"总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class _CallContext:
|
||||||
|
"""私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。
|
||||||
|
|
||||||
|
**每调用一个实例**的单任务对象: chat 重试、结构化重问、embedding 分批
|
||||||
|
都在同一任务内串行推进,故计数无需锁。**严禁提升为 client 实例属性**
|
||||||
|
——那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"、
|
||||||
|
VT `evolve_llm = llm` 教训的同一形态)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_attempts", "_now", "_started", "_terminal_claimed", "logical_call_id")
|
||||||
|
|
||||||
|
def __init__(self, *, now: Callable[[], float]) -> None:
|
||||||
|
self.logical_call_id = str(uuid.uuid4())
|
||||||
|
self._now = now
|
||||||
|
self._started = now()
|
||||||
|
self._attempts = 0
|
||||||
|
self._terminal_claimed = False
|
||||||
|
|
||||||
|
def register_attempt(self) -> None:
|
||||||
|
"""transport 调用**前**登记一次尝试(含免预算 429 与端口本地拒绝)。
|
||||||
|
|
||||||
|
登记点在调用前而非成功后: 否则失败与取消的尝试会从计数里消失,
|
||||||
|
而那正是诊断时最需要看见的那几次。
|
||||||
|
"""
|
||||||
|
self._attempts += 1
|
||||||
|
|
||||||
|
def snapshot(self) -> CallStats:
|
||||||
|
"""同步冻结当前快照;**绝不 await**,可多次调用。"""
|
||||||
|
return CallStats(
|
||||||
|
logical_call_id=self.logical_call_id,
|
||||||
|
attempts=self._attempts,
|
||||||
|
total_latency_ms=int((self._now() - self._started) * 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
def claim_terminal(self) -> bool:
|
||||||
|
"""首次 `True`、其后 `False`: 保证每逻辑调用至多写一条终态行。"""
|
||||||
|
if self._terminal_claimed:
|
||||||
|
return False
|
||||||
|
self._terminal_claimed = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LLMResponse:
|
class LLMResponse:
|
||||||
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
||||||
@@ -202,7 +396,34 @@ class LLMResponse:
|
|||||||
usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把
|
usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把
|
||||||
`completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈
|
`completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈
|
||||||
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`,
|
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`,
|
||||||
故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。"""
|
故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。
|
||||||
|
|
||||||
|
**该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报
|
||||||
|
`completion_tokens_details`(MiniMax 这一路实测已停),此时 `None` 只意味着
|
||||||
|
「没上报」而非「没推理」——同一次调用里库拿得到 185 字符推理正文。判「有没有
|
||||||
|
推理」一律改读 `thinking_observation`,上面那段只用于解读本版之前的历史数据。"""
|
||||||
|
|
||||||
|
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||||
|
"""本次调用"推理是否真的发生"的三态裁定(issue #16/#17)。
|
||||||
|
|
||||||
|
`UNKNOWN` = **本次无任何信号,判不出来**,**不是**"没推理"——把两者折叠
|
||||||
|
是 `reasoning_tokens=None` 制造的老歧义。典型来源: 非流式路径下部分模型
|
||||||
|
推理已计费却既不回传正文也不回传 `completion_tokens_details`(MiniMax-M3
|
||||||
|
实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。
|
||||||
|
要判"确实没推理"只认 `ABSENT`(上游明确上报 0)。"""
|
||||||
|
|
||||||
|
applied_effort: Effort | None = None
|
||||||
|
"""本次调用**真正发出去**的推理档位(issue #20);`None` = 调用方未表态。
|
||||||
|
|
||||||
|
与 `ChatRequest.reasoning_effort`(请求档)可能分叉: 源上配了
|
||||||
|
`EFFORT_FALLBACK=nearest` 时,请求 `medium` 而模型只有 low/high/max,实际发
|
||||||
|
出的是 `low`。遥测按本字段分组,记请求档会把整行挂在一个从未发出过的档下。
|
||||||
|
|
||||||
|
`None` 不是"没推理": 库不表态时也不推定模型自己的默认档——"没看见"不许说成
|
||||||
|
"发生了"(同 `thinking_observation` 的 `UNKNOWN` 一脉)。"""
|
||||||
|
|
||||||
|
call_stats: CallStats | None = None
|
||||||
|
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -239,6 +460,27 @@ class ChatRequest:
|
|||||||
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
|
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
|
||||||
语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。"""
|
语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。"""
|
||||||
|
|
||||||
|
# —— 请求级推理档位(issue #20;追加在末尾,不扰动既有字段的位置构造)——
|
||||||
|
reasoning_effort: Effort | None = None
|
||||||
|
"""本次调用要求的推理档位,压过源级默认(设计 §4.2 的最高优先级层)。
|
||||||
|
|
||||||
|
`None` 是**不表态**(随源级配置),与 `Effort.NONE`("要求不推理")严格区分:
|
||||||
|
把前者读成后者会让一次没写档位的调用悄悄关掉源上配好的推理。
|
||||||
|
|
||||||
|
独立成字段而非塞进 `overlay`: `overlay` 是采样参数的直通层,库不解释其内容,
|
||||||
|
而档位要经能力表校验、要进缓存 key、要落遥测——混进直通层等于放弃这三样,
|
||||||
|
正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。"""
|
||||||
|
|
||||||
|
# —— 库内部逻辑调用上下文(1.3.5;追加在末尾,不扰动既有字段的位置构造)——
|
||||||
|
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
|
||||||
|
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 `logical_call_id` 落 NULL。
|
||||||
|
|
||||||
|
`compare=False, repr=False` 不是洁癖: 进 `compare` 会让两个内容相同的请求因
|
||||||
|
"不是同一次调用"而不相等,进 `repr` 则把库内部件泄进调用方的日志。
|
||||||
|
|
||||||
|
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
|
||||||
|
重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Usage:
|
class Usage:
|
||||||
@@ -258,6 +500,31 @@ class SourceStats:
|
|||||||
tpm_used: int
|
tpm_used: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TelemetryStatus:
|
||||||
|
"""遥测后端的可写状态快照;degraded 期间下游可据此对账(issue #15)。
|
||||||
|
|
||||||
|
不叫 `health`: 库内 `health` 一律指**源的健康度**(`OcrTransport.check_health`
|
||||||
|
探活、`SourceSelector.health` 成功率 EWMA),而这里描述的是"这个 recorder
|
||||||
|
现在能不能写、为什么不能、丢了多少",是状态不是评分(设计 §3.3)。
|
||||||
|
|
||||||
|
时长一律给**相对秒数**而非绝对时间戳: 库内的时钟是 monotonic,把它的读数
|
||||||
|
交给下游会与 wall clock 混淆成两个不可比的时间轴。
|
||||||
|
"""
|
||||||
|
|
||||||
|
degraded: bool
|
||||||
|
fatal: bool
|
||||||
|
"""True = 本进程内不可恢复(仅 DSN 不可解析一类配置级失败),需改配置并重启。"""
|
||||||
|
reason: str | None
|
||||||
|
"""降级原因;未降级为 None。"""
|
||||||
|
degraded_for_s: float | None
|
||||||
|
"""已降级时长;未降级为 None。"""
|
||||||
|
dropped_rows: int
|
||||||
|
"""累计丢弃行数;**进程生命周期内单调不减**——恢复不等于没丢过。"""
|
||||||
|
retry_after_s: float | None
|
||||||
|
"""距下次重新准备的秒数;fatal 或未降级为 None,冷却已到期为 0.0。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TransportResult:
|
class TransportResult:
|
||||||
"""transport 单次原始调用的产物;治理字段由 RetryMW 补齐为 LLMResponse。"""
|
"""transport 单次原始调用的产物;治理字段由 RetryMW 补齐为 LLMResponse。"""
|
||||||
@@ -274,6 +541,18 @@ class TransportResult:
|
|||||||
cached_prompt_tokens: int | None = None
|
cached_prompt_tokens: int | None = None
|
||||||
model_reported: str | None = None
|
model_reported: str | None = None
|
||||||
reasoning_tokens: int | None = None
|
reasoning_tokens: int | None = None
|
||||||
|
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||||
|
"""本次调用"推理是否真的发生"的裁定(issue #16/#17),由 transport 组装时填。
|
||||||
|
|
||||||
|
默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默
|
||||||
|
时,不该替上游做出"没推理"这个它从未做过的声明。"""
|
||||||
|
|
||||||
|
applied_effort: Effort | None = None
|
||||||
|
"""本次调用真正发出去的推理档位(issue #20),由做注入的 transport 填。
|
||||||
|
|
||||||
|
只有做了注入的那一层知道它: `nearest` 映射后请求档与实际档分叉(请求
|
||||||
|
`medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是
|
||||||
|
"未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -282,6 +561,10 @@ class SourceConfig:
|
|||||||
|
|
||||||
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、
|
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、
|
||||||
True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。
|
True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。
|
||||||
|
|
||||||
|
2026-09-04 起 `enable_thinking` 降级为 `reasoning_effort` 的语法糖
|
||||||
|
(`True`→`AUTO`、`False`→`NONE`),保留不删是因为它已被三项目消费
|
||||||
|
(迁移兼容约束,ARCH §5.1)。两个字段说的是同一件事,故矛盾即报错。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
@@ -306,10 +589,29 @@ class SourceConfig:
|
|||||||
(加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法;
|
(加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法;
|
||||||
要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`。"""
|
要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`。"""
|
||||||
|
|
||||||
|
reasoning_effort: Effort | None = None
|
||||||
|
"""本源默认的推理档位;None = 不表态(与 `Effort.NONE`「要求不推理」不同)。
|
||||||
|
|
||||||
|
裸字符串(`"low"`、`" LOW "`)也收,构造期由 `coerce_effort` 归一成 `Effort`,
|
||||||
|
非法值当场 `ValueError` 并列出八档;**构造完成后本字段一定是 `Effort`**,库内
|
||||||
|
的 `is Effort.NONE` 身份比较依赖这条不变式。
|
||||||
|
|
||||||
|
**追加在末尾**是硬要求:三项目的测试按位置构造 fake,插在中间会静默错位
|
||||||
|
(本模块头部 docstring 的字段保序约定)。"""
|
||||||
|
|
||||||
|
effort_fallback: str = "error"
|
||||||
|
"""请求档打空时的处置: `error`(默认,报错)或 `nearest`(映射到最近的档)。
|
||||||
|
|
||||||
|
默认报错的理由是钱: 一次静默的 `medium → max` 在 GLM-5.3 上是数倍账单
|
||||||
|
(P5「严禁默认值掩盖错误」)。值域在此把关而非交给 `resolve_thinking`——
|
||||||
|
后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果
|
||||||
|
就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。"""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self._validate_identity()
|
self._validate_identity()
|
||||||
self._validate_gates()
|
self._validate_gates()
|
||||||
self._validate_watchdog()
|
self._validate_watchdog()
|
||||||
|
self._validate_thinking()
|
||||||
self._freeze_extra_body()
|
self._freeze_extra_body()
|
||||||
|
|
||||||
def effective_est_tokens(self) -> int:
|
def effective_est_tokens(self) -> int:
|
||||||
@@ -347,6 +649,52 @@ class SourceConfig:
|
|||||||
):
|
):
|
||||||
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
|
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
|
||||||
|
|
||||||
|
def _validate_thinking(self) -> None:
|
||||||
|
"""推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。
|
||||||
|
|
||||||
|
归一化必须先于下面的矛盾判定: 判据用的是 `is Effort.NONE`,而本类是公共
|
||||||
|
入口,`reasoning_effort="none"` 这种裸字符串写法(从 JSON/配置读出来的
|
||||||
|
常态)会让它误判成矛盾,再拼文案时 `.value` 直接 `AttributeError`。同一
|
||||||
|
理由也适用于下游读侧——归一化后库内一律是 `Effort`,`is` 比较才安全。
|
||||||
|
|
||||||
|
矛盾**报错而非「后者赢」**: `enable_thinking` 与 `reasoning_effort` 表达的是
|
||||||
|
同一件事,静默取其一等于替下游猜它到底想要哪个,而猜错的代价是账单——
|
||||||
|
猜成开启就是白花钱,猜成关闭就是拿到一个没推理过的答案。
|
||||||
|
|
||||||
|
判据是「二者是否都在说关闭」: `enable_thinking is False` 与
|
||||||
|
`reasoning_effort is NONE` 必须同真同假。`True` + 某个开启档(如 `low`)
|
||||||
|
不算矛盾,那只是把同一件事说了两遍,且后者更精确。
|
||||||
|
"""
|
||||||
|
if self.reasoning_effort is not None:
|
||||||
|
# frozen dataclass 改字段走 object.__setattr__(同款先例: _freeze_extra_body)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"reasoning_effort",
|
||||||
|
coerce_effort(
|
||||||
|
self.reasoning_effort, origin=f"SourceConfig({self.name}).reasoning_effort"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if isinstance(self.effort_fallback, str):
|
||||||
|
# 与相邻的 `REASONING_EFFORT` 同口径: `.env` 里的行尾空格与大写写法是
|
||||||
|
# 常态,而 `nearest`/`error` 本身没有大小写语义。归一化放在值域校验的
|
||||||
|
# 同一处(而不是 env 解析处),三条配置路一并覆盖
|
||||||
|
object.__setattr__(self, "effort_fallback", self.effort_fallback.strip().lower())
|
||||||
|
if self.effort_fallback not in _EFFORT_FALLBACK_DOMAIN:
|
||||||
|
raise ValueError(
|
||||||
|
f"SourceConfig.effort_fallback(EFFORT_FALLBACK)非法值 "
|
||||||
|
f"{self.effort_fallback!r};允许: {sorted(_EFFORT_FALLBACK_DOMAIN)}"
|
||||||
|
)
|
||||||
|
if self.enable_thinking is None or self.reasoning_effort is None:
|
||||||
|
return
|
||||||
|
if (self.enable_thinking is False) != (self.reasoning_effort is Effort.NONE):
|
||||||
|
raise ValueError(
|
||||||
|
f"源 {self.name!r} 的 enable_thinking={self.enable_thinking} 与 "
|
||||||
|
f"reasoning_effort={self.reasoning_effort.value!r} 相互矛盾: "
|
||||||
|
f"enable_thinking 已是 reasoning_effort 的语法糖"
|
||||||
|
f"(True={Effort.AUTO.value}、False={Effort.NONE.value})。"
|
||||||
|
f"请只保留其中一个,或让两者语义一致"
|
||||||
|
)
|
||||||
|
|
||||||
def _freeze_extra_body(self) -> None:
|
def _freeze_extra_body(self) -> None:
|
||||||
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
|
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
|
||||||
validated = validate_request_overlay(
|
validated = validate_request_overlay(
|
||||||
@@ -473,6 +821,8 @@ class OcrTextResult:
|
|||||||
latency_ms: int
|
latency_ms: int
|
||||||
call_id: str
|
call_id: str
|
||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
|
call_stats: CallStats | None = None
|
||||||
|
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -490,6 +840,8 @@ class OcrLayoutResult:
|
|||||||
latency_ms: int
|
latency_ms: int
|
||||||
call_id: str
|
call_id: str
|
||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
|
call_stats: CallStats | None = None
|
||||||
|
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -534,3 +886,5 @@ class EmbeddingResponse:
|
|||||||
call_id: str
|
call_id: str
|
||||||
source_name: str
|
source_name: str
|
||||||
cost: float | None = None
|
cost: float | None = None
|
||||||
|
call_stats: CallStats | None = None
|
||||||
|
"""本次逻辑调用(含全部分批)的统计快照(1.3.5);`None` = 未知。"""
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
"""测试侧独立 HTTP 取证装配;无环境自读取或成功 SSE 预读。"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator, Iterator, Mapping
|
||||||
|
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway import GatewayClient, GatewaySettings
|
||||||
|
from polygateway.client import (
|
||||||
|
_aclose_component,
|
||||||
|
_build_breaker,
|
||||||
|
_build_limiter,
|
||||||
|
_build_selector,
|
||||||
|
_build_structured,
|
||||||
|
)
|
||||||
|
from polygateway.providers import get_provider
|
||||||
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
|
from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult
|
||||||
|
from tests.live_evidence import (
|
||||||
|
AttemptEvidence,
|
||||||
|
HttpEvidence,
|
||||||
|
LiveVerdict,
|
||||||
|
assess_model_identity,
|
||||||
|
classify_live_failure,
|
||||||
|
messages_digest,
|
||||||
|
request_is_valid,
|
||||||
|
safe_attempts,
|
||||||
|
strict_json,
|
||||||
|
write_live_round,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Exchange:
|
||||||
|
"""仅在 attempt 生命周期持有原始响应引用。"""
|
||||||
|
|
||||||
|
request: httpx.Request
|
||||||
|
checks: tuple[tuple[str, bool], ...]
|
||||||
|
response: httpx.Response | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Attempt:
|
||||||
|
"""任务内可变收集器,结束时转换为冻结快照。"""
|
||||||
|
|
||||||
|
call_id: str
|
||||||
|
exchanges: list[_Exchange] = field(default_factory=list)
|
||||||
|
messages_digest: str | None = None
|
||||||
|
messages_valid: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LiveCapture:
|
||||||
|
"""矩阵显式预期与按逻辑轮次关联的独立证据。"""
|
||||||
|
|
||||||
|
def __init__(self, *, expectations: Mapping[str, Mapping[str, Any]]) -> None:
|
||||||
|
"""预期缺项即配置错误,不从实发 payload 补齐。"""
|
||||||
|
for expected in expectations.values():
|
||||||
|
required = {"model", "origin", "path", "control", "messages_digest"}
|
||||||
|
if not required <= expected.keys() or ("stream" in expected) == (
|
||||||
|
"input_shape" in expected
|
||||||
|
):
|
||||||
|
raise ValueError("取证矩阵缺少必需预期或混用 chat/embed")
|
||||||
|
if not isinstance(expected["control"], dict):
|
||||||
|
raise ValueError("control 必须是显式对象")
|
||||||
|
if "structured_max_retries" in expected and (
|
||||||
|
"stream" not in expected
|
||||||
|
or type(expected["structured_max_retries"]) is not int
|
||||||
|
or expected["structured_max_retries"] < 0
|
||||||
|
or type(expected.get("messages_prefix_length")) is not int
|
||||||
|
or expected["messages_prefix_length"] < 1
|
||||||
|
):
|
||||||
|
raise ValueError("结构化预期缺少合法前缀长度或重问预算")
|
||||||
|
self._expectations = {name: dict(value) for name, value in expectations.items()}
|
||||||
|
self._round: ContextVar[tuple[str, str]] = ContextVar("live_round")
|
||||||
|
self._attempt: ContextVar[_Attempt] = ContextVar("live_attempt")
|
||||||
|
self._records: dict[tuple[str, str], list[AttemptEvidence]] = {}
|
||||||
|
self._owners: dict[str, tuple[str, str]] = {}
|
||||||
|
self._notes: dict[tuple[str, str], list[str]] = {}
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def round_context(self, *, session_id: str, parent_call_id: str) -> Iterator[None]:
|
||||||
|
"""外围逻辑轮次绑定,异常和取消均复位。"""
|
||||||
|
key = session_id, parent_call_id
|
||||||
|
token = self._round.set(key)
|
||||||
|
self._records.setdefault(key, [])
|
||||||
|
self._notes.setdefault(key, [])
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._round.reset(token)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def attempt_context(self, call_id: str) -> Iterator[None]:
|
||||||
|
"""零 HTTP 尝试也有快照;重复/跨轮 UUID 是契约错误。"""
|
||||||
|
key = self._round.get()
|
||||||
|
if call_id in self._owners:
|
||||||
|
raise ValueError("重复或跨轮 call_id")
|
||||||
|
self._owners[call_id] = key
|
||||||
|
attempt = _Attempt(call_id)
|
||||||
|
token = self._attempt.set(attempt)
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception as exc:
|
||||||
|
error = exc
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
events = tuple(
|
||||||
|
self._snapshot(exchange, call_id, key) for exchange in attempt.exchanges
|
||||||
|
)
|
||||||
|
self._records[key].append(AttemptEvidence(call_id, events, error))
|
||||||
|
finally:
|
||||||
|
self._attempt.reset(token)
|
||||||
|
|
||||||
|
def _snapshot(self, exchange: _Exchange, call_id: str, key: tuple[str, str]) -> HttpEvidence:
|
||||||
|
"""complete 结束后只读已缓冲内容,拒绝截断和歧义身份。"""
|
||||||
|
response = exchange.response
|
||||||
|
identity: tuple[bool, str | None] = (False, None)
|
||||||
|
body = None
|
||||||
|
status = response.status_code if response is not None else 0
|
||||||
|
if response is None:
|
||||||
|
self._notes[key].append("无可配对响应")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
content = response.content
|
||||||
|
except httpx.ResponseNotRead:
|
||||||
|
self._notes[key].append("响应未缓冲,证据不足")
|
||||||
|
else:
|
||||||
|
if status >= 400:
|
||||||
|
if len(content) <= 65536:
|
||||||
|
body = content
|
||||||
|
else:
|
||||||
|
self._notes[key].append("错误体超过 64 KiB,不接受截断证据")
|
||||||
|
elif 200 <= status < 300 and dict(exchange.checks).get("stream") is not None:
|
||||||
|
try:
|
||||||
|
payload = strict_json(exchange.request.content)
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
payload = {}
|
||||||
|
if isinstance(payload, dict) and payload.get("stream") is False:
|
||||||
|
try:
|
||||||
|
data = strict_json(content)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("原始 JSON 非对象")
|
||||||
|
model = data.get("model")
|
||||||
|
if model is not None and not isinstance(model, str):
|
||||||
|
raise ValueError("原始 model 类型非法")
|
||||||
|
identity = (True, model)
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
self._notes[key].append("原始 JSON 身份无法独立解析")
|
||||||
|
return HttpEvidence(call_id, exchange.checks, status, body, identity)
|
||||||
|
|
||||||
|
def observe_messages(self, source: SourceConfig, messages: list[dict[str, Any]]) -> None:
|
||||||
|
"""先验前缀/反馈契约与委托摘要分开;摘要仅验证 HTTP 序列化保真。"""
|
||||||
|
expected = self._expectations[source.name]
|
||||||
|
if "structured_max_retries" not in expected:
|
||||||
|
return
|
||||||
|
attempt = self._attempt.get()
|
||||||
|
prefix_length = expected["messages_prefix_length"]
|
||||||
|
feedback = messages[prefix_length:]
|
||||||
|
attempt.messages_digest = messages_digest(messages)
|
||||||
|
attempt.messages_valid = (
|
||||||
|
(not feedback or bool(self._records[self._round.get()]))
|
||||||
|
and messages_digest(messages[:prefix_length]) == expected["messages_digest"]
|
||||||
|
and len(feedback) % 2 == 0
|
||||||
|
and len(feedback) <= 2 * expected["structured_max_retries"]
|
||||||
|
and all(
|
||||||
|
isinstance(message, dict)
|
||||||
|
and set(message) == {"role", "content"}
|
||||||
|
and message["role"] == ("assistant" if index % 2 == 0 else "user")
|
||||||
|
and isinstance(message["content"], str)
|
||||||
|
for index, message in enumerate(feedback)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def client_factory(self, source: SourceConfig) -> httpx.AsyncClient:
|
||||||
|
"""鉴权仅内存比较;沿已校验源 timeout/trust_env。"""
|
||||||
|
expected = self._expectations[source.name]
|
||||||
|
|
||||||
|
async def request_hook(request: httpx.Request) -> None:
|
||||||
|
"""校验实发请求而不修正它。"""
|
||||||
|
attempt = self._attempt.get()
|
||||||
|
try:
|
||||||
|
payload = strict_json(request.content)
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
payload = {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
url = request.url
|
||||||
|
origin = str(
|
||||||
|
url.copy_with(path="", query=None, fragment=None, username=None, password=None)
|
||||||
|
).rstrip("/")
|
||||||
|
# control 是本轮完整附加字段;基础键以外均比较,漏/多键都失败。
|
||||||
|
basic = {"model", "messages", "stream", "stream_options", "input"}
|
||||||
|
control = {k: v for k, v in payload.items() if k not in basic}
|
||||||
|
checks = {
|
||||||
|
"method": request.method == "POST",
|
||||||
|
"origin": origin == expected["origin"]
|
||||||
|
and not url.username
|
||||||
|
and not url.password
|
||||||
|
and not url.query,
|
||||||
|
"path": url.path == expected["path"],
|
||||||
|
"model": payload.get("model") == expected["model"],
|
||||||
|
"authorization": request.headers.get("Authorization") == f"Bearer {source.api_key}",
|
||||||
|
"control": control == expected["control"],
|
||||||
|
"messages_digest": (
|
||||||
|
attempt.messages_valid
|
||||||
|
and messages_digest(payload.get("messages")) == attempt.messages_digest
|
||||||
|
if "structured_max_retries" in expected
|
||||||
|
else messages_digest(payload.get("messages", payload.get("input")))
|
||||||
|
== expected["messages_digest"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if "stream" in expected:
|
||||||
|
checks["stream"] = payload.get("stream") is expected["stream"] and (
|
||||||
|
payload.get("stream_options") == {"include_usage": True}
|
||||||
|
if expected["stream"]
|
||||||
|
else "stream_options" not in payload
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
texts = payload.get("input")
|
||||||
|
checks["input_shape"] = (
|
||||||
|
isinstance(texts, list)
|
||||||
|
and all(isinstance(text, str) for text in texts)
|
||||||
|
and len(texts) == expected["input_shape"]
|
||||||
|
)
|
||||||
|
attempt.exchanges.append(_Exchange(request, tuple(checks.items())))
|
||||||
|
|
||||||
|
async def response_hook(response: httpx.Response) -> None:
|
||||||
|
"""只持有引用,绝不提前读取成功 SSE。"""
|
||||||
|
attempt = self._attempt.get()
|
||||||
|
matching = [event for event in attempt.exchanges if event.request is response.request]
|
||||||
|
if len(matching) != 1 or matching[0].response is not None:
|
||||||
|
raise ValueError("响应无法唯一配对")
|
||||||
|
matching[0].response = response
|
||||||
|
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
headers={"Authorization": f"Bearer {source.api_key}"},
|
||||||
|
timeout=source.timeout_s,
|
||||||
|
trust_env=source.trust_env,
|
||||||
|
event_hooks={"request": [request_hook], "response": [response_hook]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def attempts(self, *, session_id: str, parent_call_id: str) -> tuple[AttemptEvidence, ...]:
|
||||||
|
"""按逻辑轮次返回不可变快照。"""
|
||||||
|
return tuple(self._records.get((session_id, parent_call_id), ()))
|
||||||
|
|
||||||
|
def notes(self, *, session_id: str, parent_call_id: str) -> tuple[str, ...]:
|
||||||
|
"""只含固定安全原因,不包含响应正文。"""
|
||||||
|
return tuple(self._notes.get((session_id, parent_call_id), ()))
|
||||||
|
|
||||||
|
def raw_identity(
|
||||||
|
self, *, session_id: str, parent_call_id: str, call_id: str
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""精确取最终成功 attempt,不猜本轮最后一条响应。"""
|
||||||
|
key = session_id, parent_call_id
|
||||||
|
if call_id in self._owners and self._owners[call_id] != key:
|
||||||
|
raise ValueError("跨轮 call_id 身份查询")
|
||||||
|
matches = [attempt for attempt in self._records.get(key, []) if attempt.call_id == call_id]
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise ValueError("重复 call_id 身份查询")
|
||||||
|
if not matches:
|
||||||
|
return False, None
|
||||||
|
events = [event for event in matches[0].http if 200 <= event.status_code < 300]
|
||||||
|
if len(events) > 1:
|
||||||
|
raise ValueError("多个成功 HTTP 身份候选")
|
||||||
|
return events[0].raw_identity if events else (False, None)
|
||||||
|
|
||||||
|
|
||||||
|
class ObservedTransport:
|
||||||
|
"""原样委托同一个真实 transport,无重试、payload 修正或异常翻译。"""
|
||||||
|
|
||||||
|
def __init__(self, transport: OpenAICompatTransport, capture: LiveCapture) -> None:
|
||||||
|
"""资源所有权留给装配者。"""
|
||||||
|
self._transport = transport
|
||||||
|
self._capture = capture
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
source: SourceConfig,
|
||||||
|
stream: bool,
|
||||||
|
overlay: dict[str, Any],
|
||||||
|
call_id: str,
|
||||||
|
reasoning_effort: Effort | None,
|
||||||
|
) -> TransportResult:
|
||||||
|
"""与生产端口逐参数同签名。"""
|
||||||
|
with self._capture.attempt_context(call_id):
|
||||||
|
self._capture.observe_messages(source, messages)
|
||||||
|
return await self._transport.complete(
|
||||||
|
messages=messages,
|
||||||
|
source=source,
|
||||||
|
stream=stream,
|
||||||
|
overlay=overlay,
|
||||||
|
call_id=call_id,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def embed(
|
||||||
|
self, *, texts: list[str], source: SourceConfig, call_id: str
|
||||||
|
) -> EmbeddingTransportResult:
|
||||||
|
"""embedding 使用同一取证关联,不套 chat 推理判据。"""
|
||||||
|
with self._capture.attempt_context(call_id):
|
||||||
|
return await self._transport.embed(texts=texts, source=source, call_id=call_id)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def observed_client(
|
||||||
|
settings: GatewaySettings, capture: LiveCapture, *, capabilities=None
|
||||||
|
) -> AsyncIterator[GatewayClient]:
|
||||||
|
"""全量注入复用生产装配函数;自建组件显式关闭,不启用响应缓存。"""
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
sources = list(settings.sources)
|
||||||
|
limiter = _build_limiter(settings, sources)
|
||||||
|
stack.push_async_callback(_aclose_component, limiter)
|
||||||
|
breaker = _build_breaker(settings)
|
||||||
|
stack.push_async_callback(_aclose_component, breaker)
|
||||||
|
real = OpenAICompatTransport(
|
||||||
|
client_factory=capture.client_factory, capabilities=capabilities
|
||||||
|
)
|
||||||
|
stack.push_async_callback(real.aclose)
|
||||||
|
strategy, escalation = _build_structured(
|
||||||
|
[get_provider(source.provider) for source in sources]
|
||||||
|
)
|
||||||
|
client = GatewayClient(
|
||||||
|
scope=settings.scope,
|
||||||
|
sources=sources,
|
||||||
|
selector=_build_selector(settings.selector),
|
||||||
|
limiter=limiter,
|
||||||
|
breaker=breaker,
|
||||||
|
transport=ObservedTransport(real, capture),
|
||||||
|
retry=settings.retry,
|
||||||
|
backpressure=settings.backpressure,
|
||||||
|
quota_full=settings.quota_full,
|
||||||
|
circuit_open=settings.circuit_open,
|
||||||
|
structured_strategy=strategy,
|
||||||
|
structured_escalation=escalation,
|
||||||
|
structured_max_retries=settings.structured_max_retries,
|
||||||
|
)
|
||||||
|
stack.push_async_callback(client.aclose)
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def chat_expectations(
|
||||||
|
settings: GatewaySettings,
|
||||||
|
*,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
stream: bool,
|
||||||
|
controls: Mapping[str, dict[str, Any]],
|
||||||
|
structured_max_retries: int | None = None,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""URL 从源配置声明,控制片段必须由矩阵独立给出。"""
|
||||||
|
result = {}
|
||||||
|
for source in settings.sources:
|
||||||
|
url = httpx.URL(source.base_url)
|
||||||
|
result[source.name] = {
|
||||||
|
"model": source.model,
|
||||||
|
"origin": str(
|
||||||
|
url.copy_with(path="", query=None, fragment=None, username=None, password=None)
|
||||||
|
).rstrip("/"),
|
||||||
|
"path": url.path.rstrip("/") + "/chat/completions",
|
||||||
|
"stream": stream,
|
||||||
|
"control": controls[source.name],
|
||||||
|
"messages_digest": messages_digest(messages),
|
||||||
|
}
|
||||||
|
if structured_max_retries is not None:
|
||||||
|
result[source.name].update(
|
||||||
|
messages_prefix_length=len(messages), structured_max_retries=structured_max_retries
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_verdict(verdict: LiveVerdict) -> None:
|
||||||
|
"""仅在报告已写入后调用;默认失败,不打印上游异常正文。"""
|
||||||
|
if verdict.status == "UNCOVERED":
|
||||||
|
pytest.skip(verdict.reason)
|
||||||
|
assert verdict.status == "PASS", verdict.reason
|
||||||
|
|
||||||
|
|
||||||
|
async def captured_chat_round(
|
||||||
|
client: GatewayClient,
|
||||||
|
capture: LiveCapture,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
matrix_id: str,
|
||||||
|
round_index: int,
|
||||||
|
output_dir: Path,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
models: Mapping[str, str],
|
||||||
|
aliases: Mapping[str, frozenset[str]],
|
||||||
|
validate=None,
|
||||||
|
providers: Mapping[str, str] | None = None,
|
||||||
|
source_efforts: Mapping[str, Effort | None] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> tuple[Any, LiveVerdict]:
|
||||||
|
"""请求、身份与行为断言均先记逐轮证据;异常不漏轮。"""
|
||||||
|
parent = uuid4().hex
|
||||||
|
response = None
|
||||||
|
error = None
|
||||||
|
verdict = LiveVerdict("FAIL", "轮次未完成")
|
||||||
|
with capture.round_context(session_id=run_id, parent_call_id=parent):
|
||||||
|
try:
|
||||||
|
response = await client.chat(
|
||||||
|
messages, session_id=run_id, parent_call_id=parent, **kwargs
|
||||||
|
)
|
||||||
|
attempts = capture.attempts(session_id=run_id, parent_call_id=parent)
|
||||||
|
events = [event for attempt in attempts for event in attempt.http]
|
||||||
|
successful = [attempt for attempt in attempts if attempt.call_id == response.call_id]
|
||||||
|
success_paired = (
|
||||||
|
len(successful) == 1
|
||||||
|
and successful[0].error is None
|
||||||
|
and len(successful[0].http) == 1
|
||||||
|
and 200 <= successful[0].http[0].status_code < 300
|
||||||
|
)
|
||||||
|
verdict = assess_model_identity(
|
||||||
|
requested=models[response.source_name],
|
||||||
|
aliases=aliases.get(models[response.source_name], frozenset()),
|
||||||
|
reported=response.model_reported,
|
||||||
|
raw_identity=capture.raw_identity(
|
||||||
|
session_id=run_id, parent_call_id=parent, call_id=response.call_id
|
||||||
|
),
|
||||||
|
request_valid=success_paired
|
||||||
|
and bool(events)
|
||||||
|
and all(request_is_valid(event) for event in events),
|
||||||
|
)
|
||||||
|
if verdict.status == "PASS" and validate is not None:
|
||||||
|
validate(response)
|
||||||
|
except Exception as exc:
|
||||||
|
error = exc
|
||||||
|
verdict = classify_live_failure(
|
||||||
|
exc, capture.attempts(session_id=run_id, parent_call_id=parent)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
attempts = capture.attempts(session_id=run_id, parent_call_id=parent)
|
||||||
|
# 上游 model 可能回显提示词;仅输出允许集合内的名字,其他统一省略。
|
||||||
|
allowed = set(models.values()) | {
|
||||||
|
alias for values in aliases.values() for alias in values
|
||||||
|
}
|
||||||
|
write_live_round(
|
||||||
|
output_dir,
|
||||||
|
run_id=run_id,
|
||||||
|
matrix_id=matrix_id,
|
||||||
|
round_index=round_index,
|
||||||
|
safe_fields={
|
||||||
|
"session_id": run_id,
|
||||||
|
"parent_call_id": parent,
|
||||||
|
"requested_model": list(models.values()),
|
||||||
|
"provider": list(providers.values()) if providers is not None else None,
|
||||||
|
"attempts": safe_attempts(attempts),
|
||||||
|
"status": verdict.status,
|
||||||
|
"reason": verdict.reason,
|
||||||
|
"stream": kwargs.get("stream", True),
|
||||||
|
"requested_effort": kwargs.get("reasoning_effort")
|
||||||
|
or (list(source_efforts.values()) if source_efforts is not None else None),
|
||||||
|
"completed_rounds": 1,
|
||||||
|
"prompt_tokens": response.prompt_tokens if response else None,
|
||||||
|
"completion_tokens": response.completion_tokens if response else None,
|
||||||
|
"reasoning_tokens": response.reasoning_tokens if response else None,
|
||||||
|
"thinking_chars": len(response.thinking) if response else None,
|
||||||
|
"evidence_notes": (
|
||||||
|
"原始异常/正文摘要省略以避免回显泄露",
|
||||||
|
*capture.notes(session_id=run_id, parent_call_id=parent),
|
||||||
|
),
|
||||||
|
"reported_model": response.model_reported
|
||||||
|
if response and response.model_reported in allowed
|
||||||
|
else None,
|
||||||
|
"applied_effort": response.applied_effort if response else None,
|
||||||
|
"thinking_observation": response.thinking_observation if response else None,
|
||||||
|
"error_type": type(error).__name__ if error else None,
|
||||||
|
"error_status": getattr(error, "status_code", None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response, verdict
|
||||||
|
|
||||||
|
|
||||||
|
def declared_control(provider: str, effort: Effort | None) -> dict[str, Any]:
|
||||||
|
"""测试矩阵的独立 wire 声明;不调用 resolver 或生产 payload 构造器。"""
|
||||||
|
if effort is None:
|
||||||
|
return {}
|
||||||
|
if provider == "qwen":
|
||||||
|
if effort not in (Effort.AUTO, Effort.NONE):
|
||||||
|
raise ValueError("测试矩阵未声明 qwen 强度映射")
|
||||||
|
return {"enable_thinking": effort is not Effort.NONE}
|
||||||
|
if provider in {"deepseek", "zhipu", "moonshot"}:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"thinking": {"type": "disabled" if effort is Effort.NONE else "enabled"}
|
||||||
|
}
|
||||||
|
if effort not in (Effort.AUTO, Effort.NONE):
|
||||||
|
result["reasoning_effort"] = effort.value
|
||||||
|
return result
|
||||||
|
if provider not in {"minimax", "openai", "anthropic", "google"}:
|
||||||
|
raise ValueError("测试矩阵没有该 provider 的控制声明")
|
||||||
|
return {} if effort is Effort.AUTO else {"reasoning_effort": effort.value}
|
||||||
|
|
||||||
|
|
||||||
|
def source_controls(settings: GatewaySettings) -> dict[str, dict[str, Any]]:
|
||||||
|
"""源级矩阵预期独立表达;受管 raw 冲突由生产路径拒绝。"""
|
||||||
|
result = {}
|
||||||
|
for source in settings.sources:
|
||||||
|
effort = source.reasoning_effort
|
||||||
|
if effort is None and source.enable_thinking is not None:
|
||||||
|
effort = Effort.AUTO if source.enable_thinking else Effort.NONE
|
||||||
|
control = declared_control(source.provider, effort)
|
||||||
|
if effort is None:
|
||||||
|
control.update(source.extra_body)
|
||||||
|
else:
|
||||||
|
# 不用 update 覆盖控制声明,否则会掩盖所有权回归。
|
||||||
|
for key, value in source.extra_body.items():
|
||||||
|
if key in control:
|
||||||
|
raise ValueError("取证矩阵有双来源控制")
|
||||||
|
control[key] = value
|
||||||
|
result[source.name] = control
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# pytest 用例终态补充网络前缺配置、装配失败及未完成轮次;不代替逐轮报告。
|
||||||
|
@pytest.hookimpl(wrapper=True)
|
||||||
|
def pytest_runtest_makereport(item, call):
|
||||||
|
"""仅记录安全矩阵标识和阶段结果,不序列化 pytest 异常长文本。"""
|
||||||
|
report = yield
|
||||||
|
if report.skipped or report.failed:
|
||||||
|
write_live_round(
|
||||||
|
Path("tests/outputs/134/live"),
|
||||||
|
run_id=uuid4().hex,
|
||||||
|
matrix_id="pytest-" + messages_digest(item.nodeid)[:16],
|
||||||
|
round_index=0,
|
||||||
|
safe_fields={
|
||||||
|
"status": "UNCOVERED" if report.skipped else "FAIL",
|
||||||
|
"reason": "用例阶段未覆盖或失败;详情按逐轮安全证据核验,不能视为能力通过",
|
||||||
|
"evidence_notes": [report.when],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return report
|
||||||
@@ -1,88 +1,119 @@
|
|||||||
"""GovDoc 与 Video-Tree 最小接入冒烟(2026-07-20 拍板: 两个项目都做)。
|
"""历史接入调用形态的真实冒烟;不能替代缺失下游的现行配置验收。"""
|
||||||
|
|
||||||
复刻两项目的真实调用点形态,对真实网关跑一次治理调用,证明"调用点零改动
|
|
||||||
迁移"成立;并验证 VT 现有平铺键名(LLM_TIMEOUT 等)可直接装配。
|
|
||||||
reference/ 只读——本文件只 import 其 Protocol,绝不修改。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
from polygateway import GatewayClient
|
from polygateway import Effort, GatewayClient, GatewaySettings
|
||||||
|
from tests.e2e.conftest import (
|
||||||
|
LiveCapture,
|
||||||
|
captured_chat_round,
|
||||||
|
chat_expectations,
|
||||||
|
enforce_verdict,
|
||||||
|
observed_client,
|
||||||
|
source_controls,
|
||||||
|
)
|
||||||
|
from tests.live_evidence import write_live_round
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[2]
|
_REPO = Path(__file__).resolve().parents[2]
|
||||||
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||||
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
|
||||||
|
pytestmark = [
|
||||||
pytestmark = pytest.mark.skipif(
|
pytest.mark.slow,
|
||||||
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*"
|
pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
|
||||||
)
|
]
|
||||||
|
_OUT = Path("tests/outputs/134/live")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
async def _call_shape(matrix, **kwargs):
|
||||||
async def client():
|
"""session/parent 总由逐轮 UUID 传入;保留 cache_salt 调用形态。"""
|
||||||
c = GatewayClient.from_env("LLM", env=_ENV)
|
settings = GatewaySettings.from_env("LLM", env=_ENV)
|
||||||
yield c
|
messages = [{"role": "user", "content": "Reply with exactly: compatibility-ok"}]
|
||||||
await c.aclose()
|
capture = LiveCapture(
|
||||||
|
expectations=chat_expectations(
|
||||||
|
settings, messages=messages, stream=True, controls=source_controls(settings)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(response):
|
||||||
|
assert response.content.strip() and response.call_id
|
||||||
|
|
||||||
|
async with observed_client(settings, capture) as client:
|
||||||
|
_, verdict = await captured_chat_round(
|
||||||
|
client,
|
||||||
|
capture,
|
||||||
|
run_id=uuid4().hex,
|
||||||
|
matrix_id=matrix,
|
||||||
|
round_index=1,
|
||||||
|
output_dir=_OUT,
|
||||||
|
messages=messages,
|
||||||
|
models={s.name: s.model for s in settings.sources},
|
||||||
|
providers={s.name: s.provider for s in settings.sources},
|
||||||
|
source_efforts={
|
||||||
|
s.name: s.reasoning_effort
|
||||||
|
if s.reasoning_effort is not None
|
||||||
|
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
|
||||||
|
if s.enable_thinking is not None
|
||||||
|
else None
|
||||||
|
for s in settings.sources
|
||||||
|
},
|
||||||
|
aliases={},
|
||||||
|
validate=validate,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
enforce_verdict(verdict)
|
||||||
|
|
||||||
|
|
||||||
class TestGovDocOnboarding:
|
class TestGovDocOnboarding:
|
||||||
"""GovDoc agent/loop.py:377 调用形态: session_id + parent_call_id。"""
|
"""历史 session_id+parent_call_id 调用点契约。"""
|
||||||
|
|
||||||
async def test_call_site_shape_runs_governed(self, client):
|
async def test_call_site_shape_runs_governed(self):
|
||||||
response = await client.chat(
|
await _call_shape("compat-parent")
|
||||||
[{"role": "user", "content": "Reply with exactly: govdoc-ok"}],
|
|
||||||
session_id="govdoc-e2e",
|
|
||||||
parent_call_id="step-1",
|
|
||||||
)
|
|
||||||
assert response.content.strip()
|
|
||||||
assert response.call_id # GovernedLLMClient 契约字段全在
|
|
||||||
|
|
||||||
async def test_structural_protocol_match(self, client):
|
async def test_structural_protocol_match(self):
|
||||||
|
"""外部 Protocol 缺包单列未覆盖;合成契约另在 unit 跑。"""
|
||||||
|
run_id = uuid4().hex
|
||||||
sys.path.insert(0, str(_REPO / "reference/GovDoc-SaaS/packages/docagent-core/src"))
|
sys.path.insert(0, str(_REPO / "reference/GovDoc-SaaS/packages/docagent-core/src"))
|
||||||
try:
|
try:
|
||||||
from docagent_core.protocols import LLMProvider
|
from docagent_core.protocols import LLMProvider
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("GovDoc protocols 依赖不可导入(结构断言已由单测兜底覆盖)")
|
write_live_round(
|
||||||
|
_OUT,
|
||||||
|
run_id=run_id,
|
||||||
|
matrix_id="external-protocol",
|
||||||
|
round_index=0,
|
||||||
|
safe_fields={
|
||||||
|
"status": "UNCOVERED",
|
||||||
|
"reason": "外部 Protocol 包缺失;未验证真实下游",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pytest.skip("外部 Protocol 包缺失,未覆盖")
|
||||||
finally:
|
finally:
|
||||||
sys.path.pop(0)
|
sys.path.pop(0)
|
||||||
assert isinstance(client, LLMProvider)
|
status = "FAIL"
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client = GatewayClient.from_env("LLM", env=_ENV)
|
||||||
|
assert isinstance(client, LLMProvider)
|
||||||
|
status = "PASS"
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
await client.aclose()
|
||||||
|
write_live_round(
|
||||||
|
_OUT,
|
||||||
|
run_id=run_id,
|
||||||
|
matrix_id="external-protocol",
|
||||||
|
round_index=0,
|
||||||
|
safe_fields={"status": status, "reason": "外部 Protocol 结构契约,不是模型能力"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestVideoTreeOnboarding:
|
class TestVideoTreeOnboarding:
|
||||||
"""VT loop.py:336 调用形态: session_id + cache_salt(跨 epoch 重采样)。"""
|
"""历史 cache_salt 调用点契约,平铺键装配已移至 unit。"""
|
||||||
|
|
||||||
async def test_call_site_shape_with_cache_salt(self, client):
|
async def test_call_site_shape_with_cache_salt(self):
|
||||||
response = await client.chat(
|
await _call_shape("compat-salt", cache_salt="epoch-1")
|
||||||
[{"role": "user", "content": "Reply with exactly: vt-ok"}],
|
|
||||||
session_id="vt-e2e",
|
|
||||||
cache_salt="epoch-1",
|
|
||||||
)
|
|
||||||
assert response.content.strip()
|
|
||||||
|
|
||||||
async def test_flat_legacy_keys_assemble(self):
|
|
||||||
"""VT 现有键名(LLM_TIMEOUT/LLM_MAX_RETRIES 等)零改名装配成功。"""
|
|
||||||
source_keys = {k: v for k, v in _ENV.items() if k.split("__")[0] == "LLM" and "__" in k}
|
|
||||||
flat_env = {
|
|
||||||
**source_keys,
|
|
||||||
"LLM_TIMEOUT": "120",
|
|
||||||
"LLM_MAX_RETRIES": "3",
|
|
||||||
"LLM_RETRY_BASE_DELAY": "2.0",
|
|
||||||
"LLM_RETRY_MAX_DELAY": "30.0",
|
|
||||||
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
|
|
||||||
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
|
|
||||||
"LLM_TTFT_TIMEOUT": "30",
|
|
||||||
"LLM_INTER_TOKEN_TIMEOUT": "15",
|
|
||||||
"PGW_CACHE_BACKEND": "none",
|
|
||||||
"PGW_TELEMETRY_BACKEND": "none",
|
|
||||||
}
|
|
||||||
client = GatewayClient.from_env("LLM", env=flat_env)
|
|
||||||
try:
|
|
||||||
resp = await client.chat([{"role": "user", "content": "Reply: flat-ok"}])
|
|
||||||
assert resp.content.strip()
|
|
||||||
finally:
|
|
||||||
await client.aclose()
|
|
||||||
|
|||||||
@@ -1,83 +1,98 @@
|
|||||||
"""真实网关 /embeddings 端点探测(M2 设计 §11.6;人类默认口径: 实现时探测)。
|
"""真实 embedding 探测;404 仅证明请求型号不可用,不外推端点能力。"""
|
||||||
|
|
||||||
对 .env 的 LLM 源网关发一次真实 embeddings 请求: 支持则记录向量证据,
|
|
||||||
不支持(404/翻译为领域错误)则 skip 并把响应记录进 tests/outputs/
|
|
||||||
(降级证据)。无 EMBED scope 配置时复用 LLM 源的 base_url/api_key。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
from polygateway.errors import PolyGatewayError
|
from polygateway import GatewaySettings
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
from polygateway.types import SourceConfig
|
from tests.e2e.conftest import LiveCapture, ObservedTransport, enforce_verdict
|
||||||
|
from tests.live_evidence import (
|
||||||
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
LiveVerdict,
|
||||||
|
classify_live_failure,
|
||||||
pytestmark = pytest.mark.skipif(
|
messages_digest,
|
||||||
"LLM__MINIMAX__1__BASE_URL" not in _ENV, reason="缺真实网关配置(.env)"
|
request_is_valid,
|
||||||
|
safe_attempts,
|
||||||
|
write_live_round,
|
||||||
)
|
)
|
||||||
|
|
||||||
_OUT = Path("tests/outputs/embedding")
|
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.slow,
|
||||||
def _record(name: str, lines: list[str]) -> Path:
|
pytest.mark.skipif("LLM__MINIMAX__1__BASE_URL" not in _ENV, reason="缺少矩阵必需配置,未覆盖"),
|
||||||
_OUT.mkdir(parents=True, exist_ok=True)
|
]
|
||||||
path = _OUT / f"{name}_{datetime.now():%Y%m%d_%H%M%S}.md"
|
|
||||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
async def test_probe_real_gateway_embeddings():
|
async def test_probe_real_gateway_embeddings():
|
||||||
source = SourceConfig(
|
"""沿已校验源的 timeout/trust_env,所有路径 finally 关闭。"""
|
||||||
name="probe_1",
|
settings = GatewaySettings.from_env("LLM", env=_ENV)
|
||||||
provider="minimax",
|
configured = next(s for s in settings.sources if s.name == "minimax_1")
|
||||||
base_url=_ENV["LLM__MINIMAX__1__BASE_URL"],
|
source = dataclasses.replace(
|
||||||
api_key=_ENV["LLM__MINIMAX__1__API_KEY"],
|
configured, model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1")
|
||||||
model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1"),
|
|
||||||
timeout_s=30.0,
|
|
||||||
est_tokens=8,
|
|
||||||
)
|
)
|
||||||
transport = OpenAICompatTransport()
|
texts = ["polygateway embedding probe"]
|
||||||
|
url = httpx.URL(source.base_url)
|
||||||
|
capture = LiveCapture(
|
||||||
|
expectations={
|
||||||
|
source.name: {
|
||||||
|
"model": source.model,
|
||||||
|
"origin": str(url.copy_with(path="", query=None)).rstrip("/"),
|
||||||
|
"path": url.path.rstrip("/") + "/embeddings",
|
||||||
|
"input_shape": 1,
|
||||||
|
"control": {},
|
||||||
|
"messages_digest": messages_digest(texts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
real = OpenAICompatTransport(client_factory=capture.client_factory)
|
||||||
|
transport = ObservedTransport(real, capture)
|
||||||
|
run_id, parent, call_id = uuid4().hex, uuid4().hex, uuid4().hex
|
||||||
|
verdict = LiveVerdict("FAIL", "轮次未完成")
|
||||||
|
completed_rounds = 0
|
||||||
try:
|
try:
|
||||||
result = await transport.embed(
|
with capture.round_context(session_id=run_id, parent_call_id=parent):
|
||||||
texts=["polygateway embedding probe"], source=source, call_id="probe"
|
try:
|
||||||
)
|
result = await transport.embed(texts=texts, source=source, call_id=call_id)
|
||||||
except PolyGatewayError as exc:
|
events = [
|
||||||
path = _record(
|
e
|
||||||
"probe_unsupported",
|
for a in capture.attempts(session_id=run_id, parent_call_id=parent)
|
||||||
[
|
for e in a.http
|
||||||
"# Embedding 端点探测: 网关不支持",
|
]
|
||||||
f"- base_url: {source.base_url}",
|
assert len(events) == 1 and request_is_valid(events[0])
|
||||||
f"- model: {source.model}",
|
assert result.dim > 0 and len(result.vectors) == 1
|
||||||
f"- 错误分类: {type(exc).__name__}",
|
verdict = LiveVerdict("PASS", "向量形状与实发请求合格")
|
||||||
f"- status_code: {exc.status_code}",
|
completed_rounds = 1
|
||||||
f"- 详情: {exc}",
|
except Exception as error:
|
||||||
"",
|
verdict = classify_live_failure(
|
||||||
"结论: e2e 按设计 §11.6 降级,embedding 行为由 unit 全覆盖。",
|
error, capture.attempts(session_id=run_id, parent_call_id=parent)
|
||||||
],
|
)
|
||||||
)
|
completed_rounds = 1
|
||||||
await transport.aclose()
|
finally:
|
||||||
pytest.skip(f"网关不支持 embeddings({type(exc).__name__}),证据: {path}")
|
write_live_round(
|
||||||
else:
|
Path("tests/outputs/134/live"),
|
||||||
await transport.aclose()
|
run_id=run_id,
|
||||||
assert result.dim > 0 and len(result.vectors) == 1
|
matrix_id="embedding",
|
||||||
_record(
|
round_index=1,
|
||||||
"probe_supported",
|
safe_fields={
|
||||||
[
|
"requested_model": source.model,
|
||||||
"# Embedding 端点探测: 网关支持",
|
"provider": source.provider,
|
||||||
f"- base_url: {source.base_url}",
|
"planned_rounds": 1,
|
||||||
f"- model: {source.model}",
|
"completed_rounds": completed_rounds,
|
||||||
f"- dim: {result.dim}",
|
"status": verdict.status,
|
||||||
f"- usage: {result.prompt_tokens}({result.usage_source})",
|
"reason": verdict.reason,
|
||||||
f"- 向量前 5 维: {result.vectors[0][:5]}",
|
"session_id": run_id,
|
||||||
f"- raw: {dataclasses.asdict(result)['raw']}",
|
"parent_call_id": parent,
|
||||||
],
|
"attempts": safe_attempts(
|
||||||
)
|
capture.attempts(session_id=run_id, parent_call_id=parent)
|
||||||
|
),
|
||||||
|
"evidence_notes": capture.notes(session_id=run_id, parent_call_id=parent),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await real.aclose()
|
||||||
|
enforce_verdict(verdict)
|
||||||
|
|||||||
@@ -1,117 +1,116 @@
|
|||||||
"""真实网关端到端冒烟(M1 验收第 7 步)。
|
"""真实网关冒烟:逐轮独立取证,行为断言失败也必须留档。"""
|
||||||
|
|
||||||
前置: `.env` 配置至少一个 `LLM__{PROVIDER}__1__*` 真实源 + 韧性键。
|
|
||||||
缺配置时 skip(验收前必须真跑)。输出结构化 Markdown 落
|
|
||||||
`tests/outputs/e2e/`(CLAUDE.md §4.6,不提交 git)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from polygateway import GatewayClient
|
from polygateway import Effort, GatewaySettings
|
||||||
|
from tests.e2e.conftest import (
|
||||||
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
LiveCapture,
|
||||||
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
captured_chat_round,
|
||||||
|
chat_expectations,
|
||||||
pytestmark = pytest.mark.skipif(
|
enforce_verdict,
|
||||||
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)"
|
observed_client,
|
||||||
|
source_controls,
|
||||||
)
|
)
|
||||||
|
|
||||||
_OUT_DIR = Path("tests/outputs/e2e")
|
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||||
|
_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.slow,
|
||||||
|
pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class MiniAnswer(BaseModel):
|
class MiniAnswer(BaseModel):
|
||||||
|
"""最小结构化响应契约。"""
|
||||||
|
|
||||||
answer: int
|
answer: int
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
def _report(name: str, sections: list[tuple[str, str]]) -> Path:
|
async def _smoke(matrix, prompt, validate, *, stream=True, structured=None):
|
||||||
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
"""全量注入仅替换取证装配,仍调用生产结构化策略。"""
|
||||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
settings = GatewaySettings.from_env("LLM", env=_ENV)
|
||||||
path = _OUT_DIR / f"{name}_{ts}.md"
|
messages = [{"role": "user", "content": prompt}]
|
||||||
body = [f"# e2e 冒烟: {name}", ""]
|
controls = source_controls(settings)
|
||||||
for title, content in sections:
|
capture = LiveCapture(
|
||||||
body += [f"## {title}", "", "```", content, "```", ""]
|
expectations=chat_expectations(
|
||||||
path.write_text("\n".join(body), encoding="utf-8")
|
settings,
|
||||||
return path
|
messages=messages,
|
||||||
|
stream=stream,
|
||||||
|
controls=controls,
|
||||||
@pytest.fixture
|
structured_max_retries=(
|
||||||
async def client():
|
settings.structured_max_retries if isinstance(structured, type) else None
|
||||||
c = GatewayClient.from_env("LLM", env=_ENV)
|
),
|
||||||
yield c
|
)
|
||||||
await c.aclose()
|
)
|
||||||
|
async with observed_client(settings, capture) as client:
|
||||||
|
_, verdict = await captured_chat_round(
|
||||||
|
client,
|
||||||
|
capture,
|
||||||
|
run_id=uuid4().hex,
|
||||||
|
matrix_id=matrix,
|
||||||
|
round_index=1,
|
||||||
|
output_dir=Path("tests/outputs/134/live"),
|
||||||
|
messages=messages,
|
||||||
|
models={s.name: s.model for s in settings.sources},
|
||||||
|
providers={s.name: s.provider for s in settings.sources},
|
||||||
|
source_efforts={
|
||||||
|
s.name: s.reasoning_effort
|
||||||
|
if s.reasoning_effort is not None
|
||||||
|
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
|
||||||
|
if s.enable_thinking is not None
|
||||||
|
else None
|
||||||
|
for s in settings.sources
|
||||||
|
},
|
||||||
|
aliases={},
|
||||||
|
validate=validate,
|
||||||
|
stream=stream,
|
||||||
|
structured=structured,
|
||||||
|
)
|
||||||
|
enforce_verdict(verdict)
|
||||||
|
|
||||||
|
|
||||||
class TestRealGatewaySmoke:
|
class TestRealGatewaySmoke:
|
||||||
async def test_stream_chat(self, client):
|
"""保留流/非流和结构化真实行为断言。"""
|
||||||
resp = await client.chat(
|
|
||||||
[{"role": "user", "content": "Reply with exactly: pong"}], session_id="e2e-smoke"
|
|
||||||
)
|
|
||||||
path = _report(
|
|
||||||
"stream_chat",
|
|
||||||
[
|
|
||||||
("响应", resp.content),
|
|
||||||
(
|
|
||||||
"元数据",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"model": resp.model,
|
|
||||||
"source": resp.source_name,
|
|
||||||
"usage_source": resp.usage_source,
|
|
||||||
"prompt_tokens": resp.prompt_tokens,
|
|
||||||
"completion_tokens": resp.completion_tokens,
|
|
||||||
"latency_ms": resp.latency_ms,
|
|
||||||
"ttft_ms": resp.ttft_ms,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
indent=2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert resp.content.strip()
|
|
||||||
assert resp.ttft_ms is not None and resp.latency_ms > 0
|
|
||||||
print(f"输出: {path}")
|
|
||||||
|
|
||||||
async def test_non_stream_fast_path(self, client):
|
async def test_stream_chat(self):
|
||||||
resp = await client.chat(
|
def validate(response):
|
||||||
[{"role": "user", "content": "Reply with exactly: pong"}], stream=False
|
assert response.content.strip()
|
||||||
)
|
assert response.ttft_ms is not None and response.latency_ms > 0
|
||||||
_report("non_stream", [("响应", resp.content)])
|
|
||||||
assert resp.content.strip() and resp.ttft_ms is None
|
|
||||||
|
|
||||||
async def test_structured_json_tier(self, client):
|
await _smoke("smoke-stream", "Reply with exactly: pong", validate)
|
||||||
resp = await client.chat(
|
|
||||||
[{"role": "user", "content": 'Reply ONLY with JSON: {"ok": true}'}],
|
async def test_non_stream_fast_path(self):
|
||||||
|
def validate(response):
|
||||||
|
assert response.content.strip() and response.ttft_ms is None
|
||||||
|
|
||||||
|
await _smoke("smoke-json", "Reply with exactly: pong", validate, stream=False)
|
||||||
|
|
||||||
|
async def test_structured_json_tier(self):
|
||||||
|
def validate(response):
|
||||||
|
assert isinstance(response.structured_data, dict | list)
|
||||||
|
|
||||||
|
await _smoke(
|
||||||
|
"smoke-structured-json",
|
||||||
|
'Reply ONLY with JSON: {"ok": true}',
|
||||||
|
validate,
|
||||||
structured="json",
|
structured="json",
|
||||||
)
|
)
|
||||||
_report("structured_json", [("解析产物", repr(resp.structured_data))])
|
|
||||||
assert isinstance(resp.structured_data, dict | list)
|
|
||||||
|
|
||||||
async def test_structured_model_ladder(self, client):
|
async def test_structured_model_ladder(self):
|
||||||
resp = await client.chat(
|
def validate(response):
|
||||||
[
|
assert isinstance(response.structured_data, MiniAnswer)
|
||||||
{
|
assert response.structured_data.answer == 5
|
||||||
"role": "user",
|
|
||||||
"content": "What is 2+3? Reply ONLY with JSON matching "
|
await _smoke(
|
||||||
'{"answer": <int>, "reason": <short string>}',
|
"smoke-structured-model",
|
||||||
}
|
'What is 2+3? Reply ONLY with JSON matching {"answer": <int>, "reason": <short string>}',
|
||||||
],
|
validate,
|
||||||
structured=MiniAnswer,
|
structured=MiniAnswer,
|
||||||
)
|
)
|
||||||
_report(
|
|
||||||
"structured_model",
|
|
||||||
[
|
|
||||||
("原始响应", resp.content),
|
|
||||||
("校验产物", resp.structured_data.model_dump_json()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert isinstance(resp.structured_data, MiniAnswer)
|
|
||||||
assert resp.structured_data.answer == 5
|
|
||||||
|
|||||||
+540
-348
@@ -1,444 +1,636 @@
|
|||||||
"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。
|
"""真实推理矩阵:独立请求/身份资格,逐轮留证,UNKNOWN 不证明关闭。
|
||||||
|
|
||||||
本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径,
|
不新增成功 SSE 捕获器,不把整类异常跳过;历史长度/prompt 锚点仅作
|
||||||
验证不了"这个参数在这个模型上到底关没关掉推理"。
|
指定样本的形态回归,不提升能力覆盖。T10 不可关闭与预期拒绝是独立命题。
|
||||||
|
|
||||||
两条判据纪律(来自 findings §4c 的实测教训):
|
|
||||||
|
|
||||||
1. **判别量只能是 `reasoning_tokens`,不能是 `completion_tokens`。** 两档的输出
|
|
||||||
长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文),
|
|
||||||
开启档最低 13 token(medium 档想得少的那几轮),按长度阈值判两边都会误判。
|
|
||||||
而 `reasoning_tokens` 在同一批 30 轮里干净分开——关闭 15/15 为 None,
|
|
||||||
开启 15/15 大于 0。
|
|
||||||
2. **另配一个不含魔数的确定性锚点**(见 L2b): 同一模型上,关闭档的
|
|
||||||
`prompt_tokens` 严格小于开启档——供应商在开启时注入了推理指令,输入侧
|
|
||||||
token 数随之变大。这是相对比较,不硬编码任何具体数值。
|
|
||||||
3. **关闭方向要求每轮满足,开启方向只要求多数轮满足。** 中转在上游不返回
|
|
||||||
usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c),
|
|
||||||
开启方向因此可能偶尔观测不到;关闭方向不受影响。
|
|
||||||
|
|
||||||
源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from collections import Counter
|
from collections.abc import Mapping
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import MappingProxyType
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
from polygateway import GatewayClient, GatewaySettings
|
from polygateway import GatewaySettings, ThinkingObservation
|
||||||
from polygateway.errors import (
|
from polygateway.thinking import DEFAULT_CAPABILITIES, ThinkingCapability
|
||||||
AllSourcesExhausted,
|
from polygateway.types import EFFORT_ORDER, Effort
|
||||||
RequestRejectedError,
|
from tests.e2e.conftest import (
|
||||||
SourceDeadError,
|
LiveCapture,
|
||||||
TransientError,
|
captured_chat_round,
|
||||||
|
chat_expectations,
|
||||||
|
declared_control,
|
||||||
|
enforce_verdict,
|
||||||
|
observed_client,
|
||||||
|
source_controls,
|
||||||
|
)
|
||||||
|
from tests.live_evidence import (
|
||||||
|
LiveVerdict,
|
||||||
|
assess_thinking_coverage,
|
||||||
|
combine_live_verdicts,
|
||||||
|
qualify_live_rounds,
|
||||||
|
summarize_verdicts,
|
||||||
|
write_live_round,
|
||||||
)
|
)
|
||||||
from polygateway.providers import DEFAULT_CAPABILITIES, get_capability
|
|
||||||
|
|
||||||
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||||
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
|
||||||
|
|
||||||
# slow: 本组 137 次真实调用、约 7 分钟,且判据是统计性的——网络抖动会让它偶发
|
|
||||||
# 失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` 会把测试
|
|
||||||
# 变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` 显式真跑并
|
|
||||||
# 存档报告。"不自动门控"不等于"可跳过"。
|
|
||||||
pytestmark = [
|
pytestmark = [
|
||||||
pytest.mark.slow,
|
pytest.mark.slow,
|
||||||
pytest.mark.skipif(
|
pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
|
||||||
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)"
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
_OUT_DIR = Path("tests/outputs/134/live")
|
||||||
_OUT_DIR = Path("tests/outputs/e2e")
|
|
||||||
_ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
|
_ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
|
||||||
|
_TIER_ROUNDS = int(os.environ.get("PGW_E2E_TIER_ROUNDS", "5"))
|
||||||
# 需要一点推理才能答对,但答案极短: 关掉推理时 completion 稳定在个位数,
|
_TIER_LONG_ROUNDS = int(os.environ.get("PGW_E2E_TIER_LONG_ROUNDS", "3"))
|
||||||
# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里
|
_TIER_CONCURRENCY = int(os.environ.get("PGW_E2E_TIER_CONCURRENCY", "3"))
|
||||||
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
|
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
|
||||||
|
_TIER_PROMPT = "23 乘以 47 等于多少?只回答一个数字,不要解释。"
|
||||||
|
_TIER_LONG_PROMPT = (
|
||||||
|
"\n".join(
|
||||||
|
f"{i:04d}. 这是一段与题目无关的填充文字,仅用于把上下文撑到数千 token,"
|
||||||
|
"以复核短提示词下得到的关闭结论在长上下文下是否依然成立。"
|
||||||
|
for i in range(120)
|
||||||
|
)
|
||||||
|
+ "\n\n"
|
||||||
|
+ _TIER_PROMPT
|
||||||
|
)
|
||||||
|
_ALL_EFFORTS = (*EFFORT_ORDER, Effort.AUTO)
|
||||||
|
|
||||||
_ON_MIN_COMPLETION = 100
|
|
||||||
"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。"""
|
|
||||||
|
|
||||||
_ROWS: list[dict] = []
|
|
||||||
|
|
||||||
# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。
|
|
||||||
# 漏登记会被 test_every_capability_has_a_provider_mapping 当场抓住,而不是
|
|
||||||
# 在 L8 里被"源不可用"这个假理由吞掉
|
|
||||||
_MODEL_PROVIDER = {
|
_MODEL_PROVIDER = {
|
||||||
"MiniMax-M3": "minimax",
|
"MiniMax-M3": "minimax",
|
||||||
"MiniMax-M2.7": "minimax",
|
"MiniMax-M2.7": "minimax",
|
||||||
"MiniMax-M2.5": "minimax",
|
"MiniMax-M2.5": "minimax",
|
||||||
"qwen3.7-plus": "qwen",
|
"qwen3.7-plus": "qwen",
|
||||||
|
"qwen3.7-max": "qwen",
|
||||||
|
"qwen3.6-plus": "qwen",
|
||||||
|
"qwen3.5-flash": "qwen",
|
||||||
|
"qwen-plus-latest": "qwen",
|
||||||
"deepseek-v4-pro": "deepseek",
|
"deepseek-v4-pro": "deepseek",
|
||||||
|
"deepseek-v4-flash": "deepseek",
|
||||||
|
"deepseek-v4-flash-vision-exp": "deepseek",
|
||||||
|
"glm-5.3": "zhipu",
|
||||||
|
"glm-5.3-flash": "zhipu",
|
||||||
|
"glm-5.2": "zhipu",
|
||||||
|
"glm-5.1": "zhipu",
|
||||||
|
"glm-5": "zhipu",
|
||||||
|
"glm-4.6v": "zhipu",
|
||||||
|
"kimi-k3": "moonshot",
|
||||||
|
"kimi-for-coding": "moonshot",
|
||||||
|
"gpt-5.4": "openai",
|
||||||
|
"gpt-5.5": "openai",
|
||||||
|
"claude-opus-5": "anthropic",
|
||||||
|
"claude-sonnet-5": "anthropic",
|
||||||
|
"claude-haiku-5": "anthropic",
|
||||||
|
"gemini-3.1-pro": "google",
|
||||||
|
"gemini-3-flash": "google",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_MODEL_REPORTED_ALIASES: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||||
def _base_settings() -> GatewaySettings:
|
{
|
||||||
# 强制关缓存: 多轮测量要求每一轮都真的打到供应商,命中缓存会把后续轮次
|
"kimi-k3": frozenset({"k3"}),
|
||||||
# 变成对第一轮的回放,整组判据随之失效
|
"kimi-for-coding": frozenset({"k3"}),
|
||||||
return GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"})
|
"gemini-3-flash": frozenset({"gemini-3-flash-preview"}),
|
||||||
|
"gemini-3.1-pro": frozenset({"gemini-3.1-pro-preview"}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _settings(**source_overrides) -> GatewaySettings:
|
def _settings(**source_overrides):
|
||||||
base = _base_settings()
|
"""强制关闭缓存,清除 inherited 受管意图后应用本矩阵配置。"""
|
||||||
source = dataclasses.replace(base.sources[0], **source_overrides)
|
base = GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"})
|
||||||
|
source = dataclasses.replace(
|
||||||
|
base.sources[0], enable_thinking=None, reasoning_effort=None, extra_body={}
|
||||||
|
)
|
||||||
|
source = dataclasses.replace(source, **source_overrides)
|
||||||
return dataclasses.replace(base, sources=(source,))
|
return dataclasses.replace(base, sources=(source,))
|
||||||
|
|
||||||
|
|
||||||
async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -> list[dict]:
|
def _tier_settings(model):
|
||||||
"""跑 N 轮真实调用,返回逐轮观测;任一轮抛错即向上冒泡由用例决定处置。"""
|
"""保留既有一次 retry 探测预算;不压缩生产 stall 或单次 timeout。"""
|
||||||
client = GatewayClient.from_settings(_settings(**source_overrides))
|
base = GatewaySettings.from_env(
|
||||||
observations = []
|
"LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none", "LLM_MAX_RETRIES": "1"}
|
||||||
try:
|
)
|
||||||
for i in range(rounds):
|
source = dataclasses.replace(
|
||||||
resp = await client.chat(
|
base.sources[0],
|
||||||
[{"role": "user", "content": _PROMPT}],
|
model=model,
|
||||||
stream=stream,
|
provider=_MODEL_PROVIDER[model],
|
||||||
# 每轮独立 salt: 即便某层缓存意外开着也不会回放
|
enable_thinking=None,
|
||||||
cache_salt=f"thinking-live-{i}",
|
reasoning_effort=None,
|
||||||
)
|
extra_body={},
|
||||||
observations.append(
|
)
|
||||||
{
|
return dataclasses.replace(base, sources=(source,))
|
||||||
"round": i + 1,
|
|
||||||
"prompt_tokens": resp.prompt_tokens,
|
|
||||||
"completion_tokens": resp.completion_tokens,
|
|
||||||
"reasoning_tokens": resp.reasoning_tokens,
|
|
||||||
"content": resp.content[:60],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await client.aclose()
|
|
||||||
return observations
|
|
||||||
|
|
||||||
|
|
||||||
def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -> None:
|
@dataclasses.dataclass
|
||||||
_ROWS.append(
|
class _CaseRun:
|
||||||
{
|
"""单型号用例关联;子运行以同一 run_id 下的 matrix_id 唯一定位原件。"""
|
||||||
"matrix": matrix_id,
|
|
||||||
"desc": desc,
|
run_id: str
|
||||||
"status": status,
|
model: str
|
||||||
"detail": detail,
|
subruns: list[dict] = dataclasses.field(default_factory=list)
|
||||||
"observations": observations or [],
|
|
||||||
|
def report_fields(self):
|
||||||
|
"""计划分母在收集前登记,完成数量只按实际回收轮次填写。"""
|
||||||
|
return {
|
||||||
|
"session_id": self.run_id,
|
||||||
|
"requested_model": self.model,
|
||||||
|
"subruns": self.subruns,
|
||||||
|
"planned_rounds": sum(group["planned_rounds"] for group in self.subruns),
|
||||||
|
"completed_rounds": sum(group["completed_rounds"] for group in self.subruns),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_rounds(
|
||||||
|
settings,
|
||||||
|
*,
|
||||||
|
run,
|
||||||
|
rounds,
|
||||||
|
stream,
|
||||||
|
prompt,
|
||||||
|
matrix_id,
|
||||||
|
effort=None,
|
||||||
|
capabilities=None,
|
||||||
|
concurrency=1,
|
||||||
|
):
|
||||||
|
"""保留所有失败轮,不把可用轮集合偷偷当新分母。"""
|
||||||
|
if rounds < 1 or concurrency < 1:
|
||||||
|
raise ValueError("轮次/并发必须为正数")
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
controls = (
|
||||||
|
source_controls(settings)
|
||||||
|
if effort is None
|
||||||
|
else {s.name: declared_control(s.provider, effort) for s in settings.sources}
|
||||||
|
)
|
||||||
|
capture = LiveCapture(
|
||||||
|
expectations=chat_expectations(
|
||||||
|
settings, messages=messages, stream=stream, controls=controls
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if any(source.model != run.model for source in settings.sources):
|
||||||
|
raise ValueError("用例型号与收集源不一致")
|
||||||
|
if any(group["matrix_id"] == matrix_id for group in run.subruns):
|
||||||
|
raise ValueError("用例子运行标识重复")
|
||||||
|
group = {"matrix_id": matrix_id, "planned_rounds": rounds, "completed_rounds": 0}
|
||||||
|
run.subruns.append(group)
|
||||||
|
run_id = run.run_id
|
||||||
|
semaphore = asyncio.Semaphore(concurrency)
|
||||||
|
async with observed_client(settings, capture, capabilities=capabilities) as client:
|
||||||
|
|
||||||
|
async def one(index):
|
||||||
|
"""每轮已落盘后才回到汇总,断言失败也有记录。"""
|
||||||
|
|
||||||
|
def validate(response):
|
||||||
|
assert response.content.strip()
|
||||||
|
if effort is not None:
|
||||||
|
assert response.applied_effort is effort
|
||||||
|
|
||||||
|
async with semaphore:
|
||||||
|
response, verdict = await captured_chat_round(
|
||||||
|
client,
|
||||||
|
capture,
|
||||||
|
run_id=run_id,
|
||||||
|
matrix_id=matrix_id,
|
||||||
|
round_index=index + 1,
|
||||||
|
output_dir=_OUT_DIR,
|
||||||
|
messages=messages,
|
||||||
|
models={s.name: s.model for s in settings.sources},
|
||||||
|
providers={s.name: s.provider for s in settings.sources},
|
||||||
|
source_efforts={
|
||||||
|
s.name: s.reasoning_effort
|
||||||
|
if s.reasoning_effort is not None
|
||||||
|
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
|
||||||
|
if s.enable_thinking is not None
|
||||||
|
else None
|
||||||
|
for s in settings.sources
|
||||||
|
},
|
||||||
|
aliases=_MODEL_REPORTED_ALIASES,
|
||||||
|
stream=stream,
|
||||||
|
reasoning_effort=effort,
|
||||||
|
cache_salt=f"{run_id}-{index}",
|
||||||
|
validate=validate,
|
||||||
|
)
|
||||||
|
return {"round": index + 1, "verdict": verdict, "response": response}
|
||||||
|
|
||||||
|
# return_exceptions 保证一个取证写失败不使其他任务越过资源关闭边界。
|
||||||
|
results = await asyncio.gather(*(one(i) for i in range(rounds)), return_exceptions=True)
|
||||||
|
values = []
|
||||||
|
for value in results:
|
||||||
|
if isinstance(value, BaseException):
|
||||||
|
raise value
|
||||||
|
values.append(value)
|
||||||
|
group["completed_rounds"] = len(values)
|
||||||
|
counts = summarize_verdicts([value["verdict"] for value in values], planned_rounds=rounds)
|
||||||
|
write_live_round(
|
||||||
|
_OUT_DIR,
|
||||||
|
run_id=run_id,
|
||||||
|
matrix_id=matrix_id + "-rounds",
|
||||||
|
round_index=0,
|
||||||
|
safe_fields={
|
||||||
|
"session_id": run_id,
|
||||||
|
"requested_model": run.model,
|
||||||
|
"counts": counts,
|
||||||
|
"completed_rounds": len(values),
|
||||||
|
"planned_rounds": rounds,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_rounds(rounds, *, run, stream=True, matrix_id="thinking", **source_overrides):
|
||||||
|
"""L1–L8 的资格证据出口,不作整类 skip。"""
|
||||||
|
return await _collect_rounds(
|
||||||
|
_settings(**source_overrides),
|
||||||
|
run=run,
|
||||||
|
rounds=rounds,
|
||||||
|
stream=stream,
|
||||||
|
prompt=_PROMPT,
|
||||||
|
matrix_id=matrix_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reasoning_off(obs: dict) -> bool:
|
def _qualified(rows, *, planned_rounds):
|
||||||
"""关闭方向: 只看 reasoning_tokens。
|
"""汇总资格先失败后未覆盖;部分失败不能被成功轮掩盖。"""
|
||||||
|
return qualify_live_rounds([row["verdict"] for row in rows], planned_rounds=planned_rounds)
|
||||||
**刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做
|
|
||||||
"只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会
|
|
||||||
把这种正常波动误判成"没关掉"。
|
|
||||||
"""
|
|
||||||
return obs["reasoning_tokens"] in (None, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _reasoning_on(obs: dict) -> bool:
|
def _coverage(rows, *, planned_rounds, proposition):
|
||||||
"""开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据。
|
"""只有全轮资格通过才进入推理观测命题。"""
|
||||||
|
verdict = _qualified(rows, planned_rounds=planned_rounds)
|
||||||
不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大
|
if verdict.status == "PASS" and proposition == "observation-only":
|
||||||
(实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理。
|
return LiveVerdict("UNCOVERED", "未登记候选只保留观测,不自动登记能力")
|
||||||
仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据。
|
if verdict.status == "PASS":
|
||||||
"""
|
verdict = assess_thinking_coverage(
|
||||||
reasoning = obs["reasoning_tokens"]
|
[row["response"].thinking_observation for row in rows],
|
||||||
if reasoning is not None:
|
planned_rounds=planned_rounds,
|
||||||
return reasoning > 0
|
proposition=proposition,
|
||||||
return obs["completion_tokens"] > _ON_MIN_COMPLETION
|
)
|
||||||
|
return verdict
|
||||||
|
|
||||||
|
|
||||||
def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str):
|
def _conclude(matrix, verdict, *, run, proposition=None):
|
||||||
"""源不可用(渠道下线/模型未开通)→ 跳过并记为未覆盖,不伪装成通过。"""
|
"""命题汇总先落盘再交给 pytest,不覆盖逐轮原件。"""
|
||||||
_record(matrix_id, desc, "SKIP(源不可用)", str(exc)[:200])
|
write_live_round(
|
||||||
pytest.skip(f"{matrix_id} 源不可用,已记为未覆盖: {str(exc)[:120]}")
|
_OUT_DIR,
|
||||||
|
run_id=run.run_id,
|
||||||
|
matrix_id=matrix,
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
round_index=0,
|
||||||
def _write_report():
|
safe_fields={
|
||||||
yield
|
"status": verdict.status,
|
||||||
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
"reason": verdict.reason,
|
||||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
"proposition": proposition,
|
||||||
path = _OUT_DIR / f"test_thinking_live_{ts}.md"
|
**run.report_fields(),
|
||||||
lines = [
|
},
|
||||||
"# 推理开关与 reasoning_tokens 真实 API 验证",
|
)
|
||||||
"",
|
enforce_verdict(verdict)
|
||||||
f"- 时间: {ts}",
|
|
||||||
f"- 每档轮数: {_ROUNDS}",
|
|
||||||
"- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限"
|
|
||||||
"(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
|
|
||||||
f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})",
|
|
||||||
"- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
|
|
||||||
"",
|
|
||||||
"## 矩阵结论",
|
|
||||||
"",
|
|
||||||
"| 矩阵 | 场景 | 结论 | 说明 |",
|
|
||||||
"|---|---|---|---|",
|
|
||||||
]
|
|
||||||
total_calls = 0
|
|
||||||
for row in _ROWS:
|
|
||||||
detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:160]
|
|
||||||
lines.append(f"| {row['matrix']} | {row['desc']} | {row['status']} | {detail} |")
|
|
||||||
total_calls += len(row["observations"])
|
|
||||||
lines += ["", f"**总真实调用次数: {total_calls}**", "", "## 逐轮原始观测", ""]
|
|
||||||
for row in _ROWS:
|
|
||||||
if not row["observations"]:
|
|
||||||
continue
|
|
||||||
lines += [f"### {row['matrix']} — {row['desc']}", "", "```json"]
|
|
||||||
lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2))
|
|
||||||
lines += ["```", ""]
|
|
||||||
uncovered = [r["matrix"] for r in _ROWS if r["status"].startswith("SKIP")]
|
|
||||||
if uncovered:
|
|
||||||
lines += ["## 未覆盖", "", f"以下矩阵行未跑到: {', '.join(uncovered)}", ""]
|
|
||||||
path.write_text("\n".join(lines), encoding="utf-8")
|
|
||||||
print(f"\n[e2e 报告] {path}")
|
|
||||||
|
|
||||||
|
|
||||||
class TestMiniMaxM3:
|
class TestMiniMaxM3:
|
||||||
"""M3 是唯一实测可关闭推理的 MiniMax 模型,修复的地基压在它身上。"""
|
"""AUTO 拒绝已移至离线契约;真实开启明确请求 medium。"""
|
||||||
|
|
||||||
async def test_l1_disable_actually_disables(self):
|
async def test_l1_disable_actually_disables(self):
|
||||||
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=False)
|
run = _CaseRun(uuid4().hex, "MiniMax-M3")
|
||||||
offs = [o for o in obs if _reasoning_off(o)]
|
rows = await _run_rounds(
|
||||||
_record(
|
_ROUNDS,
|
||||||
"L1",
|
run=run,
|
||||||
"enable_thinking=False(流式)",
|
matrix_id="L1",
|
||||||
"PASS" if len(offs) == len(obs) else "FAIL",
|
provider="minimax",
|
||||||
f"{len(offs)}/{len(obs)} 轮确认未推理",
|
model="MiniMax-M3",
|
||||||
obs,
|
enable_thinking=False,
|
||||||
|
)
|
||||||
|
_conclude(
|
||||||
|
"L1",
|
||||||
|
_coverage(rows, planned_rounds=_ROUNDS, proposition="disabled"),
|
||||||
|
run=run,
|
||||||
|
proposition="disabled",
|
||||||
)
|
)
|
||||||
assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}"
|
|
||||||
|
|
||||||
async def test_l2_enable_actually_enables(self):
|
async def test_l2_enable_actually_enables(self):
|
||||||
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=True)
|
run = _CaseRun(uuid4().hex, "MiniMax-M3")
|
||||||
ons = [o for o in obs if _reasoning_on(o)]
|
rows = await _run_rounds(
|
||||||
_record(
|
_ROUNDS,
|
||||||
"L2",
|
run=run,
|
||||||
"enable_thinking=True(流式,注入 medium)",
|
matrix_id="L2",
|
||||||
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
|
provider="minimax",
|
||||||
f"{len(ons)}/{len(obs)} 轮观察到推理",
|
model="MiniMax-M3",
|
||||||
obs,
|
reasoning_effort=Effort.MEDIUM,
|
||||||
|
)
|
||||||
|
_conclude(
|
||||||
|
"L2",
|
||||||
|
_coverage(rows, planned_rounds=_ROUNDS, proposition="enabled"),
|
||||||
|
run=run,
|
||||||
|
proposition="enabled",
|
||||||
)
|
)
|
||||||
assert len(ons) * 2 > len(obs), f"开启方向要求多数轮满足: {obs}"
|
|
||||||
|
|
||||||
async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self):
|
async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self):
|
||||||
"""确定性锚点: 开启档的 prompt_tokens 严格大于关闭档。
|
"""指定历史 prompt 锚点回归,不宣称关闭能力已覆盖。"""
|
||||||
|
run = _CaseRun(uuid4().hex, "MiniMax-M3")
|
||||||
供应商在开启推理时会向模板注入推理指令,输入侧 token 数随之变大。这是
|
|
||||||
本组唯一不依赖输出侧噪声的证据,且是相对比较——不硬编码任何具体数值,
|
|
||||||
供应商改模板也不会让它假红。
|
|
||||||
"""
|
|
||||||
rounds = max(3, _ROUNDS // 3)
|
rounds = max(3, _ROUNDS // 3)
|
||||||
off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False)
|
off = await _run_rounds(
|
||||||
on = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=True)
|
rounds,
|
||||||
off_max = max(o["prompt_tokens"] for o in off)
|
run=run,
|
||||||
on_min = min(o["prompt_tokens"] for o in on)
|
matrix_id="L2b-off",
|
||||||
_record(
|
provider="minimax",
|
||||||
"L2b",
|
model="MiniMax-M3",
|
||||||
"关闭/开启的 prompt_tokens 可分",
|
enable_thinking=False,
|
||||||
"PASS" if off_max < on_min else "FAIL",
|
|
||||||
f"关闭档最大 {off_max} < 开启档最小 {on_min}",
|
|
||||||
off + on,
|
|
||||||
)
|
)
|
||||||
assert off_max < on_min, (
|
on = await _run_rounds(
|
||||||
f"两档的 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): 注入可能没到达模型"
|
rounds,
|
||||||
|
run=run,
|
||||||
|
matrix_id="L2b-on",
|
||||||
|
provider="minimax",
|
||||||
|
model="MiniMax-M3",
|
||||||
|
reasoning_effort=Effort.MEDIUM,
|
||||||
)
|
)
|
||||||
|
verdict = _qualified(off + on, planned_rounds=rounds * 2)
|
||||||
|
if verdict.status == "PASS":
|
||||||
|
distinct = max(r["response"].prompt_tokens for r in off) < min(
|
||||||
|
r["response"].prompt_tokens for r in on
|
||||||
|
)
|
||||||
|
verdict = LiveVerdict(
|
||||||
|
"PASS" if distinct else "FAIL", "指定历史 prompt 锚点比较;不是关闭证明"
|
||||||
|
)
|
||||||
|
_conclude("L2b", verdict, run=run, proposition="historical-prompt-anchor")
|
||||||
|
|
||||||
async def test_l3_no_opinion_is_the_model_default(self):
|
async def test_l3_no_opinion_is_the_model_default(self):
|
||||||
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=None)
|
run = _CaseRun(uuid4().hex, "MiniMax-M3")
|
||||||
# M3 的默认档实测就是不推理(findings §2.1),所以不干预时也应观测不到推理。
|
rows = await _run_rounds(
|
||||||
# 注意这**不能**反过来证明关闭方向生效 —— L1 与本行同分布,区分二者的是
|
_ROUNDS, run=run, matrix_id="L3", provider="minimax", model="MiniMax-M3"
|
||||||
# L2b 的 prompt_tokens 与 L3b 的乱码值反证
|
|
||||||
quiet = [o for o in obs if _reasoning_off(o)]
|
|
||||||
_record(
|
|
||||||
"L3",
|
|
||||||
"enable_thinking=None(不干预,基线)",
|
|
||||||
"PASS" if len(quiet) == len(obs) else "FAIL",
|
|
||||||
f"{len(quiet)}/{len(obs)} 轮未推理(M3 默认档本就不推理)",
|
|
||||||
obs,
|
|
||||||
)
|
)
|
||||||
assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}"
|
verdict = _qualified(rows, planned_rounds=_ROUNDS)
|
||||||
|
if verdict.status == "PASS" and any(
|
||||||
|
row["response"].applied_effort is not None for row in rows
|
||||||
|
):
|
||||||
|
verdict = LiveVerdict("FAIL", "不表态路径擅自记录档位")
|
||||||
|
_conclude("L3", verdict, run=run, proposition="no-opinion-not-capability")
|
||||||
|
|
||||||
async def test_l3b_none_is_recognised_not_silently_dropped(self):
|
async def test_l3b_none_is_recognised_not_silently_dropped(self):
|
||||||
"""反证: 关闭方向的观测必须排除"参数被静默丢弃"这一伪解释。
|
"""保留原非法 raw 值对照预算,但不提升 UNKNOWN。"""
|
||||||
|
run = _CaseRun(uuid4().hex, "MiniMax-M3")
|
||||||
L1(关闭)与 L3(不干预)在 M3 上**同分布**——因为 M3 默认档本就不推理。
|
|
||||||
所以 L1 单独看不能区分"`none` 真的被消费"与"`none` 被中转吞了",而后者
|
|
||||||
正是 issue #5 的原始故障形态(`enable_thinking` 就是这么被吞的)。
|
|
||||||
|
|
||||||
判别方法: 发一个**非法值**。若未知值会被静默丢弃,它的表现应与"不注入"
|
|
||||||
一致(不推理);实测它反而开启了推理,说明网关认这个键、只是不认这个值。
|
|
||||||
既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值。
|
|
||||||
"""
|
|
||||||
rounds = max(3, _ROUNDS // 3)
|
rounds = max(3, _ROUNDS // 3)
|
||||||
bogus = await _run_rounds(
|
bogus = await _run_rounds(
|
||||||
rounds,
|
rounds,
|
||||||
|
run=run,
|
||||||
|
matrix_id="L3b-bogus",
|
||||||
|
provider="minimax",
|
||||||
model="MiniMax-M3",
|
model="MiniMax-M3",
|
||||||
enable_thinking=None,
|
|
||||||
extra_body={"reasoning_effort": "definitely-not-a-real-level"},
|
extra_body={"reasoning_effort": "definitely-not-a-real-level"},
|
||||||
)
|
)
|
||||||
off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False)
|
off = await _run_rounds(
|
||||||
bogus_on = [o for o in bogus if _reasoning_on(o)]
|
|
||||||
off_quiet = [o for o in off if _reasoning_off(o)]
|
|
||||||
ok = len(bogus_on) * 2 > len(bogus) and len(off_quiet) == len(off)
|
|
||||||
_record(
|
|
||||||
"L3b",
|
|
||||||
"非法值反证 none 被识别",
|
|
||||||
"PASS" if ok else "FAIL",
|
|
||||||
f"非法值 {len(bogus_on)}/{len(bogus)} 轮推理,none {len(off_quiet)}/{len(off)} 轮不推理"
|
|
||||||
"(两者表现不同 ⇒ none 非被丢弃)",
|
|
||||||
bogus + off,
|
|
||||||
)
|
|
||||||
assert len(bogus_on) * 2 > len(bogus), (
|
|
||||||
f"非法值未开启推理,无法排除'未知值被静默丢弃'这一伪解释: {bogus}"
|
|
||||||
)
|
|
||||||
assert len(off_quiet) == len(off), f"none 未关闭推理: {off}"
|
|
||||||
|
|
||||||
async def test_l4_extra_body_overrides_the_profile(self):
|
|
||||||
"""profile 注入 none,extra_body 要求 high —— 后者必须赢(优先级不可调换)。
|
|
||||||
|
|
||||||
判据是行为而非报文: 若 extra_body 没赢,拿到的就是 none 的结果(不推理)。
|
|
||||||
"""
|
|
||||||
rounds = max(3, _ROUNDS // 2)
|
|
||||||
obs = await _run_rounds(
|
|
||||||
rounds,
|
rounds,
|
||||||
|
run=run,
|
||||||
|
matrix_id="L3b-off",
|
||||||
|
provider="minimax",
|
||||||
model="MiniMax-M3",
|
model="MiniMax-M3",
|
||||||
enable_thinking=False,
|
enable_thinking=False,
|
||||||
|
)
|
||||||
|
verdicts = [
|
||||||
|
_coverage(bogus, planned_rounds=rounds, proposition="enabled"),
|
||||||
|
_coverage(off, planned_rounds=rounds, proposition="disabled"),
|
||||||
|
]
|
||||||
|
_conclude("L3b", _combine(verdicts), run=run, proposition="raw-counterexample")
|
||||||
|
|
||||||
|
async def test_l4_raw_only_explicit_high(self):
|
||||||
|
"""退出受管意图后才保留 raw high;双来源拒绝在 unit 守卫。"""
|
||||||
|
run = _CaseRun(uuid4().hex, "MiniMax-M3")
|
||||||
|
rounds = max(3, _ROUNDS // 2)
|
||||||
|
rows = await _run_rounds(
|
||||||
|
rounds,
|
||||||
|
run=run,
|
||||||
|
matrix_id="L4",
|
||||||
|
provider="minimax",
|
||||||
|
model="MiniMax-M3",
|
||||||
extra_body={"reasoning_effort": "high"},
|
extra_body={"reasoning_effort": "high"},
|
||||||
)
|
)
|
||||||
ons = [o for o in obs if _reasoning_on(o)]
|
_conclude(
|
||||||
_record(
|
|
||||||
"L4",
|
"L4",
|
||||||
"extra_body 覆盖 profile 注入",
|
_coverage(rows, planned_rounds=rounds, proposition="enabled"),
|
||||||
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
|
run=run,
|
||||||
f"{len(ons)}/{len(obs)} 轮观察到推理(证明 high 生效而非 none)",
|
proposition="enabled",
|
||||||
obs,
|
|
||||||
)
|
)
|
||||||
assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}"
|
|
||||||
|
|
||||||
async def test_l5_non_stream_path_matches_stream(self):
|
async def test_l5_non_stream_path_is_distinguishable_and_honestly_unknown(self):
|
||||||
"""非流式快路径独立于流式实现,采集与注入都要各自验一遍。"""
|
"""保留流/非流预算;UNKNOWN 是明确未覆盖而非长度锚点成功。"""
|
||||||
|
run = _CaseRun(uuid4().hex, "MiniMax-M3")
|
||||||
rounds = max(3, _ROUNDS // 2)
|
rounds = max(3, _ROUNDS // 2)
|
||||||
off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False)
|
off = await _run_rounds(
|
||||||
on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True)
|
rounds,
|
||||||
offs = [o for o in off if _reasoning_off(o)]
|
run=run,
|
||||||
ons = [o for o in on if _reasoning_on(o)]
|
matrix_id="L5-off",
|
||||||
ok = len(offs) == len(off) and len(ons) * 2 > len(on)
|
stream=False,
|
||||||
_record(
|
provider="minimax",
|
||||||
"L5",
|
model="MiniMax-M3",
|
||||||
"非流式路径重跑 L1/L2",
|
enable_thinking=False,
|
||||||
"PASS" if ok else "FAIL",
|
|
||||||
f"关闭 {len(offs)}/{len(off)} 轮,开启 {len(ons)}/{len(on)} 轮",
|
|
||||||
off + on,
|
|
||||||
)
|
)
|
||||||
assert len(offs) == len(off), f"非流式关闭方向未满足: {off}"
|
on = await _run_rounds(
|
||||||
assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}"
|
rounds,
|
||||||
|
run=run,
|
||||||
|
matrix_id="L5-on",
|
||||||
|
stream=False,
|
||||||
|
provider="minimax",
|
||||||
|
model="MiniMax-M3",
|
||||||
|
reasoning_effort=Effort.MEDIUM,
|
||||||
|
)
|
||||||
|
_conclude(
|
||||||
|
"L5",
|
||||||
|
_combine(
|
||||||
|
[
|
||||||
|
_coverage(off, planned_rounds=rounds, proposition="disabled"),
|
||||||
|
_coverage(on, planned_rounds=rounds, proposition="enabled"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
run=run,
|
||||||
|
proposition="nonstream-enabled-disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _combine(verdicts):
|
||||||
|
"""任一失败优先,部分未覆盖不得汇总全 PASS。"""
|
||||||
|
return combine_live_verdicts(verdicts)
|
||||||
|
|
||||||
|
|
||||||
class TestOtherProviders:
|
class TestOtherProviders:
|
||||||
"""qwen / deepseek 的 profile 是既有实现,本组防的是"改 minimax 时误伤它们"。"""
|
"""既有供应商开启/关闭真实矩阵。"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("matrix", "provider", "model"),
|
("matrix", "provider", "model"),
|
||||||
[("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")],
|
[("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")],
|
||||||
)
|
)
|
||||||
async def test_existing_profiles_still_disable(self, matrix, provider, model):
|
async def test_existing_profiles_still_disable(self, matrix, provider, model):
|
||||||
desc = f"{provider} enable_thinking=False"
|
run = _CaseRun(uuid4().hex, model)
|
||||||
try:
|
rows = await _run_rounds(
|
||||||
obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=False)
|
_ROUNDS,
|
||||||
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
|
run=run,
|
||||||
# 只吞网关/网络类失败。**不吞 ValueError / RequestRejected** ——
|
matrix_id=matrix,
|
||||||
# 那两类正是本次改动最可能的误伤方向,吞掉就成了纪律(c)要防的静默
|
provider=provider,
|
||||||
_skip_if_unreachable(exc, matrix, desc)
|
model=model,
|
||||||
offs = [o for o in obs if _reasoning_off(o)]
|
enable_thinking=False,
|
||||||
_record(
|
)
|
||||||
matrix,
|
_conclude(
|
||||||
desc,
|
matrix,
|
||||||
"PASS" if len(offs) == len(obs) else "FAIL",
|
_coverage(rows, planned_rounds=_ROUNDS, proposition="disabled"),
|
||||||
f"{len(offs)}/{len(obs)} 轮确认未推理",
|
run=run,
|
||||||
obs,
|
proposition="disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_qwen_enabled_is_observed(self):
|
||||||
|
run = _CaseRun(uuid4().hex, "qwen3.7-plus")
|
||||||
|
rows = await _run_rounds(
|
||||||
|
_ROUNDS,
|
||||||
|
run=run,
|
||||||
|
matrix_id="L6b",
|
||||||
|
provider="qwen",
|
||||||
|
model="qwen3.7-plus",
|
||||||
|
enable_thinking=True,
|
||||||
|
)
|
||||||
|
_conclude(
|
||||||
|
"L6b",
|
||||||
|
_coverage(rows, planned_rounds=_ROUNDS, proposition="enabled"),
|
||||||
|
run=run,
|
||||||
|
proposition="enabled",
|
||||||
)
|
)
|
||||||
assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCapabilityDrift:
|
class TestCapabilityDrift:
|
||||||
"""L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。"""
|
"""只运行可关闭声明的真实验证;不可关闭装配拒绝另在 unit。"""
|
||||||
|
|
||||||
def test_every_capability_has_a_provider_mapping(self):
|
@pytest.mark.parametrize(
|
||||||
"""能力表新增条目必须同步本测试的映射,否则该行会被静默跳过。"""
|
"model",
|
||||||
missing = sorted(set(DEFAULT_CAPABILITIES) - set(_MODEL_PROVIDER))
|
sorted(
|
||||||
assert not missing, f"这些模型缺 provider 映射,L8 会漏测: {missing}"
|
model for model, capability in DEFAULT_CAPABILITIES.items() if capability.can_disable
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_declared_capability_matches_reality(self, model):
|
||||||
|
run = _CaseRun(uuid4().hex, model)
|
||||||
|
rounds = max(3, _ROUNDS // 2)
|
||||||
|
rows = await _run_rounds(
|
||||||
|
rounds,
|
||||||
|
run=run,
|
||||||
|
matrix_id="L8",
|
||||||
|
provider=_MODEL_PROVIDER[model],
|
||||||
|
model=model,
|
||||||
|
enable_thinking=False,
|
||||||
|
)
|
||||||
|
_conclude(
|
||||||
|
"L8",
|
||||||
|
_coverage(rows, planned_rounds=rounds, proposition="disabled"),
|
||||||
|
run=run,
|
||||||
|
proposition="disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe_effort(model, effort, *, run, rounds, prompt, prompt_kind):
|
||||||
|
"""临时全档表仅用于 T10 探测,不写回 DEFAULT,也不生成预期 wire。"""
|
||||||
|
return await _collect_rounds(
|
||||||
|
_tier_settings(model),
|
||||||
|
run=run,
|
||||||
|
rounds=rounds,
|
||||||
|
stream=True,
|
||||||
|
prompt=prompt,
|
||||||
|
matrix_id="T10-" + prompt_kind,
|
||||||
|
effort=effort,
|
||||||
|
capabilities={model: ThinkingCapability(_ALL_EFFORTS, evidence="T10 临时探测声明")},
|
||||||
|
concurrency=_TIER_CONCURRENCY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierProbe:
|
||||||
|
"""逐型号能力命题,不把拒绝、不可关闭和 UNKNOWN 混在一起。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", sorted(_MODEL_PROVIDER))
|
||||||
|
async def test_t10_none_direction_matches_declaration(self, model):
|
||||||
|
run = _CaseRun(uuid4().hex, model)
|
||||||
|
capability = DEFAULT_CAPABILITIES.get(model)
|
||||||
|
proposition = (
|
||||||
|
"observation-only"
|
||||||
|
if capability is None
|
||||||
|
else "disabled"
|
||||||
|
if capability.can_disable
|
||||||
|
else "cannot_disable"
|
||||||
|
)
|
||||||
|
short = await _probe_effort(
|
||||||
|
model,
|
||||||
|
Effort.NONE,
|
||||||
|
run=run,
|
||||||
|
rounds=_TIER_ROUNDS,
|
||||||
|
prompt=_TIER_PROMPT,
|
||||||
|
prompt_kind="none-short",
|
||||||
|
)
|
||||||
|
verdict = _coverage(short, planned_rounds=_TIER_ROUNDS, proposition=proposition)
|
||||||
|
# 沿既有矩阵:短档没有 OBSERVED 才做长上下文复核;不新增锚点调用。
|
||||||
|
if _qualified(short, planned_rounds=_TIER_ROUNDS).status == "PASS" and not any(
|
||||||
|
r["response"].thinking_observation is ThinkingObservation.OBSERVED for r in short
|
||||||
|
):
|
||||||
|
long_rows = await _probe_effort(
|
||||||
|
model,
|
||||||
|
Effort.NONE,
|
||||||
|
run=run,
|
||||||
|
rounds=_TIER_LONG_ROUNDS,
|
||||||
|
prompt=_TIER_LONG_PROMPT,
|
||||||
|
prompt_kind="none-long",
|
||||||
|
)
|
||||||
|
verdict = _coverage(
|
||||||
|
short + long_rows,
|
||||||
|
planned_rounds=_TIER_ROUNDS + _TIER_LONG_ROUNDS,
|
||||||
|
proposition=proposition,
|
||||||
|
)
|
||||||
|
_conclude("T10-none", verdict, run=run, proposition=proposition)
|
||||||
|
|
||||||
@pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES))
|
@pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES))
|
||||||
async def test_declared_capability_matches_reality(self, model):
|
async def test_t10_declared_tiers_actually_reason(self, model):
|
||||||
cap = get_capability(model)
|
run = _CaseRun(uuid4().hex, model)
|
||||||
provider = _MODEL_PROVIDER[model]
|
tiers = [e for e in DEFAULT_CAPABILITIES[model].supported_efforts if e is not Effort.NONE]
|
||||||
rounds = max(3, _ROUNDS // 2)
|
verdicts = []
|
||||||
desc = f"{model} 声明 can_disable={cap.can_disable}"
|
for tier in tiers:
|
||||||
if not cap.can_disable:
|
rows = await _probe_effort(
|
||||||
# 声明关不掉: 装配期就该炸,炸了即与声明一致(不必真调用)
|
model,
|
||||||
with pytest.raises(ValueError, match=model):
|
tier,
|
||||||
GatewayClient.from_settings(
|
run=run,
|
||||||
_settings(provider=provider, model=model, enable_thinking=False)
|
rounds=_TIER_ROUNDS,
|
||||||
)
|
prompt=_TIER_PROMPT,
|
||||||
_record("L8", desc, "PASS", "装配期按声明拒绝,与实测一致")
|
prompt_kind="tier-" + tier.value,
|
||||||
return
|
)
|
||||||
try:
|
verdict = _coverage(rows, planned_rounds=_TIER_ROUNDS, proposition="enabled")
|
||||||
obs = await _run_rounds(rounds, provider=provider, model=model, enable_thinking=False)
|
verdicts.append(verdict)
|
||||||
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
|
write_live_round(
|
||||||
_skip_if_unreachable(exc, "L8", desc)
|
_OUT_DIR,
|
||||||
offs = [o for o in obs if _reasoning_off(o)]
|
run_id=run.run_id,
|
||||||
verdict = Counter(_reasoning_off(o) for o in obs)
|
matrix_id="T10-tier",
|
||||||
_record(
|
round_index=0,
|
||||||
"L8",
|
safe_fields={
|
||||||
desc,
|
"requested_model": model,
|
||||||
"PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)",
|
"session_id": run.run_id,
|
||||||
f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭",
|
"proposition": "enabled",
|
||||||
obs,
|
"subruns": [run.subruns[-1]],
|
||||||
)
|
"planned_rounds": _TIER_ROUNDS,
|
||||||
assert len(offs) == len(obs), (
|
"completed_rounds": len(rows),
|
||||||
f"能力表漂移: {model} 声明可关闭推理,实测未关掉 —— 请复测后更新 DEFAULT_CAPABILITIES"
|
"requested_effort": tier.value,
|
||||||
|
"status": verdict.status,
|
||||||
|
"reason": verdict.reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_conclude(
|
||||||
|
"T10-tiers", _combine(verdicts), run=run, proposition="enabled-all-declared-tiers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", ["gemini-3.1-pro", "gpt-5.5", "glm-5.3"])
|
||||||
class TestAssemblyGuardAgainstRealConfig:
|
async def test_t10_no_opinion_stays_no_opinion(self, model):
|
||||||
"""L9: 纯本地,但用的是 .env 里的真实配置形态,防"守卫只在合成配置上生效"。"""
|
run = _CaseRun(uuid4().hex, model)
|
||||||
|
rows = await _collect_rounds(
|
||||||
def test_l9_m27_rejected_at_assembly(self):
|
_tier_settings(model),
|
||||||
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
run=run,
|
||||||
GatewayClient.from_settings(
|
rounds=_TIER_ROUNDS,
|
||||||
_settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
|
stream=True,
|
||||||
)
|
prompt=_TIER_PROMPT,
|
||||||
_record("L9", "M2.7 + enable_thinking=False", "PASS", "装配期报错,未发出任何请求")
|
matrix_id="T10-default",
|
||||||
|
capabilities={model: ThinkingCapability(_ALL_EFFORTS, evidence="T10 临时探测声明")},
|
||||||
def test_l9_unknown_shape_rejected_at_assembly(self):
|
|
||||||
with pytest.raises(ValueError, match="register_provider"):
|
|
||||||
GatewayClient.from_settings(
|
|
||||||
_settings(provider="openai", model="kimi-k3", enable_thinking=False)
|
|
||||||
)
|
|
||||||
_record("L9", "provider=openai 形态未知", "PASS", "装配期报错并指路")
|
|
||||||
|
|
||||||
async def test_transport_layer_rejects_when_guard_is_bypassed(self):
|
|
||||||
"""构造函数全量注入这条路绕过装配守卫,transport 必须兜住并归四分类。"""
|
|
||||||
settings = _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
|
|
||||||
client = GatewayClient.from_settings(
|
|
||||||
dataclasses.replace(
|
|
||||||
settings, sources=(dataclasses.replace(settings.sources[0], enable_thinking=None),)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
try:
|
verdict = _qualified(rows, planned_rounds=_TIER_ROUNDS)
|
||||||
# 装配用 None 绕过守卫,再把源换成 False 直接喂给 transport
|
if verdict.status == "PASS" and any(
|
||||||
bad = dataclasses.replace(settings.sources[0], enable_thinking=False)
|
row["response"].applied_effort is not None for row in rows
|
||||||
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
|
):
|
||||||
await client._terminal._transport.complete(
|
verdict = LiveVerdict("FAIL", "默认基线擅自推定档位")
|
||||||
messages=[{"role": "user", "content": _PROMPT}],
|
_conclude("T10-default", verdict, run=run, proposition="no-opinion-not-capability")
|
||||||
source=bad,
|
|
||||||
stream=True,
|
|
||||||
overlay={},
|
|
||||||
call_id="e2e-guard",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await client.aclose()
|
|
||||||
_record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类")
|
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""PG 集成测试的一次性沙箱工厂(issue #18)。
|
||||||
|
|
||||||
|
**为什么把它收敛成一份**: 在此之前,"建临时 schema → 挂 search_path → teardown
|
||||||
|
删净"这套样板在两个测试文件里重复了七处,清理逻辑各写各的——任何一处写漏,残留都
|
||||||
|
落在与真实批跑共用的那个库上。工厂让清理只有一份实现,并让"用例拿不到管理连接"
|
||||||
|
成为结构事实而不是纪律。
|
||||||
|
|
||||||
|
**admin DSN 不做成 fixture**: 它能对共享表执行任何语句。做成 fixture 等于把这个
|
||||||
|
能力摆在每一条用例面前,"用例不该直接用"就只是一句提醒。故它是模块私有函数,
|
||||||
|
只被工厂内部调用,`PgSandbox` 也不携带它。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
# 测试专用口令: 这些角色只在单条用例的生命周期内存在,且只对自建 schema 有权。
|
||||||
|
# 它不是机密,写死在这里比走 .env 更清楚——.env 里的每一项都该是真实部署会用的。
|
||||||
|
_SANDBOX_PASSWORD = "pgw-sandbox-not-a-secret" # noqa: S105
|
||||||
|
|
||||||
|
_Role = Literal["none", "owner", "grantee"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PgSandbox:
|
||||||
|
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
|
||||||
|
|
||||||
|
schema: str
|
||||||
|
role: str | None
|
||||||
|
dsn: str
|
||||||
|
"""已挂 `options=-csearch_path=<schema>`,用例默认用它。"""
|
||||||
|
bare_dsn: str | None
|
||||||
|
"""同角色但**不挂** search_path(回落 `"$user", public`);`role="none"` 时为 None。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_dsn() -> str | None:
|
||||||
|
"""读 `.env` 的 `PGW_TELEMETRY_PG_DSN` 并剥掉 SQLAlchemy 风格的 `+driver` 后缀。"""
|
||||||
|
merged = {**dotenv_values(".env"), **os.environ}
|
||||||
|
raw = merged.get("PGW_TELEMETRY_PG_DSN")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
scheme, sep, rest = raw.partition("://")
|
||||||
|
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin_dsn() -> str:
|
||||||
|
"""取管理连接串;未配置则 skip,连错库则 fail(不是 skip)。
|
||||||
|
|
||||||
|
库名守卫不肯降级成 skip: 这个实例上还有 app/chs_prod 等在用库,把"连错库"
|
||||||
|
悄悄跳过,等于让一次配置事故以"没跑那些测试"的形态过关。
|
||||||
|
"""
|
||||||
|
value = _admin_dsn()
|
||||||
|
if value is None:
|
||||||
|
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
|
||||||
|
if not value.rstrip("/").endswith("/polygateway"):
|
||||||
|
pytest.fail(f"PG 集成测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _with_search_path(dsn: str, schema: str) -> str:
|
||||||
|
sep = "&" if "?" in dsn else "?"
|
||||||
|
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
||||||
|
|
||||||
|
|
||||||
|
def _as_role(dsn: str, role: str) -> str:
|
||||||
|
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。
|
||||||
|
|
||||||
|
**换不掉就报错,绝不原样返回**: `postgresql://h:5432/db`(口令走 PGPASSWORD /
|
||||||
|
.pgpass / trust)与 `postgresql:///db?host=/var/run/postgresql`(unix socket)
|
||||||
|
都是合法 DSN,却没有可替换的内联凭据段。静默返回原串的后果不是测试报错,而是
|
||||||
|
沙箱以**管理身份**建成、用例照常绿,同时 `bare_dsn` 变成超级用户连接——最坏
|
||||||
|
情况用例会拿它跑真实 `--apply`,删空共享表之后才在退出码断言上红。
|
||||||
|
这正是 P5"严禁默认值掩盖错误"要挡的形态。
|
||||||
|
"""
|
||||||
|
swapped, count = re.subn(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1)
|
||||||
|
if count != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"DSN 里没有可替换的内联凭据段,沙箱角色 {role} 无法生效,拒绝以管理身份继续。"
|
||||||
|
"请把 PGW_TELEMETRY_PG_DSN 写成 postgresql://<用户>:<口令>@<主机>/<库> 的形态。"
|
||||||
|
)
|
||||||
|
return swapped
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def pg_catalog_probe():
|
||||||
|
"""只读地查 PG catalog,**仅供工厂自测核对残留**,不是通用查询入口。
|
||||||
|
|
||||||
|
它拿的是管理连接,故有意只暴露给 `test_pg_sandbox.py` 这一类"验证隔离本身
|
||||||
|
是否成立"的用例;业务断言一律走 `PgSandbox.dsn`。
|
||||||
|
"""
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
dsn = _require_admin_dsn()
|
||||||
|
|
||||||
|
async def probe(sql: str, *args: object) -> list[tuple]:
|
||||||
|
# 只读校验不是形式主义: 这个闭包持的是管理连接,不设限就等于把"用例够不到
|
||||||
|
# 管理能力"这句话降格成一句 docstring 里的请求。
|
||||||
|
if not sql.lstrip().upper().startswith("SELECT"):
|
||||||
|
raise RuntimeError(f"pg_catalog_probe 只接受 SELECT 语句,收到: {sql[:60]!r}")
|
||||||
|
conn = await asyncpg.connect(dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
return [tuple(r) for r in await conn.fetch(sql, *args)]
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
return probe
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def pg_sandbox():
|
||||||
|
"""一次性沙箱工厂: `await pg_sandbox(ddl=..., role=...)`,清理由 fixture 兜底。
|
||||||
|
|
||||||
|
同一条用例可以要多个沙箱(如"A 的角色去动 B 的表"),它们按后进先出清理。
|
||||||
|
"""
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
admin_dsn = _require_admin_dsn()
|
||||||
|
# 清理动作栈: 每建成一个对象就入栈一条,setup 中途失败与正常 teardown 共用
|
||||||
|
# 同一条退栈路径——两处各写一份的话,失败那条永远是没被测过的那份。
|
||||||
|
cleanups: list[str] = []
|
||||||
|
|
||||||
|
async def _run_as_admin(*statements: str) -> None:
|
||||||
|
conn = await asyncpg.connect(admin_dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
for statement in statements:
|
||||||
|
await conn.execute(statement)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def _unwind(statements: list[str]) -> None:
|
||||||
|
"""逆序执行清理并**逐条容错**: 一条失败不该拖累其余对象的清理。
|
||||||
|
|
||||||
|
吞掉异常是不行的(残留会静默累积),但让第一条失败中断整栈更糟——角色是
|
||||||
|
全局对象,漏掉的每一个都要人手工去删。故全部试完再抛出第一个异常。
|
||||||
|
"""
|
||||||
|
first: BaseException | None = None
|
||||||
|
for statement in reversed(statements):
|
||||||
|
try:
|
||||||
|
await _run_as_admin(statement)
|
||||||
|
except Exception as exc: # noqa: BLE001 — 见 docstring: 收集而非吞没
|
||||||
|
first = first or exc
|
||||||
|
statements.clear()
|
||||||
|
if first is not None:
|
||||||
|
raise first
|
||||||
|
|
||||||
|
async def make(
|
||||||
|
*,
|
||||||
|
ddl: str | None = None,
|
||||||
|
extra: Sequence[str] = (),
|
||||||
|
role: _Role = "none",
|
||||||
|
grants: Sequence[str] = ("SELECT", "INSERT"),
|
||||||
|
) -> PgSandbox:
|
||||||
|
# 权限门在建任何对象**之前**: pytest.skip 抛的是 BaseException,若它在
|
||||||
|
# 已建对象之后触发,清理会去 DROP 从未建成的东西并把 skip 盖掉。
|
||||||
|
if role != "none":
|
||||||
|
conn = await asyncpg.connect(admin_dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
can_create = await conn.fetchval(
|
||||||
|
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
if not can_create:
|
||||||
|
pytest.skip("当前账号无权建临时角色,跳过需要独占角色的用例")
|
||||||
|
|
||||||
|
# schema 与角色的前缀有意不同: 同名会让 "$user" 命中自有 schema 并遮蔽
|
||||||
|
# 共享表,于是"search_path 落到共享表"这个最坏情况就再也构造不出来。
|
||||||
|
suffix = uuid4().hex[:12]
|
||||||
|
schema = f"pgw_s_{suffix}"
|
||||||
|
role_name = f"pgw_r_{suffix}" if role != "none" else None
|
||||||
|
|
||||||
|
# 本次调用自己的清理栈: 失败只回滚**本次**建成的对象。同一条用例常要两个
|
||||||
|
# 沙箱(如"A 的角色去动 B 的表"),回滚整栈会把已通过断言依赖的对象也删掉。
|
||||||
|
local: list[str] = []
|
||||||
|
try:
|
||||||
|
if role_name is not None:
|
||||||
|
await _run_as_admin(f"CREATE ROLE {role_name} LOGIN PASSWORD '{_SANDBOX_PASSWORD}'")
|
||||||
|
# DROP OWNED BY 必须排在 DROP ROLE 之前: 角色仍持有对象时删不掉
|
||||||
|
local.append(f"DROP ROLE IF EXISTS {role_name}")
|
||||||
|
local.append(f"DROP OWNED BY {role_name}")
|
||||||
|
owner_clause = f" AUTHORIZATION {role_name}" if role == "owner" else ""
|
||||||
|
await _run_as_admin(f"CREATE SCHEMA {schema}{owner_clause}")
|
||||||
|
local.append(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
|
||||||
|
|
||||||
|
bare = _as_role(admin_dsn, role_name) if role_name is not None else None
|
||||||
|
# role="owner" 时 DDL 由角色自己执行,表属主才会是它;"grantee" 的现场
|
||||||
|
# 恰恰相反——表由别的账号建好,角色只拿到表级权限。
|
||||||
|
ddl_dsn = _with_search_path(bare if role == "owner" else admin_dsn, schema)
|
||||||
|
if ddl is not None:
|
||||||
|
conn = await asyncpg.connect(ddl_dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
await conn.execute(ddl)
|
||||||
|
for statement in extra:
|
||||||
|
await conn.execute(statement)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
if role == "grantee":
|
||||||
|
await _run_as_admin(f"GRANT USAGE ON SCHEMA {schema} TO {role_name}")
|
||||||
|
if ddl is not None:
|
||||||
|
await _run_as_admin(
|
||||||
|
f"GRANT {', '.join(grants)} ON ALL TABLES IN SCHEMA {schema} TO {role_name}"
|
||||||
|
)
|
||||||
|
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
|
||||||
|
|
||||||
|
used = bare if role_name is not None else admin_dsn
|
||||||
|
sandbox = PgSandbox(
|
||||||
|
schema=schema,
|
||||||
|
role=role_name,
|
||||||
|
dsn=_with_search_path(used, schema),
|
||||||
|
bare_dsn=bare,
|
||||||
|
)
|
||||||
|
if role_name is not None:
|
||||||
|
# 字符串替换成功不等于连上去就是那个角色(PGUSER 等环境变量仍可能
|
||||||
|
# 盖掉 DSN 里的用户名)。这道校验按**实际身份**兜底: 整个设计的价值
|
||||||
|
# 都压在"跑脚本的那个连接对共享表无权"上,不值得只用一次字符串比较
|
||||||
|
# 来担保。它必须留在 try 之内——出了这个块,清理动作已经并进 fixture
|
||||||
|
# 级的栈,再回滚一次就会对同一个角色跑两遍 DROP OWNED BY(它没有
|
||||||
|
# IF EXISTS,第二遍必报错)。
|
||||||
|
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
actual = await conn.fetchval("SELECT current_user")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
if actual != role_name:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"沙箱 DSN 连上去的身份是 {actual!r},不是预期的 {role_name!r};"
|
||||||
|
"权限边界不成立,拒绝把这个沙箱交出去。"
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
await _unwind(local)
|
||||||
|
raise
|
||||||
|
cleanups.extend(local)
|
||||||
|
|
||||||
|
return sandbox
|
||||||
|
|
||||||
|
yield make
|
||||||
|
await _unwind(cleanups)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""`conftest.py` 沙箱工厂自身的行为测试(issue #18 Task 1)。
|
||||||
|
|
||||||
|
工厂是本次一切隔离的地基: 它若在 setup 中途失败时漏掉清理、或让角色名与
|
||||||
|
schema 名撞上,受害的不是这一个文件,而是此后每一条 PG 用例。故它必须先被测。
|
||||||
|
|
||||||
|
**这里的断言全部只看自建对象与 PG catalog**,不读任何共享数据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.integration.conftest import _as_role
|
||||||
|
|
||||||
|
_DDL = "CREATE TABLE llm_calls (call_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def assert_no_leftovers(pg_catalog_probe):
|
||||||
|
"""收集沙箱名,在 `pg_sandbox` 清理之后回查它们是否真的没了。
|
||||||
|
|
||||||
|
必须比 `pg_sandbox` **先** setup: pytest 的 finalizer 是后进先出,先 setup
|
||||||
|
的后 teardown——本 fixture 的检查因此发生在沙箱清理之后,而不是之前。
|
||||||
|
"""
|
||||||
|
seen: list[tuple[str, str | None]] = []
|
||||||
|
yield seen
|
||||||
|
for schema, role in seen:
|
||||||
|
left = await pg_catalog_probe("SELECT nspname FROM pg_namespace WHERE nspname = $1", schema)
|
||||||
|
assert left == [], f"沙箱 schema 未清理: {schema}"
|
||||||
|
if role is not None:
|
||||||
|
left = await pg_catalog_probe("SELECT rolname FROM pg_roles WHERE rolname = $1", role)
|
||||||
|
assert left == [], f"沙箱角色未清理: {role}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _oid_of_llm_calls(dsn: str) -> int | None:
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
return await conn.fetchval("SELECT to_regclass('llm_calls')::oid")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleDsnConstruction:
|
||||||
|
"""凭据替换失败必须**当场报错**,不许退回管理身份(合并前审查的 P1)。
|
||||||
|
|
||||||
|
这条防线的失效形态特别隐蔽: 替换不上时 `re.sub` 原样返回管理连接串,沙箱
|
||||||
|
"看起来"建好了、用例照常绿,而 `bare_dsn` 其实是超级用户——最坏情况用例
|
||||||
|
会拿它跑真实 `--apply`,把共享表删空之后才在 `assert returncode == 2` 上红。
|
||||||
|
行已经没了。设计 §5.1 要的是"越界做不到",不是"越界会被发现"。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_inline_credentials_are_replaced(self):
|
||||||
|
swapped = _as_role("postgresql://app:secret@h:5432/polygateway", "pgw_r_x")
|
||||||
|
|
||||||
|
assert swapped.startswith("postgresql://pgw_r_x:")
|
||||||
|
assert "app:secret" not in swapped
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dsn",
|
||||||
|
[
|
||||||
|
"postgresql://h:5432/polygateway", # 口令走 PGPASSWORD / .pgpass / trust
|
||||||
|
"postgresql:///polygateway?host=/var/run/postgresql", # unix socket
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_dsn_without_inline_credentials_is_refused(self, dsn):
|
||||||
|
"""这两种都是合法 DSN,今天的 .env 恰好不是它们——恰好而已。"""
|
||||||
|
with pytest.raises(RuntimeError, match="沙箱角色"):
|
||||||
|
_as_role(dsn, "pgw_r_x")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalogProbeIsReadOnly:
|
||||||
|
"""探针拿的是管理连接,故它只许查——否则"用例够不到管理能力"就是句空话。"""
|
||||||
|
|
||||||
|
async def test_non_select_statements_are_refused(self, pg_catalog_probe):
|
||||||
|
with pytest.raises(RuntimeError, match="只接受 SELECT"):
|
||||||
|
await pg_catalog_probe("DELETE FROM llm_calls WHERE call_id = 'nope'")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaOnlySandbox:
|
||||||
|
async def test_table_lands_in_the_sandbox_schema_and_bare_dsn_is_absent(self, pg_sandbox):
|
||||||
|
"""`role="none"`: 表落在自建 schema 下;不发角色,故没有裸 DSN 可给。"""
|
||||||
|
sandbox = await pg_sandbox(ddl=_DDL)
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
where = await conn.fetchval(
|
||||||
|
"SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||||
|
"WHERE c.oid = to_regclass('llm_calls')"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
assert where == sandbox.schema
|
||||||
|
assert sandbox.role is None
|
||||||
|
assert sandbox.bare_dsn is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnerRoleSandbox:
|
||||||
|
async def test_the_role_owns_its_own_table(self, pg_sandbox):
|
||||||
|
"""`role="owner"`: 表由角色自己建,故属主是它——与"用维护角色跑"的现场一致。"""
|
||||||
|
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
owner = await conn.fetchval(
|
||||||
|
"SELECT pg_get_userbyid(relowner) FROM pg_class WHERE oid = to_regclass('llm_calls')"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
assert owner == sandbox.role
|
||||||
|
# 名字必须错开: 同名会让 "$user" 命中自有 schema 并遮蔽真表,
|
||||||
|
# 最坏情况用例就再也走不到那条真实路径上(设计 §5.1 实测)
|
||||||
|
assert sandbox.role != sandbox.schema
|
||||||
|
assert not sandbox.role.startswith("pgw_s_")
|
||||||
|
assert not sandbox.schema.startswith("pgw_r_")
|
||||||
|
|
||||||
|
async def test_bare_dsn_falls_through_to_the_default_search_path(self, pg_sandbox):
|
||||||
|
"""裸 DSN 必须真的回落到 `"$user", public`——最坏情况用例全靠它构造现场。"""
|
||||||
|
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(sandbox.bare_dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
path = await conn.fetchval("SHOW search_path")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
assert path == '"$user", public'
|
||||||
|
# 裸 DSN 解析到的绝不能是沙箱里那张表(否则"落到共享表"的现场是假的)
|
||||||
|
assert await _oid_of_llm_calls(sandbox.bare_dsn) != await _oid_of_llm_calls(sandbox.dsn)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGranteeRoleSandbox:
|
||||||
|
async def test_grantee_can_write_but_cannot_create(self, pg_sandbox):
|
||||||
|
"""`role="grantee"`: 表属主是 admin,角色只拿表级权限——最小权限部署的现场。"""
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
sandbox = await pg_sandbox(ddl=_DDL, role="grantee")
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
await conn.execute("INSERT INTO llm_calls (call_id) VALUES ('g1')")
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 1
|
||||||
|
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||||
|
await conn.execute("CREATE TABLE another (x TEXT)")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanup:
|
||||||
|
async def test_setup_failure_leaves_nothing_behind(self, pg_sandbox, pg_catalog_probe):
|
||||||
|
"""建到一半失败时也必须删净——角色是**全局**对象,残留不随库消失。"""
|
||||||
|
before_schemas = await pg_catalog_probe(
|
||||||
|
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
|
||||||
|
)
|
||||||
|
before_roles = await pg_catalog_probe(
|
||||||
|
"SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(Exception): # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
|
||||||
|
await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await pg_catalog_probe(
|
||||||
|
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
|
||||||
|
)
|
||||||
|
== before_schemas
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await pg_catalog_probe(
|
||||||
|
"SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
|
||||||
|
)
|
||||||
|
== before_roles
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_a_failure_does_not_roll_back_earlier_sandboxes(self, pg_sandbox):
|
||||||
|
"""一次失败只回滚它自己建的东西——同一条用例里先建成的沙箱必须毫发无损。
|
||||||
|
|
||||||
|
"A 的角色去动 B 的表"这类用例一条要两个沙箱;若失败回滚把整栈清空,受害的
|
||||||
|
是那些**已经通过**的断言所依赖的对象,而症状会以"表不见了"的形态出现在
|
||||||
|
与真因无关的地方。
|
||||||
|
"""
|
||||||
|
good = await pg_sandbox(ddl=_DDL, role="owner")
|
||||||
|
|
||||||
|
with pytest.raises(Exception): # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
|
||||||
|
await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")
|
||||||
|
|
||||||
|
assert await _oid_of_llm_calls(good.dsn) is not None, "先前建成的沙箱被误清理"
|
||||||
|
|
||||||
|
async def test_teardown_removes_schema_and_role(self, assert_no_leftovers, pg_sandbox):
|
||||||
|
"""正常路径的清理: 断言发生在 `pg_sandbox` teardown **之后**(见 fixture 说明)。"""
|
||||||
|
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
|
||||||
|
assert_no_leftovers.append((sandbox.schema, sandbox.role))
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ class ScriptedTransport:
|
|||||||
self.hang = hang
|
self.hang = hang
|
||||||
self.calls: list[str] = []
|
self.calls: list[str] = []
|
||||||
|
|
||||||
async def complete(self, *, messages, source, stream, overlay, call_id):
|
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
||||||
self.calls.append(source.name)
|
self.calls.append(source.name)
|
||||||
if self.hang:
|
if self.hang:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
|
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
|
||||||
|
|
||||||
DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。
|
隔离纪律(issue #18): 本文件跑的是一个**会删数据的脚本**,而实例上的共享表 `llm_calls`
|
||||||
|
与真实批跑共用。故**凡启动脚本的用例一律用 `pg_sandbox(role="owner")` 的临时角色跑**:
|
||||||
|
该角色对共享表一无所有,越界不是"会被发现",而是数据库层面做不到。
|
||||||
|
|
||||||
隔离纪律(M4 事故教训): `public.llm_calls` 是与真实批跑共享的表,而本测试跑的是
|
这条纪律取代了此前那条"跑完对比共享表行数"的安全网——行数快照守的是安全属性,却把它
|
||||||
一个**会删数据的脚本**——一律在自建的临时 schema 里操作(DSN 挂 search_path),
|
编码成对全局可变量的观测: 外部进程一写就假红,外部插入与脚本误删互相抵消则假阴。
|
||||||
teardown 只 `DROP SCHEMA ... CASCADE`;分批删除那例另行断言 `public.llm_calls`
|
权限边界两个方向都没有。
|
||||||
的行数前后不变,把"search_path 没生效"这种最坏情况钉成红灯而不是静默删库。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,10 +17,8 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from dotenv import dotenv_values
|
|
||||||
|
|
||||||
from polygateway.telemetry.schema import PG_DDL
|
from polygateway.telemetry.schema import PG_DDL
|
||||||
|
|
||||||
@@ -55,20 +54,6 @@ def _partitioned_ddl() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _dsn_value() -> str | None:
|
|
||||||
merged = {**dotenv_values(".env"), **os.environ}
|
|
||||||
raw = merged.get("PGW_TELEMETRY_PG_DSN")
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
scheme, sep, rest = raw.partition("://")
|
|
||||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
|
||||||
|
|
||||||
|
|
||||||
def _search_path_dsn(dsn: str, schema: str) -> str:
|
|
||||||
sep = "&" if "?" in dsn else "?"
|
|
||||||
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp(delta: timedelta) -> datetime:
|
def _stamp(delta: timedelta) -> datetime:
|
||||||
return datetime.now(UTC) + delta
|
return datetime.now(UTC) + delta
|
||||||
|
|
||||||
@@ -84,43 +69,6 @@ def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedP
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def dsn():
|
|
||||||
value = _dsn_value()
|
|
||||||
if value is None:
|
|
||||||
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
|
|
||||||
# 隔离守卫: 该实例有 app/chs_prod 等在用库,只许打 polygateway 专用库
|
|
||||||
if not value.rstrip("/").endswith("/polygateway"):
|
|
||||||
pytest.fail(f"保留期脚本测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
async def _make_schema(dsn_value: str, prefix: str, ddl: str, extra: tuple[str, ...] = ()) -> str:
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
name = f"pgwret_{prefix}_{uuid4().hex[:8]}"
|
|
||||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
|
||||||
try:
|
|
||||||
await conn.execute(f"CREATE SCHEMA {name}")
|
|
||||||
await conn.execute(f"SET search_path = {name}")
|
|
||||||
await conn.execute(ddl)
|
|
||||||
for statement in extra:
|
|
||||||
await conn.execute(statement)
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
async def _drop_schema(dsn_value: str, name: str) -> None:
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
|
||||||
try:
|
|
||||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
|
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
@@ -142,50 +90,31 @@ async def _call_ids(schema_dsn: str) -> list[str]:
|
|||||||
return [r["call_id"] for r in rows]
|
return [r["call_id"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
async def _public_count(dsn_value: str) -> int:
|
|
||||||
"""共享表的行数;本测试全程不得让它变动一行。"""
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
|
||||||
try:
|
|
||||||
if await conn.fetchval("SELECT to_regclass('public.llm_calls')") is None:
|
|
||||||
return -1
|
|
||||||
return await conn.fetchval("SELECT COUNT(*) FROM public.llm_calls")
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def partitioned_schema(dsn):
|
async def partitioned_sandbox(pg_sandbox):
|
||||||
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
|
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
|
||||||
name = await _make_schema(
|
return await pg_sandbox(
|
||||||
dsn,
|
ddl=_partitioned_ddl(),
|
||||||
"part",
|
|
||||||
_partitioned_ddl(),
|
|
||||||
extra=(
|
extra=(
|
||||||
"CREATE TABLE llm_calls_all PARTITION OF llm_calls "
|
"CREATE TABLE llm_calls_all PARTITION OF llm_calls "
|
||||||
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
|
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
|
||||||
),
|
),
|
||||||
|
role="owner",
|
||||||
)
|
)
|
||||||
yield _search_path_dsn(dsn, name), name
|
|
||||||
await _drop_schema(dsn, name)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def plain_schema(dsn):
|
async def plain_sandbox(pg_sandbox):
|
||||||
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
|
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
|
||||||
name = await _make_schema(dsn, "plain", PG_DDL)
|
return await pg_sandbox(ddl=PG_DDL, role="owner")
|
||||||
yield _search_path_dsn(dsn, name), name
|
|
||||||
await _drop_schema(dsn, name)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartitionedTarget:
|
class TestPartitionedTarget:
|
||||||
async def test_partitioned_table_exits_three_without_deleting_anything(
|
async def test_partitioned_table_exits_three_without_deleting_anything(
|
||||||
self, partitioned_schema
|
self, partitioned_sandbox
|
||||||
):
|
):
|
||||||
schema_dsn, schema = partitioned_schema
|
|
||||||
await _seed(
|
await _seed(
|
||||||
schema_dsn,
|
partitioned_sandbox.dsn,
|
||||||
[
|
[
|
||||||
("part-old-1", "", _stamp(timedelta(days=-30))),
|
("part-old-1", "", _stamp(timedelta(days=-30))),
|
||||||
("part-old-2", "acme", _stamp(timedelta(days=-20))),
|
("part-old-2", "acme", _stamp(timedelta(days=-20))),
|
||||||
@@ -194,24 +123,28 @@ class TestPartitionedTarget:
|
|||||||
|
|
||||||
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
|
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
|
||||||
result = _run(
|
result = _run(
|
||||||
"--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7", "--apply"
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
partitioned_sandbox.dsn,
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--apply",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.returncode == 3, (result.stdout, result.stderr)
|
assert result.returncode == 3, (result.stdout, result.stderr)
|
||||||
combined = result.stdout + result.stderr
|
combined = result.stdout + result.stderr
|
||||||
assert "DROP PARTITION" in combined
|
assert "DROP PARTITION" in combined
|
||||||
assert "DETACH" in combined
|
assert "DETACH" in combined
|
||||||
assert await _call_ids(schema_dsn) == ["part-old-1", "part-old-2"]
|
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1", "part-old-2"]
|
||||||
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
|
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
|
||||||
assert f"{schema}.llm_calls" in result.stdout
|
assert f"{partitioned_sandbox.schema}.llm_calls" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
class TestPlainTableBatches:
|
class TestPlainTableBatches:
|
||||||
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_schema, dsn):
|
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_sandbox):
|
||||||
schema_dsn, schema = plain_schema
|
|
||||||
before_public = await _public_count(dsn)
|
|
||||||
await _seed(
|
await _seed(
|
||||||
schema_dsn,
|
plain_sandbox.dsn,
|
||||||
[
|
[
|
||||||
("old-1", "", _stamp(timedelta(days=-40))),
|
("old-1", "", _stamp(timedelta(days=-40))),
|
||||||
("old-2", "acme", _stamp(timedelta(days=-30))),
|
("old-2", "acme", _stamp(timedelta(days=-30))),
|
||||||
@@ -227,7 +160,7 @@ class TestPlainTableBatches:
|
|||||||
"--backend",
|
"--backend",
|
||||||
"postgres",
|
"postgres",
|
||||||
"--dsn",
|
"--dsn",
|
||||||
schema_dsn,
|
plain_sandbox.dsn,
|
||||||
"--older-than-days",
|
"--older-than-days",
|
||||||
"7",
|
"7",
|
||||||
"--apply",
|
"--apply",
|
||||||
@@ -236,8 +169,8 @@ class TestPlainTableBatches:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||||
assert await _call_ids(schema_dsn) == ["fresh-1", "fresh-2"]
|
assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
|
||||||
assert f"{schema}.llm_calls" in result.stdout
|
assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
|
||||||
assert "将删除行数: 5" in result.stdout
|
assert "将删除行数: 5" in result.stdout
|
||||||
assert "'acme': 3" in result.stdout
|
assert "'acme': 3" in result.stdout
|
||||||
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
|
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
|
||||||
@@ -245,30 +178,200 @@ class TestPlainTableBatches:
|
|||||||
assert "批次 3" in result.stdout
|
assert "批次 3" in result.stdout
|
||||||
assert "批次 4" not in result.stdout
|
assert "批次 4" not in result.stdout
|
||||||
assert "已删除 5 行" in result.stdout
|
assert "已删除 5 行" in result.stdout
|
||||||
assert await _public_count(dsn) == before_public
|
|
||||||
|
|
||||||
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_schema):
|
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_sandbox):
|
||||||
schema_dsn, _ = plain_schema
|
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||||
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
|
||||||
|
|
||||||
result = _run("--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7")
|
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
|
||||||
|
|
||||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||||
assert "将删除行数: 1" in result.stdout
|
assert "将删除行数: 1" in result.stdout
|
||||||
assert "dry-run" in result.stdout
|
assert "dry-run" in result.stdout
|
||||||
assert await _call_ids(schema_dsn) == ["old-1"]
|
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestExplicitTable:
|
||||||
|
"""`--table SCHEMA.NAME` 的真实解析行为(issue #18 设计 §4.1;判据 1b)。
|
||||||
|
|
||||||
|
单测只能验参数分类,验不了 `to_regclass` 的语义——schema 不存在返 NULL 而非抛错、
|
||||||
|
引号限定名区分大小写、无权限落在 `COUNT` 而非解析,这三条都必须真连库才成立。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_explicit_table_deletes_exactly_like_the_implicit_path(self, plain_sandbox):
|
||||||
|
await _seed(
|
||||||
|
plain_sandbox.dsn,
|
||||||
|
[
|
||||||
|
("old-1", "", _stamp(timedelta(days=-40))),
|
||||||
|
("old-2", "acme", _stamp(timedelta(days=-30))),
|
||||||
|
("old-3", "acme", _stamp(timedelta(days=-20))),
|
||||||
|
("old-4", "acme", _stamp(timedelta(days=-15))),
|
||||||
|
("old-5", "", _stamp(timedelta(days=-10))),
|
||||||
|
("fresh-1", "acme", _stamp(timedelta(days=-1))),
|
||||||
|
("fresh-2", "", _stamp(timedelta(hours=-1))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
plain_sandbox.dsn,
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--apply",
|
||||||
|
"--batch-size",
|
||||||
|
"2",
|
||||||
|
"--table",
|
||||||
|
f"{plain_sandbox.schema}.llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 与不给 --table 的那条用例逐条同款: 显式声明只改"怎么找到表",不改任何行为
|
||||||
|
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||||
|
assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
|
||||||
|
assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
|
||||||
|
assert "将删除行数: 5" in result.stdout
|
||||||
|
assert "'acme': 3" in result.stdout
|
||||||
|
assert "批次 1" in result.stdout
|
||||||
|
assert "批次 3" in result.stdout
|
||||||
|
assert "批次 4" not in result.stdout
|
||||||
|
assert "已删除 5 行" in result.stdout
|
||||||
|
|
||||||
|
async def test_table_in_a_nonexistent_schema_exits_two(self, plain_sandbox):
|
||||||
|
"""schema 不存在时 `to_regclass` 返 NULL(不抛错),故落进既有的"目标不可用"。"""
|
||||||
|
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||||
|
missing = "pgw_s_nosuchxxxxxxxx"
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
plain_sandbox.dsn,
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--apply",
|
||||||
|
"--table",
|
||||||
|
f"{missing}.llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||||
|
assert missing in result.stderr
|
||||||
|
assert "llm_calls" in result.stderr
|
||||||
|
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
|
||||||
|
|
||||||
|
async def test_table_owned_by_another_role_exits_two(self, pg_sandbox):
|
||||||
|
"""拿 A 的连接指 B 的表: 权限拒绝,两张表都不能少一行。"""
|
||||||
|
sandbox_a = await pg_sandbox(ddl=PG_DDL, role="owner")
|
||||||
|
sandbox_b = await pg_sandbox(ddl=PG_DDL, role="owner")
|
||||||
|
await _seed(sandbox_a.dsn, [("a-old", "acme", _stamp(timedelta(days=-30)))])
|
||||||
|
await _seed(sandbox_b.dsn, [("b-old", "acme", _stamp(timedelta(days=-30)))])
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
sandbox_a.dsn,
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--apply",
|
||||||
|
"--table",
|
||||||
|
f"{sandbox_b.schema}.llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||||
|
assert await _call_ids(sandbox_a.dsn) == ["a-old"]
|
||||||
|
assert await _call_ids(sandbox_b.dsn) == ["b-old"]
|
||||||
|
|
||||||
|
async def test_explicit_partitioned_table_still_exits_three(self, partitioned_sandbox):
|
||||||
|
await _seed(partitioned_sandbox.dsn, [("part-old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
partitioned_sandbox.dsn,
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--apply",
|
||||||
|
"--table",
|
||||||
|
f"{partitioned_sandbox.schema}.llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 3, (result.stdout, result.stderr)
|
||||||
|
combined = result.stdout + result.stderr
|
||||||
|
assert "DROP PARTITION" in combined
|
||||||
|
assert "DETACH" in combined
|
||||||
|
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestInferredTargetHint:
|
||||||
|
async def test_apply_without_table_warns_that_the_target_was_inferred(self, plain_sandbox):
|
||||||
|
"""未钉死目标时必须当场说清"这张表是猜出来的"(设计 §4.4;判据 2)。
|
||||||
|
|
||||||
|
该提示行只在 PG 分支打印,不连库的单测触发不到它,故验收落在集成层。
|
||||||
|
"""
|
||||||
|
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
plain_sandbox.dsn,
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--apply",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||||
|
assert "search_path" in result.stdout
|
||||||
|
assert "--table" in result.stdout
|
||||||
|
|
||||||
|
async def test_dry_run_does_not_print_the_hint(self, plain_sandbox):
|
||||||
|
"""dry-run 不可逆性为零,它本就以"看清楚再决定"为用途,多一行提示是噪音。"""
|
||||||
|
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||||
|
|
||||||
|
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
|
||||||
|
|
||||||
|
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||||
|
assert "--table" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchPathFallsThrough:
|
||||||
|
async def test_bare_search_path_cannot_touch_the_shared_table(self, plain_sandbox):
|
||||||
|
"""最坏情况: `search_path` 没生效,脚本落到共享表 `llm_calls` 上(设计 §5.3)。
|
||||||
|
|
||||||
|
用沙箱角色的**裸** DSN 跑(search_path 回落 `"$user", public`,而角色名与 schema
|
||||||
|
名有意错开,故 `"$user"` 命不中沙箱),不给 `--table`,带 `--apply`。角色对共享表
|
||||||
|
无任何权限,于是两条可能的路都收敛到退出码 2: 库里有那张表则 `COUNT` 被权限拒绝,
|
||||||
|
没有则解析不到。**不断言 PG 的英文原文**——服务端 `lc_messages` 不由测试掌握。
|
||||||
|
"""
|
||||||
|
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
plain_sandbox.bare_dsn,
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--apply",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||||
|
assert result.stderr.strip()
|
||||||
|
assert "llm_calls" in result.stderr
|
||||||
|
# 沙箱表一行不少: 脚本既没删共享表,也没绕回来删自己这张
|
||||||
|
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
|
||||||
|
|
||||||
|
|
||||||
class TestMissingAsyncpg:
|
class TestMissingAsyncpg:
|
||||||
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_schema, tmp_path):
|
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_sandbox, tmp_path):
|
||||||
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
|
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
|
||||||
|
|
||||||
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
|
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
|
||||||
场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema,
|
场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema,
|
||||||
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。
|
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。
|
||||||
"""
|
"""
|
||||||
schema_dsn, _ = plain_schema
|
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||||
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
|
||||||
stub = tmp_path / "stub"
|
stub = tmp_path / "stub"
|
||||||
stub.mkdir()
|
stub.mkdir()
|
||||||
(stub / "asyncpg.py").write_text(
|
(stub / "asyncpg.py").write_text(
|
||||||
@@ -285,7 +388,7 @@ class TestMissingAsyncpg:
|
|||||||
"--backend",
|
"--backend",
|
||||||
"postgres",
|
"postgres",
|
||||||
"--dsn",
|
"--dsn",
|
||||||
schema_dsn,
|
plain_sandbox.dsn,
|
||||||
"--older-than-days",
|
"--older-than-days",
|
||||||
"7",
|
"7",
|
||||||
"--apply",
|
"--apply",
|
||||||
@@ -295,4 +398,4 @@ class TestMissingAsyncpg:
|
|||||||
assert result.returncode == 2, (result.stdout, result.stderr)
|
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||||
assert "asyncpg" in result.stderr
|
assert "asyncpg" in result.stderr
|
||||||
assert "pip install" in result.stderr
|
assert "pip install" in result.stderr
|
||||||
assert await _call_ids(schema_dsn) == ["old-1"]
|
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""真实测试的有限证据判定;不读取环境、不请求网络、不记录原始正文。"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from polygateway.errors import RequestRejectedError
|
||||||
|
from polygateway.types import ThinkingObservation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HttpEvidence:
|
||||||
|
"""一次 HTTP 的内存证据,禁止直接序列化。"""
|
||||||
|
|
||||||
|
call_id: str
|
||||||
|
request_checks: tuple[tuple[str, bool], ...]
|
||||||
|
status_code: int
|
||||||
|
error_body: bytes | None
|
||||||
|
raw_identity: tuple[bool, str | None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AttemptEvidence:
|
||||||
|
"""一次 transport 尝试,可以没有 HTTP。"""
|
||||||
|
|
||||||
|
call_id: str
|
||||||
|
http: tuple[HttpEvidence, ...]
|
||||||
|
error: Exception | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LiveVerdict:
|
||||||
|
"""测试命题的结论,不等同于 pytest 退出码。"""
|
||||||
|
|
||||||
|
status: Literal["PASS", "FAIL", "UNCOVERED"]
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def strict_json(body: bytes) -> Any:
|
||||||
|
"""独立解析完整 UTF-8 JSON,拒绝重复键及非标准常量。"""
|
||||||
|
|
||||||
|
def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""重复键不允许被后值掩盖。"""
|
||||||
|
result = {}
|
||||||
|
for key, value in items:
|
||||||
|
if key in result:
|
||||||
|
raise ValueError("重复 JSON 键")
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
def invalid(value: str) -> None:
|
||||||
|
"""拒绝非标准数值常量。"""
|
||||||
|
raise ValueError("非法 JSON 常量")
|
||||||
|
|
||||||
|
return json.loads(body.decode("utf-8"), object_pairs_hook=pairs, parse_constant=invalid)
|
||||||
|
|
||||||
|
|
||||||
|
def messages_digest(messages: Any) -> str:
|
||||||
|
"""只在内存比较提示词摘要,不写提示词。"""
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(messages, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def request_is_valid(event: HttpEvidence) -> bool:
|
||||||
|
"""完整且无重复的显式检查才能作为请求资格。"""
|
||||||
|
checks = dict(event.request_checks)
|
||||||
|
common = {"method", "origin", "path", "model", "authorization", "control", "messages_digest"}
|
||||||
|
return (
|
||||||
|
len(checks) == len(event.request_checks)
|
||||||
|
and set(checks) in (common | {"stream"}, common | {"input_shape"})
|
||||||
|
and all(value is True for value in checks.values())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _single_error(error: Exception, attempts: Sequence[AttemptEvidence]) -> HttpEvidence | None:
|
||||||
|
"""仅接受可与最终异常精确配对的独立单次错误。"""
|
||||||
|
if not isinstance(error, RequestRejectedError) or len(attempts) != 1:
|
||||||
|
return None
|
||||||
|
attempt = attempts[0]
|
||||||
|
if attempt.error is not error or len(attempt.http) != 1:
|
||||||
|
return None
|
||||||
|
event = attempt.http[0]
|
||||||
|
if event.call_id != attempt.call_id or not request_is_valid(event):
|
||||||
|
return None
|
||||||
|
if event.status_code != error.status_code:
|
||||||
|
return None
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def error_machine_type(event: HttpEvidence) -> str | None:
|
||||||
|
"""完整错误体中的唯一机器字段;不检查正文子串。"""
|
||||||
|
body = event.error_body
|
||||||
|
if body is None or not 0 < len(body) <= 65536:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = strict_json(body)
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("error"), dict):
|
||||||
|
return None
|
||||||
|
value = data["error"].get("type")
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_live_failure(error: Exception, attempts: Sequence[AttemptEvidence]) -> LiveVerdict:
|
||||||
|
"""默认失败;唯一自动外因是完整证据支持的 404 model_not_found。"""
|
||||||
|
event = _single_error(error, attempts)
|
||||||
|
if (
|
||||||
|
event is not None
|
||||||
|
and event.status_code == 404
|
||||||
|
and error_machine_type(event) == "model_not_found"
|
||||||
|
):
|
||||||
|
return LiveVerdict("UNCOVERED", "端点回报该请求型号不可用")
|
||||||
|
return LiveVerdict("FAIL", "无满足窄外因契约的完整独立证据")
|
||||||
|
|
||||||
|
|
||||||
|
def assess_expected_rejection(
|
||||||
|
error: Exception, attempts: Sequence[AttemptEvidence], *, status_code: int, machine_type: str
|
||||||
|
) -> LiveVerdict:
|
||||||
|
"""预先声明的拒绝命题,不把一般 400 当不支持档位。"""
|
||||||
|
event = _single_error(error, attempts)
|
||||||
|
if (
|
||||||
|
event is not None
|
||||||
|
and event.status_code == status_code
|
||||||
|
and error_machine_type(event) == machine_type
|
||||||
|
):
|
||||||
|
return LiveVerdict("PASS", "符合预声明的拒绝类型、状态和机器字段")
|
||||||
|
return LiveVerdict("FAIL", "不符合预声明拒绝证据")
|
||||||
|
|
||||||
|
|
||||||
|
def assess_model_identity(
|
||||||
|
*,
|
||||||
|
requested: str,
|
||||||
|
aliases: frozenset[str],
|
||||||
|
reported: str | None,
|
||||||
|
raw_identity: tuple[bool, str | None],
|
||||||
|
request_valid: bool,
|
||||||
|
) -> LiveVerdict:
|
||||||
|
"""公共身份异常只有原始独立证据才能归因上游。"""
|
||||||
|
if not request_valid:
|
||||||
|
return LiveVerdict("FAIL", "实发请求校验不完整或不符")
|
||||||
|
allowed = {requested, *aliases}
|
||||||
|
captured, raw = raw_identity
|
||||||
|
if captured and raw != reported:
|
||||||
|
return LiveVerdict("FAIL", "公共身份与独立原始身份不一致")
|
||||||
|
if reported in allowed:
|
||||||
|
return LiveVerdict("PASS", "身份合格")
|
||||||
|
if not captured:
|
||||||
|
return LiveVerdict("FAIL", "身份来源无法区分")
|
||||||
|
return LiveVerdict("UNCOVERED", "独立原始响应身份缺失或不属于显式别名")
|
||||||
|
|
||||||
|
|
||||||
|
def assess_thinking_coverage(
|
||||||
|
observations: Sequence[ThinkingObservation],
|
||||||
|
*,
|
||||||
|
planned_rounds: int,
|
||||||
|
proposition: Literal["enabled", "disabled", "cannot_disable"],
|
||||||
|
) -> LiveVerdict:
|
||||||
|
"""按独立命题裁定;缺轮不减分母,UNKNOWN 不证明关闭。"""
|
||||||
|
if planned_rounds < 1 or len(observations) != planned_rounds:
|
||||||
|
return LiveVerdict("FAIL", "计划轮次不完整")
|
||||||
|
if any(not isinstance(item, ThinkingObservation) for item in observations):
|
||||||
|
return LiveVerdict("FAIL", "观测类型不符")
|
||||||
|
observed = observations.count(ThinkingObservation.OBSERVED)
|
||||||
|
unknown = observations.count(ThinkingObservation.UNKNOWN)
|
||||||
|
if proposition == "enabled":
|
||||||
|
if observed > planned_rounds / 2:
|
||||||
|
return LiveVerdict("PASS", "完整轮次多数观测到推理")
|
||||||
|
return LiveVerdict("UNCOVERED" if unknown else "FAIL", "开启证据未达多数")
|
||||||
|
if proposition == "disabled":
|
||||||
|
if observed:
|
||||||
|
return LiveVerdict("FAIL", "观测到推理,证伪关闭声明")
|
||||||
|
return LiveVerdict(
|
||||||
|
"UNCOVERED" if unknown else "PASS",
|
||||||
|
"UNKNOWN 不证明关闭" if unknown else "每轮明确 ABSENT",
|
||||||
|
)
|
||||||
|
if proposition == "cannot_disable":
|
||||||
|
if observed:
|
||||||
|
return LiveVerdict("PASS", "本条件下仍推理;不外推所有私有参数")
|
||||||
|
return LiveVerdict("UNCOVERED" if unknown else "FAIL", "缺少仍推理的证据")
|
||||||
|
raise ValueError("未知测试命题")
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_verdicts(verdicts: Sequence[LiveVerdict], *, planned_rounds: int) -> dict[str, int]:
|
||||||
|
"""保留失败、未覆盖与缺轮的独立计数。"""
|
||||||
|
if planned_rounds < len(verdicts):
|
||||||
|
raise ValueError("实际轮次超出计划")
|
||||||
|
return {
|
||||||
|
**{
|
||||||
|
status: sum(v.status == status for v in verdicts)
|
||||||
|
for status in ("PASS", "FAIL", "UNCOVERED")
|
||||||
|
},
|
||||||
|
"missing": planned_rounds - len(verdicts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_SAFE_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"provider",
|
||||||
|
"requested_model",
|
||||||
|
"reported_model",
|
||||||
|
"stream",
|
||||||
|
"requested_effort",
|
||||||
|
"applied_effort",
|
||||||
|
"session_id",
|
||||||
|
"parent_call_id",
|
||||||
|
"attempts",
|
||||||
|
"status",
|
||||||
|
"reason",
|
||||||
|
"completed_rounds",
|
||||||
|
"planned_rounds",
|
||||||
|
"thinking_observation",
|
||||||
|
"prompt_tokens",
|
||||||
|
"completion_tokens",
|
||||||
|
"reasoning_tokens",
|
||||||
|
"thinking_chars",
|
||||||
|
"counts",
|
||||||
|
"proposition",
|
||||||
|
"error_type",
|
||||||
|
"error_status",
|
||||||
|
"evidence_notes",
|
||||||
|
"subruns",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_ATTEMPT_FIELDS = frozenset({"call_id", "error_type", "http"})
|
||||||
|
_HTTP_FIELDS = frozenset(
|
||||||
|
{"status_code", "request_checks", "identity_captured", "error_body_complete", "machine_type"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_safe(fields: Mapping[str, Any]) -> None:
|
||||||
|
"""拒绝原始异常和证据对象,嵌套字段也有白名单。"""
|
||||||
|
if set(fields) - _SAFE_FIELDS:
|
||||||
|
raise ValueError("报告含非白名单字段")
|
||||||
|
for attempt in fields.get("attempts", []):
|
||||||
|
if not isinstance(attempt, dict) or set(attempt) != _ATTEMPT_FIELDS:
|
||||||
|
raise ValueError("非法 attempt 报告")
|
||||||
|
for event in attempt["http"]:
|
||||||
|
if not isinstance(event, dict) or set(event) != _HTTP_FIELDS:
|
||||||
|
raise ValueError("非法 HTTP 报告")
|
||||||
|
if event["machine_type"] not in ("model_not_found", "omitted"):
|
||||||
|
raise ValueError("报告机器字段不是认可枚举")
|
||||||
|
if not isinstance(event["request_checks"], dict) or any(
|
||||||
|
type(v) is not bool for v in event["request_checks"].values()
|
||||||
|
):
|
||||||
|
raise ValueError("请求校验报告只允许布尔值")
|
||||||
|
# 不提供 default=str:原始异常、bytes、dataclass 均必须失败。
|
||||||
|
json.dumps(fields, ensure_ascii=False, allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
def write_live_round(
|
||||||
|
output_dir: Path,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
matrix_id: str,
|
||||||
|
round_index: int,
|
||||||
|
safe_fields: Mapping[str, Any],
|
||||||
|
) -> Path:
|
||||||
|
"""仅接收已脱敏字段;独占文件写入失败必须冒泡。"""
|
||||||
|
_validate_safe(safe_fields)
|
||||||
|
if round_index < 0 or not re.fullmatch(r"[a-zA-Z0-9_-]+", run_id + matrix_id):
|
||||||
|
raise ValueError("报告路径标识非法")
|
||||||
|
directory = output_dir / run_id
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = directory / f"{matrix_id}-{round_index}-{uuid4().hex}.md"
|
||||||
|
text = json.dumps(dict(safe_fields), ensure_ascii=False, indent=2, allow_nan=False)
|
||||||
|
with path.open("x", encoding="utf-8") as handle:
|
||||||
|
handle.write(f"# {matrix_id} · 轮次 {round_index}\n\n```json\n{text}\n```\n")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def safe_attempts(attempts: Sequence[AttemptEvidence]) -> list[dict[str, Any]]:
|
||||||
|
"""只导出事实、异常类与认可机器枚举;任意上游字符串一律省略。"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"call_id": attempt.call_id,
|
||||||
|
"error_type": type(attempt.error).__name__ if attempt.error else None,
|
||||||
|
"http": [
|
||||||
|
{
|
||||||
|
"status_code": event.status_code,
|
||||||
|
"request_checks": dict(event.request_checks),
|
||||||
|
"identity_captured": event.raw_identity[0],
|
||||||
|
"error_body_complete": event.error_body is not None,
|
||||||
|
"machine_type": (
|
||||||
|
"model_not_found"
|
||||||
|
if error_machine_type(event) == "model_not_found"
|
||||||
|
else "omitted"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for event in attempt.http
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for attempt in attempts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def combine_live_verdicts(verdicts: Sequence[LiveVerdict]) -> LiveVerdict:
|
||||||
|
"""部分失败优先于未覆盖;部分未覆盖不得汇总全 PASS。"""
|
||||||
|
if any(verdict.status == "FAIL" for verdict in verdicts):
|
||||||
|
return LiveVerdict("FAIL", "至少一个必需命题失败")
|
||||||
|
if not verdicts or any(verdict.status == "UNCOVERED" for verdict in verdicts):
|
||||||
|
return LiveVerdict("UNCOVERED", "至少一个必需命题未覆盖")
|
||||||
|
return LiveVerdict("PASS", "所有必需命题通过")
|
||||||
|
|
||||||
|
|
||||||
|
def qualify_live_rounds(verdicts: Sequence[LiveVerdict], *, planned_rounds: int) -> LiveVerdict:
|
||||||
|
"""汇总请求/身份资格,缺轮绝不缩小分母。"""
|
||||||
|
if planned_rounds < 1 or len(verdicts) != planned_rounds:
|
||||||
|
return LiveVerdict("FAIL", "计划轮次缺失")
|
||||||
|
return combine_live_verdicts(verdicts)
|
||||||
@@ -210,7 +210,7 @@ class ClockAdvancingTransport:
|
|||||||
self.clock = clock
|
self.clock = clock
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
async def complete(self, *, messages, source, stream, overlay, call_id):
|
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
||||||
self.calls.append((source.name, call_id))
|
self.calls.append((source.name, call_id))
|
||||||
advance, action = self.script.pop(0)
|
advance, action = self.script.pop(0)
|
||||||
self.clock.advance(advance)
|
self.clock.advance(advance)
|
||||||
|
|||||||
+517
-2
@@ -5,12 +5,19 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.backends.memory.cache import InMemoryCache
|
from polygateway.backends.memory.cache import InMemoryCache
|
||||||
from polygateway.errors import ResultInvalidError, TransientError
|
from polygateway.errors import ResultInvalidError, TransientError
|
||||||
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
|
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
from polygateway.types import (
|
||||||
|
ChatRequest,
|
||||||
|
Effort,
|
||||||
|
LLMResponse,
|
||||||
|
SourceConfig,
|
||||||
|
ThinkingObservation,
|
||||||
|
)
|
||||||
|
|
||||||
_MSGS = [{"role": "user", "content": "hi"}]
|
_MSGS = [{"role": "user", "content": "hi"}]
|
||||||
|
|
||||||
@@ -113,6 +120,62 @@ class TestKeyFormula:
|
|||||||
"m", messages2, "p", None
|
"m", messages2, "p", None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_request_tier_changes_key(self):
|
||||||
|
"""同 messages 跑 low 与 max 不得互相命中(issue #20;issue #4 的逐字翻版)。
|
||||||
|
|
||||||
|
请求级档位必须**独立于** `model_fingerprint` 进 key: 后者是装配期算出的
|
||||||
|
集合级指纹,一次调用改档位不会让它变一个字节。
|
||||||
|
"""
|
||||||
|
k_low = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.LOW)
|
||||||
|
k_max = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.MAX)
|
||||||
|
assert k_low != k_max
|
||||||
|
|
||||||
|
def test_explicit_none_tier_is_not_the_absent_tier(self):
|
||||||
|
"""`None`(不表态)与 `Effort.NONE`(要求不推理)是两个 key。
|
||||||
|
|
||||||
|
二者合并即毒化: "没写档位"的调用会读到"明确关掉推理"那次的响应,
|
||||||
|
而后者的内容恰恰是缺推理过程的。
|
||||||
|
"""
|
||||||
|
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
|
||||||
|
"m", _MSGS, "proj", None, reasoning_effort=Effort.NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_absent_tier_keeps_legacy_key(self):
|
||||||
|
"""不表态档位时键形逐字不变,存量缓存不被本次升级全量作废。
|
||||||
|
|
||||||
|
golden 值与 `test_empty_sampling_keeps_legacy_key` 同源,取自加
|
||||||
|
`reasoning_effort` 维度之前的实现,不得随实现漂移。
|
||||||
|
"""
|
||||||
|
assert build_cache_key(
|
||||||
|
"qwen-max",
|
||||||
|
[{"role": "user", "content": "hi"}],
|
||||||
|
"proj",
|
||||||
|
None,
|
||||||
|
reasoning_effort=None,
|
||||||
|
) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b")
|
||||||
|
|
||||||
|
def test_declared_tier_key_is_a_golden(self):
|
||||||
|
"""配了档位那一侧同样要有 golden: 字面量变了就是所有该档缓存冷启动。
|
||||||
|
|
||||||
|
存量(不表态)那侧的 golden 由 `test_absent_tier_keeps_legacy_key` 守着,
|
||||||
|
而"档位怎么写进 key"此前没有任何字面量断言——变异实测把 `str(...)` 换成
|
||||||
|
`repr(...)`,全套件依然全绿(2026-09-05 独立验证查出)。
|
||||||
|
"""
|
||||||
|
assert build_cache_key(
|
||||||
|
"qwen-max",
|
||||||
|
[{"role": "user", "content": "hi"}],
|
||||||
|
"proj",
|
||||||
|
None,
|
||||||
|
reasoning_effort=Effort.LOW,
|
||||||
|
) == ("pgw:cache:21d7be93729635b27d4ee54e0e7e7310554bb04faf08919f8ce83794ac33f575")
|
||||||
|
assert build_cache_key(
|
||||||
|
"qwen-max",
|
||||||
|
[{"role": "user", "content": "hi"}],
|
||||||
|
"proj",
|
||||||
|
"s1",
|
||||||
|
reasoning_effort=Effort.NONE,
|
||||||
|
) == ("pgw:cache:44f4f1ce1ee39e5003a27f4f21a531554cb54d1c66e936d11008d4ff06ddc5e6")
|
||||||
|
|
||||||
|
|
||||||
class _Terminal:
|
class _Terminal:
|
||||||
def __init__(self, response):
|
def __init__(self, response):
|
||||||
@@ -162,6 +225,21 @@ class TestCacheFlow:
|
|||||||
third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
||||||
assert third.cache_hit is True and terminal.calls == 2
|
assert third.cache_hit is True and terminal.calls == 2
|
||||||
|
|
||||||
|
async def test_differing_reasoning_effort_does_not_hit(self):
|
||||||
|
"""接线门: `CacheMW` 必须把 `request.reasoning_effort` 传进 key 公式。
|
||||||
|
|
||||||
|
只测 `build_cache_key` 不够——参数加了却没人传是本改动最可能的落地方式,
|
||||||
|
那种缺口在公式层的用例里完全看不见。
|
||||||
|
"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
|
||||||
|
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.MAX), terminal)
|
||||||
|
assert terminal.calls == 2 # 两档各自回源
|
||||||
|
third = await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
|
||||||
|
assert third.cache_hit is True and terminal.calls == 2 # 同档才命中
|
||||||
|
|
||||||
async def test_structured_injection_does_not_pollute_key(self):
|
async def test_structured_injection_does_not_pollute_key(self):
|
||||||
"""CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。"""
|
"""CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。"""
|
||||||
backend = InMemoryCache()
|
backend = InMemoryCache()
|
||||||
@@ -249,6 +327,143 @@ class TestObservabilityFieldsOnHit:
|
|||||||
assert hit.cached_prompt_tokens is None and hit.model_reported is None
|
assert hit.cached_prompt_tokens is None and hit.model_reported is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingObservationRehydration:
|
||||||
|
"""issue #16/#17: 命中回放必须复活成枚举实例,而不是 JSON 里的裸 str。
|
||||||
|
|
||||||
|
裸 str 与字段注解分叉,下游拿 `resp.thinking_observation is
|
||||||
|
ThinkingObservation.OBSERVED` 判等会在缓存命中路径上静默为 False。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_hit_replays_enum_instance_not_bare_str(self):
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(_resp(thinking_observation=ThinkingObservation.OBSERVED))
|
||||||
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.cache_hit is True and terminal.calls == 1
|
||||||
|
assert isinstance(hit.thinking_observation, ThinkingObservation)
|
||||||
|
assert hit.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
async def test_unknown_value_degrades_to_unknown_and_still_hits(self):
|
||||||
|
"""域外取值降级为 UNKNOWN,内容照常复活——不得因此作废整条缓存。
|
||||||
|
|
||||||
|
真实场景: 三项目共用一个 Redis,先升级的项目写入了本版没有的第四态,
|
||||||
|
未升级的两个项目若把它判成未命中,就会在这些 key 上每次真打网关、随后
|
||||||
|
覆写回旧值,两个版本互相打对方的缓存(表现是命中率莫名腰斩)。一个纯
|
||||||
|
可观测性字段不该有能力废掉内容完好的缓存响应。
|
||||||
|
"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
poisoned = dataclasses.asdict(_resp(content="from-a-newer-version"))
|
||||||
|
poisoned["thinking_observation"] = "partially_observed"
|
||||||
|
poisoned.pop("structured_data", None)
|
||||||
|
await backend.set(key, json.dumps(poisoned), 3600)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert terminal.calls == 0 and resp.cache_hit is True
|
||||||
|
assert resp.content == "from-a-newer-version" # 内容完好,照常复活
|
||||||
|
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
# 单独一条讲清原因的 warning: 通用的"重建失败"没有任何线索指向真因
|
||||||
|
hits = [m for m in messages if "partially_observed" in m]
|
||||||
|
assert len(hits) == 1, f"域外取值必须单独告警一次,实得 {len(hits)} 条: {messages}"
|
||||||
|
assert "thinking_observation" in hits[0]
|
||||||
|
assert [m for m in messages if "重建失败" in m] == []
|
||||||
|
|
||||||
|
async def test_a_broken_payload_still_falls_back_to_source(self):
|
||||||
|
"""对照组: 内容完整性真被破坏时,仍必须按未命中回源(降级方向不变)。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
await backend.set(key, "{not json at all", 3600)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert terminal.calls == 1 and resp.cache_hit is False
|
||||||
|
assert resp.content == "cached"
|
||||||
|
|
||||||
|
async def test_legacy_entry_without_key_rehydrates_to_default(self):
|
||||||
|
"""升级前写入的条目没有该键,必须照常复活并落到默认 UNKNOWN。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
legacy = dataclasses.asdict(_resp(content="legacy"))
|
||||||
|
legacy.pop("thinking_observation")
|
||||||
|
legacy.pop("structured_data", None)
|
||||||
|
await backend.set(key, json.dumps(legacy), 3600)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.content == "legacy" and terminal.calls == 0
|
||||||
|
assert hit.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppliedEffortRehydration:
|
||||||
|
"""issue #20: 实际档同样必须复活成枚举,理由与 `thinking_observation` 逐条相同。
|
||||||
|
|
||||||
|
JSON 里存的是 `StrEnum` 的字符串值;不转就复活成裸 str,而库内一路是
|
||||||
|
`is Effort.LOW` 的身份比较——命中路径上会静默判否,且下游拿到的类型与字段
|
||||||
|
注解分叉。缓存是档位的**第三条入口**(另两条是 `.env` 解析与 `chat()` 参数),
|
||||||
|
归一化不变式必须在这里也闭合。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_hit_replays_enum_instance_not_bare_str(self):
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(_resp(applied_effort=Effort.LOW))
|
||||||
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.cache_hit is True and terminal.calls == 1
|
||||||
|
assert isinstance(hit.applied_effort, Effort)
|
||||||
|
assert hit.applied_effort is Effort.LOW
|
||||||
|
|
||||||
|
async def test_unknown_tier_degrades_to_none_and_still_hits(self):
|
||||||
|
"""域外档位降级为 None(=不知道这次跑在哪档),不作废内容完好的条目。
|
||||||
|
|
||||||
|
降级方向与 `thinking_observation` 同源: 共用一个 Redis 的项目里,先升级
|
||||||
|
的那个可能写入本版没有的档位名,未升级的项目若判成未命中,两个版本就会
|
||||||
|
互相打对方的缓存。归因字段不该有能力废掉一条内容完好的响应。
|
||||||
|
"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
poisoned = dataclasses.asdict(_resp(content="from-a-newer-version"))
|
||||||
|
poisoned["applied_effort"] = "ultra"
|
||||||
|
poisoned.pop("structured_data", None)
|
||||||
|
await backend.set(key, json.dumps(poisoned), 3600)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert terminal.calls == 0 and resp.cache_hit is True
|
||||||
|
assert resp.content == "from-a-newer-version"
|
||||||
|
assert resp.applied_effort is None
|
||||||
|
hits = [m for m in messages if "ultra" in m]
|
||||||
|
assert len(hits) == 1, f"域外档位必须单独告警一次,实得 {len(hits)} 条: {messages}"
|
||||||
|
assert "applied_effort" in hits[0]
|
||||||
|
assert [m for m in messages if "重建失败" in m] == []
|
||||||
|
|
||||||
|
async def test_legacy_entry_without_key_rehydrates_to_none(self):
|
||||||
|
"""升级前写入的条目没有该键,必须照常复活并落到默认 None。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
legacy = dataclasses.asdict(_resp(content="legacy"))
|
||||||
|
legacy.pop("applied_effort")
|
||||||
|
legacy.pop("structured_data", None)
|
||||||
|
await backend.set(key, json.dumps(legacy), 3600)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.content == "legacy" and terminal.calls == 0
|
||||||
|
assert hit.applied_effort is None
|
||||||
|
|
||||||
|
|
||||||
class _BrokenBackend:
|
class _BrokenBackend:
|
||||||
async def get(self, key):
|
async def get(self, key):
|
||||||
raise ConnectionError("redis down")
|
raise ConnectionError("redis down")
|
||||||
@@ -355,7 +570,7 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
|
|||||||
before = build_cache_key("m", messages, "proj", None)
|
before = build_cache_key("m", messages, "proj", None)
|
||||||
|
|
||||||
rec = self._Rows()
|
rec = self._Rows()
|
||||||
await TelemetryEmitter(rec, text_cap=8).emit_attempt(
|
await TelemetryEmitter(rec, text_cap=8, scope="LLM").emit_attempt(
|
||||||
request=ChatRequest(messages=messages),
|
request=ChatRequest(messages=messages),
|
||||||
source=SourceConfig(
|
source=SourceConfig(
|
||||||
name="s1",
|
name="s1",
|
||||||
@@ -369,6 +584,8 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
|
|||||||
latency_ms=1,
|
latency_ms=1,
|
||||||
response=_resp(),
|
response=_resp(),
|
||||||
error=None,
|
error=None,
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
# 截断确实发生了(否则本用例恒真)
|
# 截断确实发生了(否则本用例恒真)
|
||||||
logged = json.loads(rec.rows[0]["messages"])
|
logged = json.loads(rec.rows[0]["messages"])
|
||||||
@@ -376,3 +593,301 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
|
|||||||
assert "(略 112 字)" in logged[1]["content"][0]["text"]
|
assert "(略 112 字)" in logged[1]["content"][0]["text"]
|
||||||
|
|
||||||
assert build_cache_key("m", messages, "proj", None) == before
|
assert build_cache_key("m", messages, "proj", None) == before
|
||||||
|
|
||||||
|
|
||||||
|
class TestExplicitCacheMigration:
|
||||||
|
"""相同模型身份不代表相同推理策略,隔离必须由调用方显式选择。"""
|
||||||
|
|
||||||
|
def _client(self, cache, source, *, capabilities=None, registry=None):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
|
from tests.unit.test_client import _client, _sse
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
sent.append(payload)
|
||||||
|
return _sse(json.dumps(payload, sort_keys=True))
|
||||||
|
|
||||||
|
transport = OpenAICompatTransport(
|
||||||
|
client_factory=lambda src: httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||||
|
capabilities=capabilities,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
client = _client(
|
||||||
|
sources=[source],
|
||||||
|
transport=transport,
|
||||||
|
cache=cache,
|
||||||
|
cache_namespace="tenant-a",
|
||||||
|
cache_ttl_s=60,
|
||||||
|
)
|
||||||
|
return client, transport, sent
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
|
||||||
|
@pytest.mark.parametrize("change", ["capability", "fallback"])
|
||||||
|
async def test_capability_change_requires_explicit_identity(self, isolation, change):
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
from polygateway.errors import RequestRejectedError
|
||||||
|
from polygateway.thinking import ThinkingCapability
|
||||||
|
from tests.unit.test_client import _source
|
||||||
|
|
||||||
|
cache = InMemoryCache()
|
||||||
|
source = _source(provider="openai", model="migration-model")
|
||||||
|
if change == "capability":
|
||||||
|
old_cap = ThinkingCapability((Effort.AUTO, Effort.HIGH), "本地旧声明")
|
||||||
|
new_cap = ThinkingCapability((Effort.HIGH,), "本地新声明")
|
||||||
|
tier = Effort.AUTO
|
||||||
|
new_source = source
|
||||||
|
else:
|
||||||
|
old_cap = new_cap = ThinkingCapability((Effort.LOW, Effort.HIGH), "本地映射声明")
|
||||||
|
source = dataclasses.replace(source, effort_fallback="nearest")
|
||||||
|
new_source = dataclasses.replace(source, effort_fallback="error")
|
||||||
|
tier = Effort.MEDIUM
|
||||||
|
assert build_model_fingerprint([source]) == build_model_fingerprint([new_source])
|
||||||
|
old, old_transport, old_sent = self._client(
|
||||||
|
cache, source, capabilities={source.model: old_cap}
|
||||||
|
)
|
||||||
|
new, new_transport, new_sent = self._client(
|
||||||
|
cache, new_source, capabilities={source.model: new_cap}
|
||||||
|
)
|
||||||
|
identity = (
|
||||||
|
{"cache_namespace": "tenant-a:migrated"}
|
||||||
|
if isolation == "namespace"
|
||||||
|
else {"cache_salt": "migrated"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
original = await old.chat(_MSGS, reasoning_effort=tier)
|
||||||
|
replay = await new.chat(_MSGS, reasoning_effort=tier)
|
||||||
|
assert replay.cache_hit and replay.content == original.content
|
||||||
|
assert len(old_sent) == 1 and not new_sent
|
||||||
|
with pytest.raises(RequestRejectedError):
|
||||||
|
await new.chat(_MSGS, reasoning_effort=tier, **identity)
|
||||||
|
assert not new_sent
|
||||||
|
assert (await old.chat(_MSGS, reasoning_effort=tier)).cache_hit
|
||||||
|
finally:
|
||||||
|
await old_transport.aclose()
|
||||||
|
await new_transport.aclose()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
|
||||||
|
async def test_custom_wire_change_requires_explicit_identity(self, isolation):
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
from polygateway.providers import ProviderProfile, ThinkingWire
|
||||||
|
from polygateway.thinking import ThinkingCapability
|
||||||
|
from tests.unit.test_client import _source
|
||||||
|
|
||||||
|
source = _source(provider="custom", model="migration-model")
|
||||||
|
caps = {source.model: ThinkingCapability((Effort.HIGH,), "本地声明")}
|
||||||
|
|
||||||
|
def profile(key):
|
||||||
|
return {
|
||||||
|
"custom": ProviderProfile(
|
||||||
|
name="custom",
|
||||||
|
thinking=ThinkingWire(off=None, on_base={}, effort_key=key),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = InMemoryCache()
|
||||||
|
old, t1, sent1 = self._client(cache, source, capabilities=caps, registry=profile("depth_a"))
|
||||||
|
new, t2, sent2 = self._client(cache, source, capabilities=caps, registry=profile("depth_b"))
|
||||||
|
assert build_model_fingerprint(old._terminal._sources) == build_model_fingerprint(
|
||||||
|
new._terminal._sources
|
||||||
|
)
|
||||||
|
identity = (
|
||||||
|
{"cache_namespace": "tenant-a:migrated"}
|
||||||
|
if isolation == "namespace"
|
||||||
|
else {"cache_salt": "migrated"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
original = await old.chat(_MSGS, reasoning_effort=Effort.HIGH)
|
||||||
|
assert (await new.chat(_MSGS, reasoning_effort=Effort.HIGH)).cache_hit
|
||||||
|
migrated = await new.chat(_MSGS, reasoning_effort=Effort.HIGH, **identity)
|
||||||
|
assert not migrated.cache_hit and migrated.content != original.content
|
||||||
|
assert len(sent1) == len(sent2) == 1
|
||||||
|
assert sent2[0]["depth_b"] == "high" and "depth_a" not in sent2[0]
|
||||||
|
assert (await old.chat(_MSGS, reasoning_effort=Effort.HIGH)).content == original.content
|
||||||
|
finally:
|
||||||
|
await t1.aclose()
|
||||||
|
await t2.aclose()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
|
||||||
|
async def test_legacy_raw_override_requires_explicit_identity(self, isolation):
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
from polygateway.errors import RequestRejectedError
|
||||||
|
from tests.unit.test_client import _source
|
||||||
|
|
||||||
|
source = _source(
|
||||||
|
provider="openai", reasoning_effort="high", extra_body={"reasoning_effort": "low"}
|
||||||
|
)
|
||||||
|
cache = InMemoryCache()
|
||||||
|
key = build_cache_key(build_model_fingerprint([source]), _MSGS, "tenant-a", None)
|
||||||
|
legacy = dataclasses.asdict(_resp(content="legacy-raw-low", applied_effort=Effort.HIGH))
|
||||||
|
legacy.pop("structured_data", None)
|
||||||
|
await cache.set(key, json.dumps(legacy), 60)
|
||||||
|
client, transport, sent = self._client(cache, source)
|
||||||
|
identity = (
|
||||||
|
{"cache_namespace": "tenant-a:migrated"}
|
||||||
|
if isolation == "namespace"
|
||||||
|
else {"cache_salt": "migrated"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert (await client.chat(_MSGS)).content == "legacy-raw-low"
|
||||||
|
with pytest.raises(RequestRejectedError, match="冲突"):
|
||||||
|
await client.chat(_MSGS, **identity)
|
||||||
|
assert sent == []
|
||||||
|
assert await cache.get(key) is not None
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
async def test_per_call_namespace_survives_a_changed_factory_default(self):
|
||||||
|
from polygateway import GatewayClient, GatewaySettings
|
||||||
|
from tests.unit.test_client import _ENV, _source
|
||||||
|
|
||||||
|
cache = InMemoryCache()
|
||||||
|
source = _source()
|
||||||
|
old, transport, sent = self._client(cache, source)
|
||||||
|
try:
|
||||||
|
await old.chat(_MSGS, cache_namespace="tenant-a")
|
||||||
|
# 工厂路径和全量注入配置同模型身份;只改默认不能改变显式租户覆盖。
|
||||||
|
settings = GatewaySettings.from_env(
|
||||||
|
env={
|
||||||
|
**_ENV,
|
||||||
|
"PGW_CACHE_BACKEND": "memory",
|
||||||
|
"PGW_CACHE_NAMESPACE": "changed-default",
|
||||||
|
"PGW_CACHE_TTL_S": "60",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
new = GatewayClient.from_settings(settings, cache=cache)
|
||||||
|
try:
|
||||||
|
assert (await new.chat(_MSGS, cache_namespace="tenant-a")).cache_hit
|
||||||
|
assert len(sent) == 1
|
||||||
|
finally:
|
||||||
|
await new.aclose()
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
async def test_shared_source_pool_migration_preserves_tenant_boundaries(self):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from polygateway.errors import RequestRejectedError
|
||||||
|
from polygateway.thinking import ThinkingCapability
|
||||||
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
|
from tests.unit.test_client import _client, _source, _sse
|
||||||
|
|
||||||
|
sources = [
|
||||||
|
_source(name=name, provider="openai", model="shared-model") for name in ("a", "b")
|
||||||
|
]
|
||||||
|
cache = InMemoryCache()
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
sent.append(request)
|
||||||
|
return _sse()
|
||||||
|
|
||||||
|
transports = [
|
||||||
|
OpenAICompatTransport(
|
||||||
|
client_factory=lambda src: httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(handler)
|
||||||
|
),
|
||||||
|
capabilities={"shared-model": ThinkingCapability(choices, "本地声明")},
|
||||||
|
)
|
||||||
|
for choices in ((Effort.AUTO, Effort.HIGH), (Effort.HIGH,))
|
||||||
|
]
|
||||||
|
clients = [
|
||||||
|
_client(
|
||||||
|
sources=sources, transport=t, cache=cache, cache_namespace="default", cache_ttl_s=60
|
||||||
|
)
|
||||||
|
for t in transports
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for tenant in ("tenant-a", "tenant-b"):
|
||||||
|
await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
|
||||||
|
assert (
|
||||||
|
await clients[1].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
|
||||||
|
).cache_hit
|
||||||
|
with pytest.raises(RequestRejectedError):
|
||||||
|
await clients[1].chat(
|
||||||
|
_MSGS, reasoning_effort="auto", cache_namespace=tenant + ":new"
|
||||||
|
)
|
||||||
|
assert len(sent) == 2
|
||||||
|
for tenant in ("tenant-a", "tenant-b"):
|
||||||
|
assert (
|
||||||
|
await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
|
||||||
|
).cache_hit
|
||||||
|
finally:
|
||||||
|
for transport in transports:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallStatsNotPoisoned:
|
||||||
|
"""缓存不得回放历史统计(1.3.5 设计 §3)。
|
||||||
|
|
||||||
|
统计描述**本次**调用;把上次那条存进去再放出来,等于对调用方谎称这次
|
||||||
|
重试了 N 次、耗了 M 毫秒。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_serialized_payload_carries_no_call_stats_key(self):
|
||||||
|
from polygateway.types import CallStats
|
||||||
|
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
stats = CallStats(logical_call_id="lc-1", attempts=3, total_latency_ms=900)
|
||||||
|
terminal = _Terminal(_resp(call_stats=stats))
|
||||||
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
stored = json.loads(await backend.get(key))
|
||||||
|
assert "call_stats" not in stored # asdict 会把它摊成 dict,必须显式剔除
|
||||||
|
|
||||||
|
async def test_historic_dict_never_impersonates_call_stats(self):
|
||||||
|
"""旧条目里的 `call_stats` dict 会被 `_RESPONSE_FIELDS` 放行,必须显式覆盖。
|
||||||
|
|
||||||
|
不覆盖就会有一个 dict 冒充 `CallStats` 从公共 API 漏给调用方,
|
||||||
|
`resp.call_stats.attempts` 当场 `AttributeError`。
|
||||||
|
"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
poisoned = {
|
||||||
|
"content": "legacy",
|
||||||
|
"thinking": "",
|
||||||
|
"model": "m",
|
||||||
|
"provider": "p",
|
||||||
|
"prompt_tokens": 1,
|
||||||
|
"completion_tokens": 2,
|
||||||
|
"latency_ms": 30,
|
||||||
|
"ttft_ms": 5.0,
|
||||||
|
"max_inter_token_ms": 2.0,
|
||||||
|
"cache_hit": False,
|
||||||
|
"call_id": "orig",
|
||||||
|
"source_name": "s1",
|
||||||
|
"usage_source": "measured",
|
||||||
|
"call_stats": {
|
||||||
|
"logical_call_id": "stale-lc",
|
||||||
|
"attempts": 7,
|
||||||
|
"total_latency_ms": 9999,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await backend.set(key, json.dumps(poisoned), ttl_s=100)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.content == "legacy" and terminal.calls == 0 # 真的走了缓存
|
||||||
|
assert hit.call_stats is None # dict 不得冒充 CallStats
|
||||||
|
|
||||||
|
async def test_cache_key_is_unchanged_by_the_new_field(self):
|
||||||
|
"""新增内部字段不得扰动 key 公式,否则存量缓存全量冷启动(黄金值)。"""
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __call__(self):
|
||||||
|
return 1000.0
|
||||||
|
|
||||||
|
ctx = _CallContext(now=_Clock())
|
||||||
|
bare = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
assert bare == build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
# 带上下文的请求与不带的请求必须落在同一个 key 上
|
||||||
|
with_ctx = ChatRequest(messages=_MSGS, call_context=ctx)
|
||||||
|
without = ChatRequest(messages=_MSGS)
|
||||||
|
assert with_ctx.cache_namespace == without.cache_namespace
|
||||||
|
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
|
||||||
|
|||||||
+1176
-14
File diff suppressed because it is too large
Load Diff
+254
-3
@@ -8,6 +8,8 @@ from loguru import logger
|
|||||||
|
|
||||||
from polygateway.client import GatewayClient
|
from polygateway.client import GatewayClient
|
||||||
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
||||||
|
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
|
||||||
|
from polygateway.types import Effort, SourceConfig
|
||||||
|
|
||||||
_BASE_ENV = {
|
_BASE_ENV = {
|
||||||
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
||||||
@@ -132,6 +134,113 @@ class TestExtraBodyParsing:
|
|||||||
GatewaySettings.from_env("LLM", env=env)
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasoningEffortParsing:
|
||||||
|
"""源级推理档位两个键的 env 解析(issue #20 Task 4)。"""
|
||||||
|
|
||||||
|
def test_effort_key_parsed(self):
|
||||||
|
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "low"})
|
||||||
|
s = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
assert s.sources[0].reasoning_effort is Effort.LOW
|
||||||
|
|
||||||
|
def test_absent_keys_keep_the_source_silent(self):
|
||||||
|
"""未配置 = 不表态,与 `Effort.NONE`(要求不推理)是两回事;映射默认关闭。"""
|
||||||
|
src = GatewaySettings.from_env("LLM", env=_env()).sources[0]
|
||||||
|
assert src.reasoning_effort is None
|
||||||
|
assert src.effort_fallback == "error"
|
||||||
|
|
||||||
|
def test_invalid_effort_lists_vocabulary(self):
|
||||||
|
"""写错档位的人要的是"那该填什么",故报错必须把八档全摆出来。"""
|
||||||
|
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "lowest"})
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
message = str(exc.value)
|
||||||
|
assert "REASONING_EFFORT" in message
|
||||||
|
assert all(tier.value in message for tier in Effort)
|
||||||
|
|
||||||
|
def test_effort_fallback_parsed(self):
|
||||||
|
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "nearest"})
|
||||||
|
s = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
assert s.sources[0].effort_fallback == "nearest"
|
||||||
|
|
||||||
|
def test_effort_key_tolerates_case_and_whitespace(self):
|
||||||
|
"""`.env` 里的行尾空格与大写写法是常态,档位取值本身没有大小写语义。"""
|
||||||
|
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": " LOW "})
|
||||||
|
assert GatewaySettings.from_env("LLM", env=env).sources[0].reasoning_effort is Effort.LOW
|
||||||
|
|
||||||
|
def test_effort_fallback_tolerates_case_and_whitespace(self):
|
||||||
|
"""与相邻的 REASONING_EFFORT 同口径: 同一份 .env 里两个键脾气不同即是坑。
|
||||||
|
|
||||||
|
`EFFORT_FALLBACK=Nearest` 此前会原样落到 `SourceConfig`,被值域校验拒掉
|
||||||
|
——而人看着 .env 里明明写了 nearest(2026-09-05 独立验证查出)。
|
||||||
|
"""
|
||||||
|
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": " Nearest "})
|
||||||
|
assert GatewaySettings.from_env("LLM", env=env).sources[0].effort_fallback == "nearest"
|
||||||
|
|
||||||
|
def test_invalid_effort_fallback_rejected(self):
|
||||||
|
"""`resolve_thinking` 对未知 fallback 值是 fail-closed,不会替配置兜错。"""
|
||||||
|
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "closest"})
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
message = str(exc.value)
|
||||||
|
assert "effort_fallback" in message
|
||||||
|
assert "nearest" in message and "error" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingFlagContradiction:
|
||||||
|
"""`enable_thinking` 与 `reasoning_effort` 说的是同一件事(设计 §4.2 语法糖)。
|
||||||
|
|
||||||
|
矛盾时报错而非「后者赢」: 两个字段表达同一件事时,矛盾是配置错误,
|
||||||
|
静默取其一等于替下游猜它想要哪个。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _source(self, enable_thinking, effort):
|
||||||
|
env = _env(
|
||||||
|
**{
|
||||||
|
"LLM__QWEN__1__ENABLE_THINKING": enable_thinking,
|
||||||
|
"LLM__QWEN__1__REASONING_EFFORT": effort,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return GatewaySettings.from_env("LLM", env=env).sources[0]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("enable_thinking", "effort"),
|
||||||
|
[("true", "none"), ("false", "low"), ("false", "auto"), ("false", "max")],
|
||||||
|
)
|
||||||
|
def test_contradictory_thinking_flags_rejected(self, enable_thinking, effort):
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
self._source(enable_thinking, effort)
|
||||||
|
message = str(exc.value)
|
||||||
|
assert "enable_thinking" in message and "reasoning_effort" in message
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("enable_thinking", "effort"),
|
||||||
|
[("false", "none"), ("true", "auto"), ("true", "low")],
|
||||||
|
)
|
||||||
|
def test_consistent_flags_allowed(self, enable_thinking, effort):
|
||||||
|
"""语义一致就放行: `False`+`none` 与 `True`+某个开启档都只是说了两遍。"""
|
||||||
|
src = self._source(enable_thinking, effort)
|
||||||
|
assert src.enable_thinking is (enable_thinking == "true")
|
||||||
|
assert src.reasoning_effort is Effort(effort)
|
||||||
|
|
||||||
|
def test_one_sided_declaration_never_trips_the_guard(self):
|
||||||
|
"""只配一个键是常态(存量源全是这样),不得被矛盾守卫误伤。"""
|
||||||
|
assert self._source("true", None).reasoning_effort is None
|
||||||
|
assert self._source(None, "high").enable_thinking is None
|
||||||
|
|
||||||
|
def test_contradiction_guarded_on_direct_construction(self):
|
||||||
|
"""守卫挂在构造期而非 env 解析处: 构造函数全量注入那条装配路同样过闸。"""
|
||||||
|
base = SourceConfig(
|
||||||
|
name="s1",
|
||||||
|
provider="qwen",
|
||||||
|
base_url="https://gw.example/v1",
|
||||||
|
api_key="sk-x",
|
||||||
|
model="qwen-max",
|
||||||
|
timeout_s=60.0,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="reasoning_effort"):
|
||||||
|
dataclasses.replace(base, enable_thinking=True, reasoning_effort=Effort.NONE)
|
||||||
|
|
||||||
|
|
||||||
class TestResilienceKeys:
|
class TestResilienceKeys:
|
||||||
def test_flat_legacy_keys(self):
|
def test_flat_legacy_keys(self):
|
||||||
s = GatewaySettings.from_env("LLM", env=_env())
|
s = GatewaySettings.from_env("LLM", env=_env())
|
||||||
@@ -425,6 +534,73 @@ class TestTelemetryTextCap:
|
|||||||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2k"))
|
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2k"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTelemetryPoolKeys:
|
||||||
|
"""`PGW_TELEMETRY_PG_POOL_MAX` / `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(issue #15)。
|
||||||
|
|
||||||
|
两键都带 `PG` 前缀,与 `PGW_TELEMETRY_PG_DSN` 一致: SQLite 侧没有池、也没有
|
||||||
|
等价的写入预算旋钮,这个不对称是已知且有理由的。缺省值(4 / 5.0)只写在
|
||||||
|
config 一处——recorder 的两个同名参数是必填 keyword-only,不许各带一份缺省。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _pg_env(self, **overrides):
|
||||||
|
return _env(
|
||||||
|
PGW_TELEMETRY_BACKEND="postgres",
|
||||||
|
PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway",
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unset_keys_fall_back_to_the_documented_defaults(self):
|
||||||
|
s = GatewaySettings.from_env("LLM", env=self._pg_env())
|
||||||
|
assert s.telemetry_pg_pool_max == 4
|
||||||
|
assert s.telemetry_pg_write_timeout_s == 5.0
|
||||||
|
|
||||||
|
def test_values_parsed_from_env(self):
|
||||||
|
s = GatewaySettings.from_env(
|
||||||
|
"LLM",
|
||||||
|
env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="8", PGW_TELEMETRY_PG_WRITE_TIMEOUT_S="1.5"),
|
||||||
|
)
|
||||||
|
assert s.telemetry_pg_pool_max == 8
|
||||||
|
assert s.telemetry_pg_write_timeout_s == 1.5
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["0", "-1"])
|
||||||
|
def test_non_positive_pool_max_rejected_naming_the_env_key(self, raw):
|
||||||
|
"""池上限 0 = 永远拿不到连接(遥测全灭),负数无意义。"""
|
||||||
|
with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_POOL_MAX"):
|
||||||
|
GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX=raw))
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["0", "-1"])
|
||||||
|
def test_non_positive_write_timeout_rejected_naming_the_env_key(self, raw):
|
||||||
|
"""预算 0 = 每一行都当场超预算;不设预算不是这个键的写法。"""
|
||||||
|
with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_WRITE_TIMEOUT_S"):
|
||||||
|
GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S=raw))
|
||||||
|
|
||||||
|
def test_non_numeric_rejected_naming_the_env_key(self):
|
||||||
|
with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_POOL_MAX"):
|
||||||
|
GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="many"))
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
[("telemetry_pg_pool_max", 0), ("telemetry_pg_write_timeout_s", 0.0)],
|
||||||
|
)
|
||||||
|
def test_direct_construction_and_replace_are_validated_too(self, field, value):
|
||||||
|
"""env 路只覆盖 from_env;直接构造与 replace 是同等官方的装配路(与 text_cap 同款)。"""
|
||||||
|
base = GatewaySettings.from_env("LLM", env=_env())
|
||||||
|
with pytest.raises(ValueError, match=field):
|
||||||
|
dataclasses.replace(base, **{field: value})
|
||||||
|
|
||||||
|
def test_values_reach_the_recorder(self):
|
||||||
|
"""配置到 recorder 之间不得断链——两个键唯一的作用就是抵达那里。"""
|
||||||
|
from polygateway.client import _build_telemetry
|
||||||
|
|
||||||
|
settings = GatewaySettings.from_env(
|
||||||
|
"LLM",
|
||||||
|
env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="7", PGW_TELEMETRY_PG_WRITE_TIMEOUT_S="2.5"),
|
||||||
|
)
|
||||||
|
recorder = _build_telemetry(settings)
|
||||||
|
assert recorder._pool_max == 7
|
||||||
|
assert recorder._write_timeout_s == 2.5
|
||||||
|
|
||||||
|
|
||||||
class TestOcrSettings:
|
class TestOcrSettings:
|
||||||
"""M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。"""
|
"""M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。"""
|
||||||
|
|
||||||
@@ -570,10 +746,41 @@ class TestCrossFieldInvariants:
|
|||||||
GatewayClient.from_settings(settings)
|
GatewayClient.from_settings(settings)
|
||||||
|
|
||||||
def test_unknown_thinking_shape_fails_at_assembly(self):
|
def test_unknown_thinking_shape_fails_at_assembly(self):
|
||||||
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
|
# 形态未知即报错并指路。2026-09-04 起默认表 8 段全部有形态(openai 段改发
|
||||||
settings = self._thinking_sources("openai", "kimi-k3", False)
|
# OpenAI 标准的 reasoning_effort),故样本改为显式注册一个未知段——守卫测的
|
||||||
|
# 是机制,不是某个段当时的配置
|
||||||
|
mystery = ProviderProfile(
|
||||||
|
name="mystery",
|
||||||
|
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
settings = self._thinking_sources("mystery", "kimi-k3", False)
|
||||||
with pytest.raises(ValueError, match="register_provider"):
|
with pytest.raises(ValueError, match="register_provider"):
|
||||||
GatewayClient.from_settings(settings)
|
GatewayClient.from_settings(settings, registry=register_provider(mystery))
|
||||||
|
|
||||||
|
def test_source_tier_the_model_cannot_satisfy_fails_at_assembly(self):
|
||||||
|
"""守卫必须读**源级档位**,而不只是 `enable_thinking`(设计 §4.2 三层优先级)。
|
||||||
|
|
||||||
|
变异实测: 把 `_guard_thinking` 的 `source_effort=source.reasoning_effort`
|
||||||
|
改成 `None`,全套单测依然全绿(2026-09-05 独立验证查出)。文案里必须出现
|
||||||
|
可执行替代 `low`——glm-5.3 官方关不掉推理,只报"不行"会把人推回
|
||||||
|
`extra_body` 那条绕过治理的老路(issue #20 的成因)。
|
||||||
|
"""
|
||||||
|
base = self._base()
|
||||||
|
src = dataclasses.replace(
|
||||||
|
base.sources[0], provider="zhipu", model="glm-5.3", reasoning_effort=Effort.NONE
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
GatewayClient.from_settings(dataclasses.replace(base, sources=(src,)))
|
||||||
|
message = str(exc.value)
|
||||||
|
assert "glm-5.3" in message
|
||||||
|
assert "low" in message
|
||||||
|
|
||||||
|
def test_openai_segment_now_assembles_with_the_standard_field(self):
|
||||||
|
# 行为变更(2026-09-04): reasoning_effort 是 OpenAI 官方字段而非厂商方言,
|
||||||
|
# 经网关的兼容端点收得下,故兜底段不再把"关闭"判为形态未知
|
||||||
|
settings = self._thinking_sources("openai", "gpt-5.5", False)
|
||||||
|
assert GatewayClient.from_settings(settings) is not None
|
||||||
|
|
||||||
def test_supported_combination_assembles(self):
|
def test_supported_combination_assembles(self):
|
||||||
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
|
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
|
||||||
@@ -790,3 +997,47 @@ class TestCrossFieldInvariants:
|
|||||||
)
|
)
|
||||||
client = GatewayClient.from_settings(settings)
|
client = GatewayClient.from_settings(settings)
|
||||||
assert client is not None
|
assert client is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flat_legacy_keys_assemble_without_source_timeout_or_network():
|
||||||
|
"""完整合成 env 验证平铺回落,不借真实配置或 Redis/PG。"""
|
||||||
|
env = dict(_BASE_ENV)
|
||||||
|
del env["LLM__QWEN__1__TIMEOUT_S"]
|
||||||
|
env.update({"LLM_TIMEOUT": "317", "LLM_TTFT_TIMEOUT": "41", "LLM_INTER_TOKEN_TIMEOUT": "19"})
|
||||||
|
settings = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
assert settings.sources[0].timeout_s == 317
|
||||||
|
assert settings.sources[0].ttft_timeout_s == 41
|
||||||
|
assert settings.sources[0].inter_token_timeout_s == 19
|
||||||
|
assert settings.retry.max_attempts == 3
|
||||||
|
client = GatewayClient.from_settings(settings)
|
||||||
|
try:
|
||||||
|
assert client._transport._clients == {}
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", ["MiniMax-M2.7", "MiniMax-M3"])
|
||||||
|
def test_live_assembly_rejection_is_local_only(model):
|
||||||
|
"""M2.7 NONE 与 M3 AUTO 的旧 live 装配断言离线执行。"""
|
||||||
|
env = _env(**{"LLM__QWEN__1__MODEL": model})
|
||||||
|
settings = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
source = dataclasses.replace(
|
||||||
|
settings.sources[0], provider="minimax", enable_thinking=model == "MiniMax-M3"
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match=model):
|
||||||
|
GatewayClient.from_settings(dataclasses.replace(settings, sources=(source,)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_unknown_wire_assembly_is_local_only():
|
||||||
|
"""L9 明确全 None profile,不从当前默认注册表猜未知形态。"""
|
||||||
|
mystery = ProviderProfile(
|
||||||
|
name="mystery",
|
||||||
|
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
settings = GatewaySettings.from_env("LLM", env=_BASE_ENV)
|
||||||
|
source = dataclasses.replace(settings.sources[0], provider="mystery", enable_thinking=False)
|
||||||
|
with pytest.raises(ValueError, match="register_provider"):
|
||||||
|
GatewayClient.from_settings(
|
||||||
|
dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery)
|
||||||
|
)
|
||||||
|
|||||||
@@ -533,3 +533,115 @@ class TestEmbeddingSettings:
|
|||||||
s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
|
s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
|
||||||
client = EmbeddingClient.from_settings(s)
|
client = EmbeddingClient.from_settings(s)
|
||||||
assert isinstance(client, EmbeddingClient)
|
assert isinstance(client, EmbeddingClient)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasonlessTelemetryContract:
|
||||||
|
"""从真实客户端到落库,误配推理配置也不能产生推理档。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
|
||||||
|
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
|
||||||
|
async def test_failed_then_successful_attempts_have_null_effort(
|
||||||
|
self, config, backend, tmp_path
|
||||||
|
):
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||||
|
|
||||||
|
path = tmp_path / "embed.sqlite"
|
||||||
|
recorder = (
|
||||||
|
_MemoryRecorder() if backend == "memory" else SQLiteRecorder(path, auto_migrate=True)
|
||||||
|
)
|
||||||
|
client, _ = _embed_client(
|
||||||
|
[_src(**config)], [TransientError("retry"), "ok"], telemetry=recorder
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await client.embed(["text"], session_id="run", parent_call_id="embed")
|
||||||
|
if backend == "memory":
|
||||||
|
rows = [(r["error"], r["reasoning_effort"]) for r in recorder.rows]
|
||||||
|
else:
|
||||||
|
with sqlite3.connect(path) as db:
|
||||||
|
rows = db.execute("SELECT error, reasoning_effort FROM llm_calls").fetchall()
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert sum(bool(error) for error, _ in rows) == 1
|
||||||
|
assert [tier for _, tier in rows] == [None, None]
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
if backend == "sqlite":
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exhausted", [False, True])
|
||||||
|
async def test_failed_attempts_still_have_null_effort(self, exhausted):
|
||||||
|
from polygateway.errors import AllSourcesExhausted
|
||||||
|
|
||||||
|
script = (
|
||||||
|
[TransientError("retry")] * 3 if exhausted else [RequestRejectedError("bad request")]
|
||||||
|
)
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _ = _embed_client([_src(enable_thinking=True)], script, telemetry=recorder)
|
||||||
|
with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError):
|
||||||
|
await client.embed(["text"])
|
||||||
|
# 1.3.5: 逐次 attempt 行之外,本次逻辑调用另有**一条**终态行
|
||||||
|
attempts = [r for r in recorder.rows if r["event_kind"] == "attempt"]
|
||||||
|
terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"]
|
||||||
|
assert len(attempts) == len(script)
|
||||||
|
assert len(terminals) == 1
|
||||||
|
# 推理档在三类行上都必须是 NULL: embed payload 从不带推理参数
|
||||||
|
assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows)
|
||||||
|
|
||||||
|
async def test_embedding_wire_ignores_reasoning_configuration(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.append(json.loads(request.content))
|
||||||
|
return httpx.Response(200, json=_ok_body([[1.0]], usage={"prompt_tokens": 1}))
|
||||||
|
|
||||||
|
transport = _transport_with(handler)
|
||||||
|
try:
|
||||||
|
await transport.embed(
|
||||||
|
texts=["text"],
|
||||||
|
source=_src(enable_thinking=True, reasoning_effort="high"),
|
||||||
|
call_id="wire",
|
||||||
|
)
|
||||||
|
assert seen == [{"model": "embed-1", "input": ["text"]}]
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmbedLogicalCallStats:
|
||||||
|
"""分批共享同一逻辑调用(1.3.5 设计 §3/§3.5)。"""
|
||||||
|
|
||||||
|
async def test_three_batches_count_three_attempts(self):
|
||||||
|
"""分批是库的实现细节,但每批都真打了一次网关,故计 3 次尝试。"""
|
||||||
|
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=2)
|
||||||
|
resp = await client.embed(["a", "bb", "ccc", "dddd", "eeeee"])
|
||||||
|
assert resp.call_stats is not None
|
||||||
|
assert resp.call_stats.attempts == 3
|
||||||
|
|
||||||
|
async def test_separate_calls_get_distinct_logical_ids(self):
|
||||||
|
"""一次公开调用一个 ID: 两次 `embed` 不得共用同一个。
|
||||||
|
|
||||||
|
共用就意味着上下文被提升成了 client 实例属性(库铁律禁止的形态)。
|
||||||
|
"""
|
||||||
|
client, _ = _embed_client([_src()], ["ok"] * 5, batch_size=2)
|
||||||
|
first = await client.embed(["a", "bb", "ccc", "dddd", "eeeee"]) # 3 批
|
||||||
|
second = await client.embed(["x", "y"]) # 1 批
|
||||||
|
assert first.call_stats.attempts == 3 and second.call_stats.attempts == 1
|
||||||
|
assert first.call_stats.logical_call_id != second.call_stats.logical_call_id
|
||||||
|
|
||||||
|
async def test_retry_within_a_batch_is_counted(self):
|
||||||
|
client, _ = _embed_client([_src(), _src(name="e2")], [TransientError("t1"), "ok"])
|
||||||
|
resp = await client.embed(["a"])
|
||||||
|
assert resp.call_stats.attempts == 2
|
||||||
|
|
||||||
|
async def test_empty_input_is_zero_attempts_and_writes_no_telemetry_row(self):
|
||||||
|
"""合法零尝试: 返回真实统计,且**不写任何遥测行**(设计 §3 M2)。
|
||||||
|
|
||||||
|
与 cache_hit 不同——不要按"遥测必录"推断空输入也有台账行。
|
||||||
|
"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
client, _ = _embed_client([_src()], [], telemetry=rec)
|
||||||
|
resp = await client.embed([])
|
||||||
|
assert resp.call_stats is not None
|
||||||
|
assert resp.call_stats.attempts == 0
|
||||||
|
assert resp.call_stats.logical_call_id # 真实 ID,不是空串
|
||||||
|
assert rec.rows == [] # 零遥测行
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -405,3 +405,31 @@ class TestLifecycle:
|
|||||||
await t.check_health(source=_source())
|
await t.check_health(source=_source())
|
||||||
await t.aclose()
|
await t.aclose()
|
||||||
await t.aclose()
|
await t.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
||||||
|
async def test_ocr_wire_does_not_send_reasoning_configuration(method):
|
||||||
|
"""真实 multipart 与 ZIP 下载两段均不发送源级推理配置。"""
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
sent.append(request)
|
||||||
|
if request.method == "GET":
|
||||||
|
return httpx.Response(200, content=_zip_bytes())
|
||||||
|
return httpx.Response(
|
||||||
|
200, json=_text_body() if method == "recognize_text" else _parse_body()
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = _transport_for(handler)
|
||||||
|
try:
|
||||||
|
await getattr(transport, method)(
|
||||||
|
image=b"image",
|
||||||
|
source=_source(enable_thinking=True, reasoning_effort="high"),
|
||||||
|
call_id="wire",
|
||||||
|
)
|
||||||
|
assert len(sent) == (1 if method == "recognize_text" else 2)
|
||||||
|
for request in sent:
|
||||||
|
for key in (b"reasoning_effort", b"enable_thinking", b"thinking_budget"):
|
||||||
|
assert key not in request.content
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|||||||
@@ -564,3 +564,91 @@ class TestAssembly:
|
|||||||
client = OcrClient.from_env("OCR", env=dict(self._ENV))
|
client = OcrClient.from_env("OCR", env=dict(self._ENV))
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasonlessTelemetryContract:
|
||||||
|
"""text/layout 两个入口分别验证错误行不受源级推理配置污染。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method,action", [("recognize_text", "text"), ("parse_layout", "layout")]
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
|
||||||
|
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
|
||||||
|
async def test_failed_then_successful_attempts_have_null_effort(
|
||||||
|
self, method, action, config, backend, tmp_path
|
||||||
|
):
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||||
|
|
||||||
|
path = tmp_path / "ocr.sqlite"
|
||||||
|
recorder = (
|
||||||
|
_MemoryRecorder() if backend == "memory" else SQLiteRecorder(path, auto_migrate=True)
|
||||||
|
)
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src(**config)], [TransientError("retry"), action], telemetry=recorder
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await getattr(client, method)(b"image", session_id="run", parent_call_id=method)
|
||||||
|
if backend == "memory":
|
||||||
|
rows = [(r["error"], r["reasoning_effort"]) for r in recorder.rows]
|
||||||
|
else:
|
||||||
|
with sqlite3.connect(path) as db:
|
||||||
|
rows = db.execute("SELECT error, reasoning_effort FROM llm_calls").fetchall()
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert sum(bool(error) for error, _ in rows) == 1
|
||||||
|
assert [tier for _, tier in rows] == [None, None]
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
if backend == "sqlite":
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
||||||
|
@pytest.mark.parametrize("exhausted", [False, True])
|
||||||
|
async def test_failed_attempts_still_have_null_effort(self, method, exhausted):
|
||||||
|
script = (
|
||||||
|
[TransientError("retry")] * 3 if exhausted else [RequestRejectedError("bad request")]
|
||||||
|
)
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _, _ = _client([_src(enable_thinking=True)], script, telemetry=recorder)
|
||||||
|
with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError):
|
||||||
|
await getattr(client, method)(b"image")
|
||||||
|
# 1.3.5: 逐次 attempt 行之外,本次逻辑调用另有**一条**终态行
|
||||||
|
attempts = [r for r in recorder.rows if r["event_kind"] == "attempt"]
|
||||||
|
terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"]
|
||||||
|
assert len(attempts) == len(script)
|
||||||
|
assert len(terminals) == 1
|
||||||
|
# 终态行的 operation 是**公开方法**名,与尝试行一致
|
||||||
|
assert terminals[0]["operation"] == method
|
||||||
|
assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOcrLogicalCallStats:
|
||||||
|
"""OCR 两个公开方法各自拥有一次逻辑调用(1.3.5 设计 §3/§3.5)。"""
|
||||||
|
|
||||||
|
async def test_text_success_counts_one_attempt(self):
|
||||||
|
client, _, _ = _client([_src()], ["text"])
|
||||||
|
r = await client.recognize_text(b"jpg")
|
||||||
|
assert r.call_stats is not None and r.call_stats.attempts == 1
|
||||||
|
|
||||||
|
async def test_layout_two_http_calls_count_as_one_attempt(self):
|
||||||
|
"""POST + ZIP GET 在同一次 transport 调用内,计 **1** 次尝试而非 2。
|
||||||
|
|
||||||
|
`attempts` 的语义是"调用 transport 端口的次数",不是 HTTP 请求条数。
|
||||||
|
"""
|
||||||
|
client, _, _ = _client([_src()], ["layout"])
|
||||||
|
r = await client.parse_layout(b"jpg")
|
||||||
|
assert r.call_stats is not None and r.call_stats.attempts == 1
|
||||||
|
|
||||||
|
async def test_retry_counts_every_attempt(self):
|
||||||
|
client, _, _ = _client([_src(), _src(name="m2")], [TransientError("t1"), "text"])
|
||||||
|
r = await client.recognize_text(b"jpg")
|
||||||
|
assert r.call_stats.attempts == 2
|
||||||
|
|
||||||
|
async def test_input_validation_stays_outside_the_stats_boundary(self):
|
||||||
|
"""`image` 类型/空校验先于上下文创建(M1 例外),保持原异常行为。"""
|
||||||
|
client, _, _ = _client([_src()], [])
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
await client.recognize_text("not-bytes")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await client.recognize_text(b"")
|
||||||
|
|||||||
@@ -16,13 +16,14 @@ from polygateway.errors import (
|
|||||||
)
|
)
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||||
from polygateway.pricing import ModelPrice, PricingTable
|
from polygateway.pricing import ModelPrice, PricingTable
|
||||||
|
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
|
||||||
from polygateway.transports._http_errors import summarize_body
|
from polygateway.transports._http_errors import summarize_body
|
||||||
from polygateway.transports.openai_compat import (
|
from polygateway.transports.openai_compat import (
|
||||||
OpenAICompatTransport,
|
OpenAICompatTransport,
|
||||||
_iter_sse_deltas,
|
_iter_sse_deltas,
|
||||||
_sse_data_payload,
|
_sse_data_payload,
|
||||||
)
|
)
|
||||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation
|
||||||
|
|
||||||
|
|
||||||
def _source(**overrides):
|
def _source(**overrides):
|
||||||
@@ -60,20 +61,22 @@ def _sse_stream(*frames, done=True):
|
|||||||
return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"})
|
return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"})
|
||||||
|
|
||||||
|
|
||||||
def _transport_for(handler):
|
def _transport_for(handler, *, registry=None):
|
||||||
mock = httpx.MockTransport(handler)
|
mock = httpx.MockTransport(handler)
|
||||||
return OpenAICompatTransport(
|
return OpenAICompatTransport(
|
||||||
client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock)
|
client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock),
|
||||||
|
registry=registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _complete(transport, source, *, stream=True, overlay=None):
|
async def _complete(transport, source, *, stream=True, overlay=None, reasoning_effort=None):
|
||||||
return await transport.complete(
|
return await transport.complete(
|
||||||
messages=[{"role": "user", "content": "hi"}],
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
source=source,
|
source=source,
|
||||||
stream=stream,
|
stream=stream,
|
||||||
overlay=overlay or {},
|
overlay=overlay or {},
|
||||||
call_id="cid-1",
|
call_id="cid-1",
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,13 +116,15 @@ async def _recorded_cost(result, source):
|
|||||||
source_name=source.name,
|
source_name=source.name,
|
||||||
usage_source=result.usage_source,
|
usage_source=result.usage_source,
|
||||||
)
|
)
|
||||||
await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None).emit_attempt(
|
await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None, scope="LLM").emit_attempt(
|
||||||
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
|
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
|
||||||
source=source,
|
source=source,
|
||||||
call_id="cid-1",
|
call_id="cid-1",
|
||||||
latency_ms=1,
|
latency_ms=1,
|
||||||
response=response,
|
response=response,
|
||||||
error=None,
|
error=None,
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
return recorder.rows[0]["cost"]
|
return recorder.rows[0]["cost"]
|
||||||
|
|
||||||
@@ -471,6 +476,216 @@ class TestReasoningTokens:
|
|||||||
assert result.reasoning_tokens is None
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingObservationVerdict:
|
||||||
|
"""issue #16/#17: 两条组装路径都必须裁定"推理到底发生没发生"。
|
||||||
|
|
||||||
|
流式与非流式各测一遍是刻意的——只填一条路径正是本 issue 的根因形态:
|
||||||
|
库在其中一条路径上悄悄给出了不同的可观测性,下游无从分辨。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _reasoning_usage(self, reasoning):
|
||||||
|
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
|
||||||
|
|
||||||
|
async def test_stream_reasoning_content_is_observed(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(reasoning="想一下"), _chunk(content="ok"), _chunk(usage=_USAGE)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
async def test_stream_without_any_signal_is_unknown(self):
|
||||||
|
"""无正文、无 details: 库不知道,就如实说不知道。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
async def test_stream_zero_reasoning_tokens_is_absent(self):
|
||||||
|
"""上游明确上报 0 才算 ABSENT——这是唯一的"确实没推理"证据。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.thinking_observation is ThinkingObservation.ABSENT
|
||||||
|
|
||||||
|
async def test_non_stream_reasoning_content_is_observed(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42", "reasoning_content": "想一下"}}],
|
||||||
|
"usage": _USAGE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
async def test_non_stream_without_any_signal_is_unknown(self):
|
||||||
|
"""M3 非流式实测形态: 推理已计费却既不回传正文也不回传 details。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
async def test_non_stream_zero_reasoning_tokens_is_absent(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42"}}],
|
||||||
|
"usage": self._reasoning_usage(0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.thinking_observation is ThinkingObservation.ABSENT
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingReconciliation:
|
||||||
|
"""对账告警按 (source, model, direction) 节流(设计 §5)。
|
||||||
|
|
||||||
|
键的三段缺一不可,理由同源: 合并任意一段,都会让先出现的那一组把另一组
|
||||||
|
永久静音——同一模型的开/关两档是两个独立的矛盾,同一模型的两个源背后是
|
||||||
|
两个独立的账号/网关。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _handler(self, request):
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
if payload.get("reasoning_effort") == "none":
|
||||||
|
# 关闭档却回了推理正文 → OBSERVED,与"要求关闭"矛盾
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(reasoning="偷偷想了"), _chunk(content="ok"), _chunk(usage=_USAGE)
|
||||||
|
)
|
||||||
|
# 开启档却零信号 → UNKNOWN,无法确认是否生效(M3 实测形态)
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
def _minimax(self, enable_thinking, name="mm"):
|
||||||
|
return _source(
|
||||||
|
name=name, provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_same_model_and_direction_warns_only_once(self):
|
||||||
|
transport = _transport_for(self._handler)
|
||||||
|
source = self._minimax(False)
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
await _complete(transport, source)
|
||||||
|
await _complete(transport, source)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
hits = [m for m in messages if "MiniMax-M3" in m]
|
||||||
|
assert len(hits) == 1, f"同一 (model, direction) 应只告警一次,实得 {len(hits)} 次"
|
||||||
|
|
||||||
|
async def test_each_source_gets_its_own_warning(self):
|
||||||
|
"""多源多账号是本库的核心场景: 同一 model 跨 N 个源不得只喊第一个。
|
||||||
|
|
||||||
|
节流键漏掉源标识时,5 个共用同一模型的源里第一个出问题的喊完一次,其余
|
||||||
|
四个**永久静音**——而每个源背后是独立的账号/网关,它们的行为互不代表。
|
||||||
|
"""
|
||||||
|
transport = _transport_for(self._handler)
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
await _complete(transport, self._minimax(False, name="gw-a"))
|
||||||
|
await _complete(transport, self._minimax(False, name="gw-b"))
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
hits = [m for m in messages if "MiniMax-M3" in m]
|
||||||
|
assert len(hits) == 2, f"两个源各应告警一次,实得 {len(hits)} 次"
|
||||||
|
|
||||||
|
async def test_the_warning_names_the_source(self):
|
||||||
|
"""拿到告警的人得知道该查哪个网关: 只报模型名定位不到源。"""
|
||||||
|
transport = _transport_for(self._handler)
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
await _complete(transport, self._minimax(False, name="gw-a"))
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
hits = [m for m in messages if "MiniMax-M3" in m]
|
||||||
|
assert len(hits) == 1
|
||||||
|
assert "gw-a" in hits[0], f"告警未点名出问题的源: {hits[0]}"
|
||||||
|
|
||||||
|
async def test_switching_direction_earns_a_second_warning(self):
|
||||||
|
transport = _transport_for(self._handler)
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
await _complete(transport, self._minimax(False))
|
||||||
|
await _complete(transport, self._minimax(False))
|
||||||
|
await _complete(transport, self._minimax(True), reasoning_effort=Effort.MEDIUM)
|
||||||
|
await _complete(transport, self._minimax(True), reasoning_effort=Effort.MEDIUM)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
hits = [m for m in messages if "MiniMax-M3" in m]
|
||||||
|
assert len(hits) == 2, f"两个方向各应告警一次,实得 {len(hits)} 次"
|
||||||
|
|
||||||
|
async def test_each_tier_of_one_model_earns_its_own_warning(self):
|
||||||
|
"""同一源同一模型的两个强度档是**两个独立的矛盾**,不得共用一个节流键。
|
||||||
|
|
||||||
|
节流键沿用旧的 `enable_thinking` 三态时,两次请求的键逐字相同(都是
|
||||||
|
`None`——档位根本不经过那个字段),于是 `max` 档的矛盾被 `low` 档那次
|
||||||
|
永久静音。档位化后 low 与 max 各喊一次,重复的 low 仍只喊一次。
|
||||||
|
"""
|
||||||
|
transport = _transport_for(self._zero_signal)
|
||||||
|
source = _source(name="zp", provider="zhipu", model="glm-5.3")
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
await _complete(transport, source, reasoning_effort=Effort.LOW)
|
||||||
|
await _complete(transport, source, reasoning_effort=Effort.LOW)
|
||||||
|
await _complete(transport, source, reasoning_effort=Effort.MAX)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
hits = [m for m in messages if "glm-5.3" in m]
|
||||||
|
assert len(hits) == 2, f"low 与 max 应各告警一次,实得 {len(hits)} 次"
|
||||||
|
|
||||||
|
def _zero_signal(self, request):
|
||||||
|
"""零推理信号的成功响应 → UNKNOWN,与"要求开启"矛盾(M3 实测形态)。"""
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppliedTierLeavesTheTransport:
|
||||||
|
"""本次**实际**发出去的档必须随 TransportResult 上浮(设计 §4.1 / 计划 T8-5)。
|
||||||
|
|
||||||
|
不上浮就只能由遥测自己再算一遍请求档,而 `nearest` 映射后两者不同——压测
|
||||||
|
要按档分组的那一列会挂在一个从未真正发出过的档下,且错得看不出来。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _ok(self, request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
async def test_result_carries_the_mapped_tier_not_the_requested_one(self):
|
||||||
|
"""glm-5.3 只有 low/high/max: 请求 `medium`,实际发出的是 `low`。"""
|
||||||
|
transport = _transport_for(self._ok)
|
||||||
|
source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest")
|
||||||
|
result = await _complete(transport, source, reasoning_effort=Effort.MEDIUM)
|
||||||
|
assert result.applied_effort is Effort.LOW
|
||||||
|
|
||||||
|
async def test_result_carries_the_tier_that_was_asked_for_when_supported(self):
|
||||||
|
transport = _transport_for(self._ok)
|
||||||
|
source = _source(provider="zhipu", model="glm-5.3")
|
||||||
|
result = await _complete(transport, source, reasoning_effort=Effort.MAX)
|
||||||
|
assert result.applied_effort is Effort.MAX
|
||||||
|
|
||||||
|
async def test_no_statement_stays_none(self):
|
||||||
|
"""不表态时库既不注入也不推定模型默认档——"没看见"不许说成"发生了"。"""
|
||||||
|
transport = _transport_for(self._ok)
|
||||||
|
result = await _complete(transport, _source())
|
||||||
|
assert result.applied_effort is None
|
||||||
|
|
||||||
|
|
||||||
class TestNonStreamFastPath:
|
class TestNonStreamFastPath:
|
||||||
async def test_non_stream_parses_message(self):
|
async def test_non_stream_parses_message(self):
|
||||||
def handler(request):
|
def handler(request):
|
||||||
@@ -491,59 +706,44 @@ class TestNonStreamFastPath:
|
|||||||
|
|
||||||
|
|
||||||
class TestRequestShaping:
|
class TestRequestShaping:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("tier", [Effort.MEDIUM, Effort.NONE])
|
||||||
("enable_thinking", "expected"),
|
async def test_minimax_explicit_tier_is_sent(self, tier):
|
||||||
[(True, {"enable_thinking": True}), (False, {"enable_thinking": False}), (None, {})],
|
|
||||||
)
|
|
||||||
async def test_thinking_tri_state_injection(self, enable_thinking, expected):
|
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def handler(request):
|
def handler(request):
|
||||||
seen.update(json.loads(request.content))
|
seen.update(json.loads(request.content))
|
||||||
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
await _complete(_transport_for(handler), _source(enable_thinking=enable_thinking))
|
transport = _transport_for(handler)
|
||||||
assert {k: seen[k] for k in expected} == expected
|
try:
|
||||||
if enable_thinking is None:
|
result = await _complete(
|
||||||
|
transport, _source(provider="minimax", model="MiniMax-M3"), reasoning_effort=tier
|
||||||
|
)
|
||||||
|
assert seen["reasoning_effort"] == tier.value
|
||||||
|
assert result.applied_effort is tier
|
||||||
assert "enable_thinking" not in seen
|
assert "enable_thinking" not in seen
|
||||||
assert seen["stream_options"] == {"include_usage": True}
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
async def test_raw_only_keeps_source_then_request_priority(self):
|
||||||
("enable_thinking", "expected"),
|
|
||||||
[(True, "medium"), (False, "none")],
|
|
||||||
)
|
|
||||||
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
|
|
||||||
"""issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。"""
|
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def handler(request):
|
def handler(request):
|
||||||
seen.update(json.loads(request.content))
|
seen.update(json.loads(request.content))
|
||||||
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
source = _source(
|
transport = _transport_for(handler)
|
||||||
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
|
try:
|
||||||
)
|
result = await _complete(
|
||||||
await _complete(_transport_for(handler), source)
|
transport,
|
||||||
assert seen["reasoning_effort"] == expected
|
_source(extra_body={"reasoning_effort": "low", "temperature": 0}),
|
||||||
assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发
|
overlay={"reasoning_effort": "high", "temperature": 1},
|
||||||
|
)
|
||||||
async def test_extra_body_overrides_the_profile_slot(self):
|
assert seen["reasoning_effort"] == "high"
|
||||||
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。"""
|
assert seen["temperature"] == 1
|
||||||
seen = {}
|
assert result.applied_effort is None
|
||||||
|
finally:
|
||||||
def handler(request):
|
await transport.aclose()
|
||||||
seen.update(json.loads(request.content))
|
|
||||||
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
|
||||||
|
|
||||||
source = _source(
|
|
||||||
name="mm",
|
|
||||||
provider="minimax",
|
|
||||||
model="MiniMax-M3",
|
|
||||||
enable_thinking=True,
|
|
||||||
extra_body={"reasoning_effort": "high"},
|
|
||||||
)
|
|
||||||
await _complete(_transport_for(handler), source)
|
|
||||||
assert seen["reasoning_effort"] == "high"
|
|
||||||
|
|
||||||
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
|
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
|
||||||
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
|
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
|
||||||
@@ -602,9 +802,15 @@ class TestRequestShaping:
|
|||||||
def handler(request): # pragma: no cover - 不该走到发请求
|
def handler(request): # pragma: no cover - 不该走到发请求
|
||||||
raise AssertionError("请求不该发出")
|
raise AssertionError("请求不该发出")
|
||||||
|
|
||||||
source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False)
|
# 2026-09-04 起默认表 8 段全部有形态,守卫样本改为显式注册的未知段
|
||||||
|
mystery = ProviderProfile(
|
||||||
|
name="mystery",
|
||||||
|
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
source = _source(name="k3", provider="mystery", model="kimi-k3", enable_thinking=False)
|
||||||
with pytest.raises(RequestRejectedError, match="register_provider"):
|
with pytest.raises(RequestRejectedError, match="register_provider"):
|
||||||
await _complete(_transport_for(handler), source)
|
await _complete(_transport_for(handler, registry=register_provider(mystery)), source)
|
||||||
|
|
||||||
async def test_overlay_merged_into_payload(self):
|
async def test_overlay_merged_into_payload(self):
|
||||||
seen = {}
|
seen = {}
|
||||||
@@ -820,3 +1026,121 @@ class TestLifecycle:
|
|||||||
await _complete(transport, _source())
|
await _complete(transport, _source())
|
||||||
await transport.aclose()
|
await transport.aclose()
|
||||||
await transport.aclose()
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagedReasoningOwnership:
|
||||||
|
"""通过真实 transport 验证拒绝发生在 HTTP 之前。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
{"reasoning_effort": "high"},
|
||||||
|
{"enable_thinking": True},
|
||||||
|
{"thinking": {}},
|
||||||
|
{"thinking_budget": 100},
|
||||||
|
{"reasoning": {}},
|
||||||
|
{"thinkingConfig": {}},
|
||||||
|
{"output_config": {"effort": "low"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("layer", ["source", "request", "shadowed"])
|
||||||
|
async def test_raw_control_is_rejected_before_http(self, raw, layer):
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
sent.append(request)
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
source = _source(
|
||||||
|
provider="openai",
|
||||||
|
reasoning_effort=Effort.HIGH,
|
||||||
|
extra_body=raw if layer != "request" else {},
|
||||||
|
)
|
||||||
|
overlay = raw if layer != "source" else {}
|
||||||
|
transport = _transport_for(handler)
|
||||||
|
try:
|
||||||
|
with pytest.raises(RequestRejectedError):
|
||||||
|
await _complete(transport, source, overlay=overlay)
|
||||||
|
assert sent == []
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultClientFactory:
|
||||||
|
"""直接检查生产 factory,不用取证测试的另一套 factory 代替。"""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_proxy_environment(self, monkeypatch):
|
||||||
|
"""离线构造测试不继承开发机代理;仍真实验证 trust_env 传递。"""
|
||||||
|
for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
monkeypatch.delenv(key.lower(), raising=False)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["fake-a", "fake-b"])
|
||||||
|
async def test_authorization_uses_source_api_key(self, key):
|
||||||
|
from polygateway.transports.openai_compat import _default_client_factory
|
||||||
|
|
||||||
|
client = _default_client_factory(_source(api_key=key))
|
||||||
|
try:
|
||||||
|
assert client.headers["Authorization"] == f"Bearer {key}"
|
||||||
|
assert (
|
||||||
|
client.build_request("POST", "https://gw.example/v1/chat/completions").headers[
|
||||||
|
"Authorization"
|
||||||
|
]
|
||||||
|
== f"Bearer {key}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("timeout", [17.0, 53.0])
|
||||||
|
async def test_timeout_uses_source_timeout_for_all_phases(self, timeout):
|
||||||
|
from polygateway.transports.openai_compat import _default_client_factory
|
||||||
|
|
||||||
|
client = _default_client_factory(_source(timeout_s=timeout))
|
||||||
|
try:
|
||||||
|
assert [
|
||||||
|
client.timeout.connect,
|
||||||
|
client.timeout.read,
|
||||||
|
client.timeout.write,
|
||||||
|
client.timeout.pool,
|
||||||
|
] == [timeout] * 4
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("trust_env", [True, False])
|
||||||
|
async def test_trust_env_uses_source_setting(self, trust_env):
|
||||||
|
from polygateway.transports.openai_compat import _default_client_factory
|
||||||
|
|
||||||
|
client = _default_client_factory(_source(trust_env=trust_env))
|
||||||
|
try:
|
||||||
|
assert client.trust_env is trust_env
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["depth.key", "off_control", "switch"])
|
||||||
|
async def test_custom_profile_raw_roots_cannot_override_managed_intent(key):
|
||||||
|
registry = {
|
||||||
|
"custom": ProviderProfile(
|
||||||
|
name="custom",
|
||||||
|
thinking=ThinkingWire(
|
||||||
|
off={"off_control": False}, on_base={"switch": True}, effort_key="depth.key"
|
||||||
|
),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sent = []
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda request: sent.append(request) or _sse_stream(_chunk(content="x")), registry=registry
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(RequestRejectedError, match="冲突"):
|
||||||
|
await _complete(
|
||||||
|
transport,
|
||||||
|
_source(provider="custom"),
|
||||||
|
reasoning_effort=Effort.HIGH,
|
||||||
|
overlay={key: True},
|
||||||
|
)
|
||||||
|
assert sent == []
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|||||||
@@ -37,3 +37,54 @@ def test_telemetry_schema_sql_exported():
|
|||||||
assert callable(polygateway.telemetry_schema_sql)
|
assert callable(polygateway.telemetry_schema_sql)
|
||||||
assert "missing_columns_warning" not in polygateway.__all__
|
assert "missing_columns_warning" not in polygateway.__all__
|
||||||
assert not hasattr(polygateway, "missing_columns_warning")
|
assert not hasattr(polygateway, "missing_columns_warning")
|
||||||
|
|
||||||
|
|
||||||
|
def test_telemetry_status_exported():
|
||||||
|
"""issue #15: `client.telemetry_status` 的返回类型必须能从顶层 import。
|
||||||
|
|
||||||
|
下游对账/告警要给这个快照做类型标注,若只在 `polygateway.types` 里,标注就得
|
||||||
|
深入子模块,而本库的约定是「顶层导出即公共 API 面」。`TelemetryStatusProvider`
|
||||||
|
则**不**导出: 它是端口,库外无实现者,导出即多一份永久承诺。
|
||||||
|
"""
|
||||||
|
assert "TelemetryStatus" in polygateway.__all__
|
||||||
|
assert polygateway.TelemetryStatus is not None
|
||||||
|
assert "TelemetryStatusProvider" not in polygateway.__all__
|
||||||
|
|
||||||
|
|
||||||
|
def test_thinking_public_surface_exported():
|
||||||
|
"""issue #16/#17: 推理决策搬进 `polygateway.thinking` 后,公共符号必须走顶层。
|
||||||
|
|
||||||
|
搬模块本身会断掉 `from polygateway.providers import ThinkingCapability` 这类
|
||||||
|
深路径 import。给下游一个稳定引用点,是以后再重组不再破坏下游的前提——本库
|
||||||
|
的约定是「顶层导出即公共 API 面」。
|
||||||
|
|
||||||
|
`observe_thinking` / `reconcile_thinking` **不**导出: 它们是 transport 内部
|
||||||
|
的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份
|
||||||
|
永久承诺。`ThinkingResolution` 则**要**导出——它是已导出的 `resolve_thinking`
|
||||||
|
的返回类型,不导出等于下游拿得到实例却写不出类型标注。
|
||||||
|
"""
|
||||||
|
for name in (
|
||||||
|
"ThinkingCapability",
|
||||||
|
"ThinkingObservation",
|
||||||
|
"ThinkingResolution",
|
||||||
|
"ThinkingUnsupportedError",
|
||||||
|
"ThinkingWire",
|
||||||
|
"get_capability",
|
||||||
|
"register_capability",
|
||||||
|
"resolve_thinking",
|
||||||
|
):
|
||||||
|
assert hasattr(polygateway, name), name
|
||||||
|
assert name in polygateway.__all__, name
|
||||||
|
assert "observe_thinking" not in polygateway.__all__
|
||||||
|
assert "reconcile_thinking" not in polygateway.__all__
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_promised_export_is_actually_importable():
|
||||||
|
"""`__all__` 里的每个名字都必须真的绑在包上。
|
||||||
|
|
||||||
|
只维护 `__all__` 而漏掉 import,`from polygateway import X` 与 `import *`
|
||||||
|
都会当场炸,而逐个点名的用例只覆盖它当时想到的符号——2026-09-04 的
|
||||||
|
`ThinkingWire` 正是这样漏进来的(在 `__all__` 里躺了一个提交却 import 不到)。
|
||||||
|
"""
|
||||||
|
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
|
||||||
|
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
|
||||||
|
|||||||
+110
-3
@@ -1,24 +1,28 @@
|
|||||||
"""ports.py 端口冻结测试(M1 设计 §4): Protocol 结构性检查 + Gate 快照校验。"""
|
"""ports.py 端口冻结测试(M1 设计 §4): Protocol 结构性检查 + Gate 快照校验。"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from polygateway.ports import (
|
from polygateway.ports import (
|
||||||
CacheBackend,
|
CacheBackend,
|
||||||
|
EmbeddingTransport,
|
||||||
GateDecision,
|
GateDecision,
|
||||||
GateState,
|
GateState,
|
||||||
GateUpdate,
|
GateUpdate,
|
||||||
Middleware,
|
Middleware,
|
||||||
|
OcrTransport,
|
||||||
Permit,
|
Permit,
|
||||||
ProviderGate,
|
ProviderGate,
|
||||||
RateLimiter,
|
RateLimiter,
|
||||||
SourceSelector,
|
SourceSelector,
|
||||||
StructuredOutputStrategy,
|
StructuredOutputStrategy,
|
||||||
TelemetryRecorder,
|
TelemetryRecorder,
|
||||||
|
TelemetryStatusProvider,
|
||||||
Transport,
|
Transport,
|
||||||
)
|
)
|
||||||
from polygateway.types import LLMResponse, SourceStats
|
from polygateway.types import LLMResponse, SourceStats, TelemetryStatus
|
||||||
|
|
||||||
|
|
||||||
def _resp() -> LLMResponse:
|
def _resp() -> LLMResponse:
|
||||||
@@ -68,7 +72,7 @@ class _DummyMw:
|
|||||||
|
|
||||||
|
|
||||||
class _DummyTransport:
|
class _DummyTransport:
|
||||||
async def complete(self, *, messages, source, stream, overlay, call_id):
|
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +124,18 @@ class _DummyRecorder:
|
|||||||
reasoning_tokens,
|
reasoning_tokens,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
meta,
|
meta,
|
||||||
|
thinking_observation,
|
||||||
|
reasoning_effort,
|
||||||
|
scope,
|
||||||
|
operation,
|
||||||
|
logical_call_id,
|
||||||
|
event_kind,
|
||||||
|
http_status_code,
|
||||||
|
error_type,
|
||||||
|
cause_type,
|
||||||
|
error_body,
|
||||||
|
attempts,
|
||||||
|
total_latency_ms,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +157,56 @@ def test_protocols_are_runtime_checkable(impl, protocol):
|
|||||||
assert isinstance(impl, protocol)
|
assert isinstance(impl, protocol)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasoningTierIsOnlyOnTheChatPort:
|
||||||
|
"""档位属于 chat 端口,且**只属于**它(Task 5b)。
|
||||||
|
|
||||||
|
`@runtime_checkable` 只查方法名不查签名,故协议签名本身必须被显式断言——
|
||||||
|
否则实现漏改一个参数,要到运行期调用才会以 `TypeError` 现形,而那时的现场
|
||||||
|
离根因已经很远。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_chat_transport_carries_the_per_call_tier(self):
|
||||||
|
params = inspect.signature(Transport.complete).parameters
|
||||||
|
assert "reasoning_effort" in params
|
||||||
|
# 不给默认值是有意的(与 TelemetryRecorder 同一既有约定): 库外无第三方
|
||||||
|
# 实现者,写全签名成本为零,而默认值会把"漏传"变成静默的"不表态"
|
||||||
|
assert params["reasoning_effort"].default is inspect.Parameter.empty
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("protocol", "method"),
|
||||||
|
[
|
||||||
|
(EmbeddingTransport, "embed"),
|
||||||
|
(OcrTransport, "recognize_text"),
|
||||||
|
(OcrTransport, "parse_layout"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_other_transports_have_no_reasoning_tier(self, protocol, method):
|
||||||
|
"""embedding 与 OCR 没有推理语义,给它们加档位只会静默无效(issue #4 同款决策)。"""
|
||||||
|
assert "reasoning_effort" not in inspect.signature(getattr(protocol, method)).parameters
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyStatusProvider(_DummyRecorder):
|
||||||
|
@property
|
||||||
|
def telemetry_status(self) -> TelemetryStatus:
|
||||||
|
return TelemetryStatus(
|
||||||
|
degraded=False,
|
||||||
|
fatal=False,
|
||||||
|
reason=None,
|
||||||
|
degraded_for_s=None,
|
||||||
|
dropped_rows=0,
|
||||||
|
retry_after_s=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_provider_is_a_separate_optional_port():
|
||||||
|
"""状态**不得**并进 TelemetryRecorder: 那会让只实现 record_llm_call 的对象
|
||||||
|
当场不再满足 @runtime_checkable 的结构检查(设计 §3.3,Codex 审查)。"""
|
||||||
|
assert isinstance(_DummyStatusProvider(), TelemetryStatusProvider)
|
||||||
|
assert isinstance(_DummyStatusProvider(), TelemetryRecorder)
|
||||||
|
assert not isinstance(_DummyRecorder(), TelemetryStatusProvider)
|
||||||
|
assert isinstance(_DummyRecorder(), TelemetryRecorder) # 这条断言是那条决策的执法点
|
||||||
|
|
||||||
|
|
||||||
def _decision(**overrides) -> GateDecision:
|
def _decision(**overrides) -> GateDecision:
|
||||||
base = {
|
base = {
|
||||||
"source_name": "qwen_1",
|
"source_name": "qwen_1",
|
||||||
@@ -221,7 +287,48 @@ class TestTelemetryRecorderSignature:
|
|||||||
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||||
assert {"tenant_id", "meta"} <= set(params)
|
assert {"tenant_id", "meta"} <= set(params)
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", ["tenant_id", "meta"])
|
def test_call_observability_fields_are_declared(self):
|
||||||
|
"""1.3.5 十列进协议(issue #19/#23);字段总数以实测为准不凭记忆。
|
||||||
|
|
||||||
|
本签名同时是装配闸的事实源(`_assert_recorder_shape` 按它派生参数名),
|
||||||
|
故它与实现一旦漂移,下游自定义 recorder 会在装配期就被拒。
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||||
|
assert {
|
||||||
|
"scope",
|
||||||
|
"operation",
|
||||||
|
"logical_call_id",
|
||||||
|
"event_kind",
|
||||||
|
"http_status_code",
|
||||||
|
"error_type",
|
||||||
|
"cause_type",
|
||||||
|
"error_body",
|
||||||
|
"attempts",
|
||||||
|
"total_latency_ms",
|
||||||
|
} <= set(params)
|
||||||
|
assert len(params) - 1 == 36 # 减掉 self
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
[
|
||||||
|
"tenant_id",
|
||||||
|
"meta",
|
||||||
|
"thinking_observation",
|
||||||
|
"reasoning_effort",
|
||||||
|
"scope",
|
||||||
|
"operation",
|
||||||
|
"logical_call_id",
|
||||||
|
"event_kind",
|
||||||
|
"http_status_code",
|
||||||
|
"error_type",
|
||||||
|
"cause_type",
|
||||||
|
"error_body",
|
||||||
|
"attempts",
|
||||||
|
"total_latency_ms",
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_caller_dimensions_have_no_default(self, name):
|
def test_caller_dimensions_have_no_default(self, name):
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ def _source(model="qwen-max"):
|
|||||||
class TestEmitterCost:
|
class TestEmitterCost:
|
||||||
async def test_success_row_costed(self):
|
async def test_success_row_costed(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
|
||||||
await emitter.emit_attempt(
|
await emitter.emit_attempt(
|
||||||
request=_REQ,
|
request=_REQ,
|
||||||
source=_source(),
|
source=_source(),
|
||||||
@@ -177,18 +177,20 @@ class TestEmitterCost:
|
|||||||
latency_ms=1,
|
latency_ms=1,
|
||||||
response=_resp(),
|
response=_resp(),
|
||||||
error=None,
|
error=None,
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
assert rec.rows[0]["cost"] == pytest.approx(7.2)
|
assert rec.rows[0]["cost"] == pytest.approx(7.2)
|
||||||
|
|
||||||
async def test_cache_hit_row_costs_zero(self):
|
async def test_cache_hit_row_costs_zero(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
|
||||||
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True))
|
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True), operation="chat")
|
||||||
assert rec.rows[0]["cost"] == 0.0
|
assert rec.rows[0]["cost"] == 0.0
|
||||||
|
|
||||||
async def test_failure_row_cost_none(self):
|
async def test_failure_row_cost_none(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
|
||||||
await emitter.emit_attempt(
|
await emitter.emit_attempt(
|
||||||
request=_REQ,
|
request=_REQ,
|
||||||
source=_source(),
|
source=_source(),
|
||||||
@@ -196,12 +198,14 @@ class TestEmitterCost:
|
|||||||
latency_ms=1,
|
latency_ms=1,
|
||||||
response=None,
|
response=None,
|
||||||
error="TransientError: boom",
|
error="TransientError: boom",
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
assert rec.rows[0]["cost"] is None
|
assert rec.rows[0]["cost"] is None
|
||||||
|
|
||||||
async def test_unknown_model_none_without_blocking(self):
|
async def test_unknown_model_none_without_blocking(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
|
||||||
await emitter.emit_attempt(
|
await emitter.emit_attempt(
|
||||||
request=_REQ,
|
request=_REQ,
|
||||||
source=_source(model="mystery"),
|
source=_source(model="mystery"),
|
||||||
@@ -209,13 +213,15 @@ class TestEmitterCost:
|
|||||||
latency_ms=1,
|
latency_ms=1,
|
||||||
response=_resp(model="mystery"),
|
response=_resp(model="mystery"),
|
||||||
error=None,
|
error=None,
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
assert rec.rows[0]["cost"] is None
|
assert rec.rows[0]["cost"] is None
|
||||||
|
|
||||||
async def test_no_pricing_keeps_none(self):
|
async def test_no_pricing_keeps_none(self):
|
||||||
"""未注入价格表 = M1 现状: cost 恒 None(回归)。"""
|
"""未注入价格表 = M1 现状: cost 恒 None(回归)。"""
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
emitter = TelemetryEmitter(rec, text_cap=None)
|
emitter = TelemetryEmitter(rec, text_cap=None, scope="LLM")
|
||||||
await emitter.emit_attempt(
|
await emitter.emit_attempt(
|
||||||
request=_REQ,
|
request=_REQ,
|
||||||
source=_source(),
|
source=_source(),
|
||||||
@@ -223,5 +229,7 @@ class TestEmitterCost:
|
|||||||
latency_ms=1,
|
latency_ms=1,
|
||||||
response=_resp(),
|
response=_resp(),
|
||||||
error=None,
|
error=None,
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
assert rec.rows[0]["cost"] is None
|
assert rec.rows[0]["cost"] is None
|
||||||
|
|||||||
+91
-118
@@ -1,63 +1,114 @@
|
|||||||
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。
|
||||||
|
|
||||||
|
2026-09-04 起 profile 存的是 `ThinkingWire`(off / on_base / effort_key)而非两个
|
||||||
|
固定片段——档位型模型(GLM-5.3、kimi-k3、deepseek-v4…)的"开"档需要附一个档位值,
|
||||||
|
两个固定片段表达不了。
|
||||||
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from polygateway.providers import (
|
from polygateway.providers import (
|
||||||
DEFAULT_CAPABILITIES,
|
|
||||||
DEFAULT_PROFILES,
|
DEFAULT_PROFILES,
|
||||||
ProviderProfile,
|
ProviderProfile,
|
||||||
ThinkingCapability,
|
ThinkingWire,
|
||||||
get_capability,
|
|
||||||
get_provider,
|
get_provider,
|
||||||
register_capability,
|
|
||||||
register_provider,
|
register_provider,
|
||||||
resolve_thinking,
|
)
|
||||||
|
|
||||||
|
_EXPECTED_SEGMENTS = frozenset(
|
||||||
|
{"qwen", "deepseek", "zhipu", "moonshot", "minimax", "openai", "anthropic", "google"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultProfiles:
|
class TestDefaultProfiles:
|
||||||
def test_qwen_profile(self):
|
def test_all_eight_profiles_registered(self):
|
||||||
p = get_provider("qwen")
|
"""issue #20: 智谱缺段,下游只能把 GLM 挂在 openai 兜底段下再手写 extra_body。"""
|
||||||
assert p.thinking_on == {"enable_thinking": True}
|
assert set(DEFAULT_PROFILES) == _EXPECTED_SEGMENTS
|
||||||
assert p.thinking_off == {"enable_thinking": False}
|
|
||||||
assert p.strip_think_tags is True
|
|
||||||
assert p.supports_native_schema is False
|
|
||||||
|
|
||||||
def test_deepseek_profile(self):
|
def test_qwen_is_a_switch_with_no_tiers(self):
|
||||||
p = get_provider("deepseek")
|
w = get_provider("qwen").thinking
|
||||||
assert p.thinking_on == {"thinking": {"type": "enabled"}}
|
assert w.on_base == {"enable_thinking": True}
|
||||||
assert p.thinking_off == {"thinking": {"type": "disabled"}}
|
assert w.off == {"enable_thinking": False}
|
||||||
assert p.strip_think_tags is False
|
assert w.effort_key is None # 百炼靠 thinking_budget 调深度,不是档位
|
||||||
|
assert get_provider("qwen").strip_think_tags is True
|
||||||
|
|
||||||
def test_openai_slots_are_unknown_not_empty(self):
|
def test_deepseek_carries_both_switch_and_tier(self):
|
||||||
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此),
|
w = get_provider("deepseek").thinking
|
||||||
|
assert w.on_base == {"thinking": {"type": "enabled"}}
|
||||||
|
assert w.off == {"thinking": {"type": "disabled"}}
|
||||||
|
assert w.effort_key == "reasoning_effort"
|
||||||
|
|
||||||
故不能下发任何厂商方言参数。None = 形态未知 → 配了 enable_thinking 即报错,
|
def test_zhipu_matches_the_vendor_migration_note(self):
|
||||||
而不是空字典那种"注入了个寂寞"的静默失效。
|
"""智谱官方: thinking.type=enabled + reasoning_effort 才是 GLM-5.3 的正确形态。"""
|
||||||
|
w = get_provider("zhipu").thinking
|
||||||
|
assert w.on_base == {"thinking": {"type": "enabled"}}
|
||||||
|
assert w.off == {"thinking": {"type": "disabled"}}
|
||||||
|
assert w.effort_key == "reasoning_effort"
|
||||||
|
|
||||||
|
def test_openai_family_sends_the_standard_field_only(self):
|
||||||
|
"""gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。
|
||||||
|
|
||||||
|
minimax 2026-09-05 起不在本组: 它的形态相同,但"开"这一档被迫带上了一个
|
||||||
|
档位值(见 `test_minimax_on_tier_carries_a_tier_value`)。
|
||||||
"""
|
"""
|
||||||
p = get_provider("openai")
|
for name in ("openai", "anthropic", "google"):
|
||||||
assert p.thinking_on is None and p.thinking_off is None
|
w = get_provider(name).thinking
|
||||||
assert p.strip_think_tags is False
|
assert w.on_base == {}, name
|
||||||
|
assert w.off == {"reasoning_effort": "none"}, name
|
||||||
|
assert w.effort_key == "reasoning_effort", name
|
||||||
|
|
||||||
def test_minimax_profile_uses_reasoning_effort(self):
|
def test_minimax_on_does_not_select_a_tier(self):
|
||||||
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。"""
|
"""形态不代替模型能力,也不替调用者选择付费档位。"""
|
||||||
p = get_provider("minimax")
|
w = get_provider("minimax").thinking
|
||||||
assert p.thinking_off == {"reasoning_effort": "none"}
|
assert w.on_base == {}
|
||||||
assert p.thinking_on == {"reasoning_effort": "medium"}
|
assert w.off == {"reasoning_effort": "none"}
|
||||||
assert p.strip_think_tags is False
|
assert w.effort_key == "reasoning_effort"
|
||||||
|
|
||||||
def test_unknown_provider_fails_loudly(self):
|
def test_unknown_provider_fails_loudly(self):
|
||||||
"""消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。"""
|
"""消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。"""
|
||||||
with pytest.raises(ValueError, match="glm"):
|
with pytest.raises(ValueError, match="glm"):
|
||||||
get_provider("glm")
|
get_provider("glm") # 段名是 zhipu,不是 glm
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
get_provider("qwen2") # 子串相似也不放行
|
get_provider("qwen2") # 子串相似也不放行
|
||||||
|
|
||||||
|
def test_error_lists_every_registered_segment(self):
|
||||||
|
with pytest.raises(ValueError, match="zhipu"):
|
||||||
|
get_provider("nope")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWireNoneSemantics:
|
||||||
|
"""三个 `None` 语义互不重叠(issue #5 的成果,不可退回成"注入了个寂寞")。"""
|
||||||
|
|
||||||
|
def test_on_base_none_means_shape_unknown(self):
|
||||||
|
w = ThinkingWire(off=None, on_base=None, effort_key=None)
|
||||||
|
assert w.on_base is None
|
||||||
|
|
||||||
|
def test_off_none_means_no_off_shape(self):
|
||||||
|
"""有开启形态但没有关闭形态,与"整个形态未知"是两回事。"""
|
||||||
|
w = ThinkingWire(off=None, on_base={"x": 1}, effort_key=None)
|
||||||
|
assert w.on_base is not None and w.off is None
|
||||||
|
|
||||||
|
def test_effort_key_none_means_switch_only(self):
|
||||||
|
"""qwen 是这一档: 能开能关,但没有档位可谈。"""
|
||||||
|
assert get_provider("qwen").thinking.effort_key is None
|
||||||
|
|
||||||
|
def test_empty_on_base_is_not_none(self):
|
||||||
|
"""`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。
|
||||||
|
|
||||||
|
样本 2026-09-05 由 minimax 换成 openai: minimax 的 `on_base` 因 issue #21
|
||||||
|
改回带值,不再是空片段;openai 段是现存 `{}` 语义的代表。
|
||||||
|
"""
|
||||||
|
w = get_provider("openai").thinking
|
||||||
|
assert w.on_base == {} and w.on_base is not None
|
||||||
|
|
||||||
|
|
||||||
class TestPureFunctionRegistration:
|
class TestPureFunctionRegistration:
|
||||||
def test_register_returns_new_mapping(self):
|
def test_register_returns_new_mapping(self):
|
||||||
glm = ProviderProfile(name="glm", thinking_on={}, thinking_off={}, strip_think_tags=False)
|
glm = ProviderProfile(
|
||||||
|
name="glm",
|
||||||
|
thinking=ThinkingWire(off={}, on_base={}, effort_key=None),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
table = register_provider(glm)
|
table = register_provider(glm)
|
||||||
assert get_provider("glm", registry=table) is glm
|
assert get_provider("glm", registry=table) is glm
|
||||||
# 默认表未被污染(无可变全局状态铁律)
|
# 默认表未被污染(无可变全局状态铁律)
|
||||||
@@ -65,93 +116,15 @@ class TestPureFunctionRegistration:
|
|||||||
get_provider("glm")
|
get_provider("glm")
|
||||||
|
|
||||||
def test_register_on_custom_base_and_override(self):
|
def test_register_on_custom_base_and_override(self):
|
||||||
custom_qwen = ProviderProfile(
|
custom = ProviderProfile(
|
||||||
name="qwen", thinking_on={"x": 1}, thinking_off={}, strip_think_tags=False
|
name="qwen",
|
||||||
|
thinking=ThinkingWire(off={}, on_base={"x": 1}, effort_key=None),
|
||||||
|
strip_think_tags=False,
|
||||||
)
|
)
|
||||||
table = register_provider(custom_qwen, base=DEFAULT_PROFILES)
|
table = register_provider(custom, base=DEFAULT_PROFILES)
|
||||||
assert get_provider("qwen", registry=table).thinking_on == {"x": 1}
|
assert get_provider("qwen", registry=table).thinking.on_base == {"x": 1}
|
||||||
assert get_provider("qwen").thinking_on == {"enable_thinking": True}
|
assert get_provider("qwen").thinking.on_base == {"enable_thinking": True}
|
||||||
|
|
||||||
def test_default_profiles_mapping_is_read_only(self):
|
def test_default_profiles_mapping_is_read_only(self):
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
|
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
def _warnings():
|
|
||||||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
|
||||||
messages: list[str] = []
|
|
||||||
sink_id = logger.add(messages.append, level="WARNING")
|
|
||||||
return messages, sink_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestThinkingCapability:
|
|
||||||
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
|
|
||||||
|
|
||||||
def test_registered_models_carry_evidence(self):
|
|
||||||
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
|
|
||||||
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
|
|
||||||
cap = get_capability(model)
|
|
||||||
assert cap is not None and cap.evidence.strip()
|
|
||||||
|
|
||||||
def test_m3_can_disable_but_m2x_cannot(self):
|
|
||||||
assert get_capability("MiniMax-M3").can_disable is True
|
|
||||||
assert get_capability("MiniMax-M2.7").can_disable is False
|
|
||||||
assert get_capability("MiniMax-M2.5").can_disable is False
|
|
||||||
|
|
||||||
def test_unregistered_model_is_unknown(self):
|
|
||||||
assert get_capability("some-brand-new-model") is None
|
|
||||||
|
|
||||||
def test_register_capability_is_pure(self):
|
|
||||||
table = register_capability("x-1", ThinkingCapability(True, "实测"))
|
|
||||||
assert get_capability("x-1", table=table) is not None
|
|
||||||
assert get_capability("x-1") is None # 默认表未被污染
|
|
||||||
|
|
||||||
def test_default_capabilities_mapping_is_read_only(self):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveThinking:
|
|
||||||
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
|
|
||||||
|
|
||||||
def test_rule1_none_injects_nothing(self):
|
|
||||||
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
|
|
||||||
assert got == {}
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("enable", [True, False])
|
|
||||||
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
|
|
||||||
with pytest.raises(ValueError, match="register_provider") as exc:
|
|
||||||
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
|
|
||||||
assert "extra_body" in str(exc.value)
|
|
||||||
|
|
||||||
def test_rule3_unregistered_model_warns_but_passes(self):
|
|
||||||
messages, sink_id = _warnings()
|
|
||||||
try:
|
|
||||||
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
|
|
||||||
finally:
|
|
||||||
logger.remove(sink_id)
|
|
||||||
assert got == {"reasoning_effort": "none"}
|
|
||||||
assert any("MiniMax-M9" in m for m in messages)
|
|
||||||
|
|
||||||
def test_rule4_cannot_disable_raises_with_the_model_name(self):
|
|
||||||
cap = get_capability("MiniMax-M2.7")
|
|
||||||
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
|
||||||
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
|
|
||||||
|
|
||||||
def test_rule4_only_blocks_the_off_direction(self):
|
|
||||||
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
|
|
||||||
cap = get_capability("MiniMax-M2.7")
|
|
||||||
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
|
|
||||||
assert got == {"reasoning_effort": "medium"}
|
|
||||||
|
|
||||||
def test_rule5_normal_path(self):
|
|
||||||
cap = get_capability("MiniMax-M3")
|
|
||||||
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
|
|
||||||
"reasoning_effort": "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_unknown_shape_beats_capability_check(self):
|
|
||||||
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
|
|
||||||
cap = ThinkingCapability(can_disable=False, evidence="构造")
|
|
||||||
with pytest.raises(ValueError, match="register_provider"):
|
|
||||||
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
|
|
||||||
|
|||||||
@@ -261,6 +261,157 @@ class TestUsageErrors:
|
|||||||
|
|
||||||
assert result.returncode == 1
|
assert result.returncode == 1
|
||||||
|
|
||||||
|
# --- --table 的参数分类(issue #18 设计 §4.2);真实解析行为在集成层验 ---
|
||||||
|
|
||||||
|
def test_sqlite_with_table_exits_one(self, tmp_path):
|
||||||
|
"""SQLite 库文件即目标,无 schema 概念,故 `--table` 在该分支无歧义可消。"""
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"sqlite",
|
||||||
|
"--path",
|
||||||
|
str(tmp_path / "x.db"),
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--table",
|
||||||
|
"some_schema.llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "--table" in result.stderr
|
||||||
|
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||||
|
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||||
|
assert "unrecognized" not in result.stderr
|
||||||
|
|
||||||
|
def test_table_without_schema_qualifier_exits_one(self):
|
||||||
|
"""单段等于没声明: 目标仍由 `search_path` 决定,隐式性原样保留,故拒绝。"""
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
"postgresql://x/y",
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--table",
|
||||||
|
"llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "--table" in result.stderr
|
||||||
|
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||||
|
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||||
|
assert "unrecognized" not in result.stderr
|
||||||
|
|
||||||
|
def test_table_with_empty_segment_exits_one(self):
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
"postgresql://x/y",
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--table",
|
||||||
|
".llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "--table" in result.stderr
|
||||||
|
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||||
|
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||||
|
assert "unrecognized" not in result.stderr
|
||||||
|
|
||||||
|
def test_table_with_quote_in_a_segment_exits_one(self):
|
||||||
|
"""含引号的复杂标识符不支持: 此时退回不给 `--table` 的路径(见 epilog)。"""
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
"postgresql://x/y",
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--table",
|
||||||
|
'sch"ema.llm_calls',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "--table" in result.stderr
|
||||||
|
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||||
|
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||||
|
assert "unrecognized" not in result.stderr
|
||||||
|
|
||||||
|
def test_table_naming_another_table_exits_one(self):
|
||||||
|
"""表名段锁死: 不加这条,`--table` 会把本脚本扩成"任意同形表删除工具"。"""
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
"postgresql://x/y",
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--table",
|
||||||
|
"audit.events",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "--table" in result.stderr
|
||||||
|
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||||
|
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||||
|
assert "unrecognized" not in result.stderr
|
||||||
|
# 错误消息要当场把边界说清: 本脚本的作用域到 llm_calls 为止
|
||||||
|
assert "llm_calls" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestTableIdentifierWhitelist:
|
||||||
|
"""schema 段只收普通标识符: 让 `--help` 说的"不支持复杂标识符"成为事实。
|
||||||
|
|
||||||
|
这不是安全边界(`to_regclass($1)` 参数化 + `_quote` 转义,注入面本就不存在),
|
||||||
|
是**契约边界**: 帮助文本写着不支持,实现却照单全收,受害的是照文档做判断的人。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_schema_with_a_space_exits_one(self):
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
"postgresql://x/y",
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--table",
|
||||||
|
"bad schema.llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "--table" in result.stderr
|
||||||
|
|
||||||
|
def test_schema_with_a_semicolon_exits_one(self):
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
"postgresql://x/y",
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--table",
|
||||||
|
"a;b.llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "--table" in result.stderr
|
||||||
|
|
||||||
|
def test_a_plain_identifier_with_underscores_and_digits_is_accepted(self):
|
||||||
|
"""收紧不得误伤正常名字: 这条走到连接阶段才失败(退出 2),说明校验放行了。"""
|
||||||
|
result = _run(
|
||||||
|
"--backend",
|
||||||
|
"postgres",
|
||||||
|
"--dsn",
|
||||||
|
"postgresql://127.0.0.1:1/nope",
|
||||||
|
"--older-than-days",
|
||||||
|
"7",
|
||||||
|
"--table",
|
||||||
|
"pgw_s_a1b2c3.llm_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 2
|
||||||
|
|
||||||
|
|
||||||
class TestHelp:
|
class TestHelp:
|
||||||
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
|
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
|
||||||
@@ -271,3 +422,12 @@ class TestHelp:
|
|||||||
assert "维护角色" in result.stdout
|
assert "维护角色" in result.stdout
|
||||||
assert "REVOKE" in result.stdout
|
assert "REVOKE" in result.stdout
|
||||||
assert "PARTITION" in result.stdout
|
assert "PARTITION" in result.stdout
|
||||||
|
|
||||||
|
def test_help_states_the_table_name_is_fixed(self):
|
||||||
|
"""`--table` 只有 schema 一段可变,这条边界必须写在运维会读到的地方。"""
|
||||||
|
result = _run("--help")
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "--table" in result.stdout
|
||||||
|
assert "只清理" in result.stdout
|
||||||
|
assert "llm_calls" in result.stdout
|
||||||
|
|||||||
+150
-2
@@ -5,6 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -24,9 +25,11 @@ from polygateway.types import (
|
|||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
BreakerConfig,
|
BreakerConfig,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
|
Effort,
|
||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
ThinkingObservation,
|
||||||
TransportResult,
|
TransportResult,
|
||||||
)
|
)
|
||||||
from tests.contracts.conftest import FakeClock
|
from tests.contracts.conftest import FakeClock
|
||||||
@@ -62,14 +65,21 @@ def _ok(content="ok"):
|
|||||||
|
|
||||||
|
|
||||||
class FakeTransport:
|
class FakeTransport:
|
||||||
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id)。"""
|
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id) 与收到的档位。
|
||||||
|
|
||||||
|
`reasoning_effort` 刻意**不给默认值**,与 `Transport` 协议保持逐字一致:
|
||||||
|
`@runtime_checkable` 只查方法名不查签名,fake 上多一个默认值就会把"中间件漏传"
|
||||||
|
这类缺口伪装成"调用方没表态",而报错现场离根因很远。
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, script):
|
def __init__(self, script):
|
||||||
self.script = list(script)
|
self.script = list(script)
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
self.efforts = []
|
||||||
|
|
||||||
async def complete(self, *, messages, source, stream, overlay, call_id):
|
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
||||||
self.calls.append((source.name, call_id))
|
self.calls.append((source.name, call_id))
|
||||||
|
self.efforts.append(reasoning_effort)
|
||||||
action = self.script.pop(0)
|
action = self.script.pop(0)
|
||||||
if isinstance(action, Exception):
|
if isinstance(action, Exception):
|
||||||
raise action
|
raise action
|
||||||
@@ -134,6 +144,37 @@ def _harness(
|
|||||||
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
|
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequestTierReachesTransport:
|
||||||
|
"""请求级档位必须一路穿过洋葱到达 transport(Task 5b)。
|
||||||
|
|
||||||
|
`ChatRequest` 上填了字段而中间件不搬运,是"看起来配了、实际没发出去"的静默
|
||||||
|
失效——正是 issue #20 里下游改用 extra_body 绕过治理的成因。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_request_tier_reaches_transport(self):
|
||||||
|
mw, _, _, transport, *_ = _harness([_src("a")], [_ok()])
|
||||||
|
await mw(
|
||||||
|
ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.HIGH)
|
||||||
|
)
|
||||||
|
assert transport.efforts == [Effort.HIGH]
|
||||||
|
|
||||||
|
async def test_absent_tier_is_carried_as_none(self):
|
||||||
|
"""不表态也要显式传下去: 漏传与"传了 None"在协议上必须区分不开才安全。"""
|
||||||
|
mw, _, _, transport, *_ = _harness([_src("a")], [_ok()])
|
||||||
|
await mw(_REQ)
|
||||||
|
assert transport.efforts == [None]
|
||||||
|
|
||||||
|
async def test_tier_is_carried_on_every_retry_attempt(self):
|
||||||
|
"""换源重试时档位不得在第二次尝试上丢失。"""
|
||||||
|
mw, _, _, transport, *_ = _harness(
|
||||||
|
[_src("a"), _src("b")], [TransientError("boom", source_name="a"), _ok()]
|
||||||
|
)
|
||||||
|
await mw(
|
||||||
|
ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.LOW)
|
||||||
|
)
|
||||||
|
assert transport.efforts == [Effort.LOW, Effort.LOW]
|
||||||
|
|
||||||
|
|
||||||
class TestSuccessPath:
|
class TestSuccessPath:
|
||||||
async def test_first_attempt_success_builds_response(self):
|
async def test_first_attempt_success_builds_response(self):
|
||||||
mw, limiter, gate, transport, sleep, _ = _harness([_src("a")], [_ok("hello")])
|
mw, limiter, gate, transport, sleep, _ = _harness([_src("a")], [_ok("hello")])
|
||||||
@@ -225,6 +266,56 @@ class TestObservabilityPassthrough:
|
|||||||
assert resp.cached_prompt_tokens is None and resp.model_reported is None
|
assert resp.cached_prompt_tokens is None and resp.model_reported is None
|
||||||
assert resp.reasoning_tokens is None
|
assert resp.reasoning_tokens is None
|
||||||
|
|
||||||
|
async def test_thinking_observation_reaches_the_response(self):
|
||||||
|
"""issue #16/#17: 裁定归 transport,中间件只透传,不得在途中改判。"""
|
||||||
|
result = TransportResult(
|
||||||
|
content="ok",
|
||||||
|
thinking="想一下",
|
||||||
|
prompt_tokens=10,
|
||||||
|
completion_tokens=5,
|
||||||
|
usage_source="measured",
|
||||||
|
ttft_ms=12.0,
|
||||||
|
max_inter_token_ms=3.0,
|
||||||
|
raw={},
|
||||||
|
thinking_observation=ThinkingObservation.OBSERVED,
|
||||||
|
)
|
||||||
|
mw, *_ = _harness([_src("a")], [result])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
async def test_unjudged_transport_result_stays_unknown(self):
|
||||||
|
"""不裁定的 transport(如 OCR)透传出来仍是 UNKNOWN,不被默认成 ABSENT。"""
|
||||||
|
mw, *_ = _harness([_src("a")], [_ok()])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
async def test_applied_tier_reaches_the_response(self):
|
||||||
|
"""issue #20: 实际发出的档由 transport 裁定,本层只搬运。
|
||||||
|
|
||||||
|
搬运这一步漏掉,`LLMResponse.applied_effort` 恒为 None,而遥测正是从这个
|
||||||
|
字段取"这一行跑在哪档"——整列会静默地全是 NULL。
|
||||||
|
"""
|
||||||
|
result = TransportResult(
|
||||||
|
content="ok",
|
||||||
|
thinking="",
|
||||||
|
prompt_tokens=10,
|
||||||
|
completion_tokens=5,
|
||||||
|
usage_source="measured",
|
||||||
|
ttft_ms=12.0,
|
||||||
|
max_inter_token_ms=3.0,
|
||||||
|
raw={},
|
||||||
|
applied_effort=Effort.HIGH,
|
||||||
|
)
|
||||||
|
mw, *_ = _harness([_src("a")], [result])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.applied_effort is Effort.HIGH
|
||||||
|
|
||||||
|
async def test_unstated_tier_stays_none(self):
|
||||||
|
"""不表态的调用不得被填成某个档: 那等于替调用方声称它做过一个选择。"""
|
||||||
|
mw, *_ = _harness([_src("a")], [_ok()])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.applied_effort is None
|
||||||
|
|
||||||
|
|
||||||
class TestRetryAndFailover:
|
class TestRetryAndFailover:
|
||||||
async def test_transient_switches_source_then_succeeds(self):
|
async def test_transient_switches_source_then_succeeds(self):
|
||||||
@@ -686,3 +777,60 @@ class TestRateLimitPushback:
|
|||||||
await mw(_REQ)
|
await mw(_REQ)
|
||||||
assert ei.value.reason == "retry_exhausted"
|
assert ei.value.reason == "retry_exhausted"
|
||||||
assert len(transport.calls) == 3
|
assert len(transport.calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogicalAttemptCounting:
|
||||||
|
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
|
||||||
|
|
||||||
|
登记点若挪到成功之后,失败与取消的尝试就会从计数里消失——而那正是
|
||||||
|
诊断时最需要看见的几次。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _ctx(self, clock):
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
return _CallContext(now=clock)
|
||||||
|
|
||||||
|
async def test_single_success_counts_one(self):
|
||||||
|
mw, _, _, _, _, clock = _harness([_src("a")], [_ok()])
|
||||||
|
ctx = self._ctx(clock)
|
||||||
|
await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||||
|
assert ctx.snapshot().attempts == 1
|
||||||
|
|
||||||
|
async def test_failed_retries_are_counted(self):
|
||||||
|
"""两次可重试失败 + 一次成功 = 3 次尝试,不是 1 次。"""
|
||||||
|
mw, _, _, transport, _, clock = _harness(
|
||||||
|
[_src("a")], [TransientError("t1"), TransientError("t2"), _ok()]
|
||||||
|
)
|
||||||
|
ctx = self._ctx(clock)
|
||||||
|
await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||||
|
assert ctx.snapshot().attempts == 3 == len(transport.calls)
|
||||||
|
|
||||||
|
async def test_budget_free_429_still_counts_as_an_attempt(self):
|
||||||
|
"""429 免的是重试预算,不是"没发生过"——它确实打到了网关。"""
|
||||||
|
mw, _, _, transport, _, clock = _harness(
|
||||||
|
[_src("a")],
|
||||||
|
[
|
||||||
|
TransientError("t1", status_code=429, retry_after_s=1.0),
|
||||||
|
TransientError("t2", status_code=429, retry_after_s=1.0),
|
||||||
|
_ok(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
ctx = self._ctx(clock)
|
||||||
|
await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||||
|
assert ctx.snapshot().attempts == 3 == len(transport.calls)
|
||||||
|
|
||||||
|
async def test_retry_exhausted_counts_every_attempt(self):
|
||||||
|
mw, _, _, transport, _, clock = _harness(
|
||||||
|
[_src("a")], [TransientError(str(i)) for i in range(5)], max_attempts=3
|
||||||
|
)
|
||||||
|
ctx = self._ctx(clock)
|
||||||
|
with pytest.raises(AllSourcesExhausted):
|
||||||
|
await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||||
|
assert ctx.snapshot().attempts == 3 == len(transport.calls)
|
||||||
|
|
||||||
|
async def test_absent_context_does_not_break_the_call(self):
|
||||||
|
"""库内现场构造的 `ChatRequest` 没有上下文,不得因此报错(设计 §3.5)。"""
|
||||||
|
mw, _, _, _, _, _ = _harness([_src("a")], [_ok()])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.content == "ok" and _REQ.call_context is None
|
||||||
|
|||||||
+1856
-88
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,924 @@
|
|||||||
|
"""推理裁定与对账的行为测试(issue #16/#17 设计 §4-§5)。
|
||||||
|
|
||||||
|
判据来自 2026-08-25 实测(findings): MiniMax-M3 在开启档流式路径下返回 185 字符
|
||||||
|
推理正文却不上报 `completion_tokens_details`,而 qwen/deepseek 两者都报。库因此
|
||||||
|
不能把任何单一信号当权威——本组用例逐条钉死"哪个信号该赢"。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.providers import ProviderProfile, ThinkingWire, get_provider
|
||||||
|
from polygateway.thinking import (
|
||||||
|
DEFAULT_CAPABILITIES,
|
||||||
|
ThinkingCapability,
|
||||||
|
ThinkingUnsupportedError,
|
||||||
|
effective_effort,
|
||||||
|
get_capability,
|
||||||
|
observe_thinking,
|
||||||
|
reconcile_thinking,
|
||||||
|
register_capability,
|
||||||
|
resolve_thinking,
|
||||||
|
)
|
||||||
|
from polygateway.types import Effort, ThinkingObservation
|
||||||
|
|
||||||
|
_MYSTERY = ProviderProfile(
|
||||||
|
name="mystery",
|
||||||
|
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
"""形态完全未知的 provider(issue #5 的守卫对象)。
|
||||||
|
|
||||||
|
2026-09-04 起默认表 8 段全部有形态,故未知样本改为显式构造——测的是**机制**
|
||||||
|
(不知道怎么表达就报错并指路),不是某个段当时的配置。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _warnings():
|
||||||
|
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
return messages, sink_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveThinking:
|
||||||
|
"""三态裁定: 证据硬度决定优先级,无信号一律 UNKNOWN。"""
|
||||||
|
|
||||||
|
def test_reasoning_text_alone_proves_it_happened(self):
|
||||||
|
"""推理正文是事实本身: 上游不报 token 数也照样成立(M3 流式实测形态)。"""
|
||||||
|
assert (
|
||||||
|
observe_thinking(thinking="先解方程 x+y=35", reasoning_tokens=None)
|
||||||
|
is ThinkingObservation.OBSERVED
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_blank_text_is_not_evidence(self):
|
||||||
|
"""纯空白正文不算证据: 网关响应是外部输入,truthy 判据会把空格计成推理(P5)。"""
|
||||||
|
assert (
|
||||||
|
observe_thinking(thinking=" \n\t ", reasoning_tokens=None)
|
||||||
|
is ThinkingObservation.UNKNOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_positive_token_count_proves_it_happened(self):
|
||||||
|
"""无正文但上游报了推理用量(qwen 非流式形态)。"""
|
||||||
|
assert observe_thinking(thinking="", reasoning_tokens=205) is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
|
def test_zero_token_count_is_positive_evidence_of_absence(self):
|
||||||
|
"""`0` 是"上报了且为零",与"没上报"语义不同,故是 ABSENT 而非 UNKNOWN。"""
|
||||||
|
assert observe_thinking(thinking="", reasoning_tokens=0) is ThinkingObservation.ABSENT
|
||||||
|
|
||||||
|
def test_no_signal_at_all_stays_unknown(self):
|
||||||
|
"""M3 非流式开启档的真实形态: 推理已计费却既无正文也无 token 数。
|
||||||
|
|
||||||
|
判成 ABSENT 就是伪装成"没推理"——正是 issue #16/#17 的病根。
|
||||||
|
"""
|
||||||
|
assert observe_thinking(thinking="", reasoning_tokens=None) is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
def test_text_outranks_a_zero_count(self):
|
||||||
|
"""转述与事实冲突时事实赢: 正文在,`reasoning_tokens=0` 不能翻案。"""
|
||||||
|
assert (
|
||||||
|
observe_thinking(thinking="想了想", reasoning_tokens=0) is ThinkingObservation.OBSERVED
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("negative", [-1, -205])
|
||||||
|
def test_negative_token_count_is_not_evidence_of_absence(self, negative):
|
||||||
|
"""负数是坏数据,不是"上游明确上报未推理"这个最强的正面结论。
|
||||||
|
|
||||||
|
当前 transport 已在边界把负数归 `None`,所以这条走不通;但本函数的
|
||||||
|
docstring 自称"外部输入校验后使用",第二个 transport 直接填该值时,
|
||||||
|
`> 0 else ABSENT` 会给出一个方向相反的强结论。函数自身必须闭合(P5)。
|
||||||
|
"""
|
||||||
|
assert observe_thinking(thinking="", reasoning_tokens=negative) is (
|
||||||
|
ThinkingObservation.UNKNOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingObservationEnum:
|
||||||
|
def test_values_are_stable_strings(self):
|
||||||
|
"""取值进遥测落库,改名即历史数据断层。"""
|
||||||
|
assert ThinkingObservation.OBSERVED == "observed"
|
||||||
|
assert ThinkingObservation.ABSENT == "absent"
|
||||||
|
assert ThinkingObservation.UNKNOWN == "unknown"
|
||||||
|
|
||||||
|
def test_enum_lives_in_the_innermost_layer(self):
|
||||||
|
"""枚举必须定义在 `types.py`(最内层)。
|
||||||
|
|
||||||
|
它是 `LLMResponse` 的字段类型;定义在决策层 `thinking.py` 会让 `types.py`
|
||||||
|
反向 import 决策模块,违反 P7 依赖铁律(import-linter 契约执法)。
|
||||||
|
"""
|
||||||
|
assert ThinkingObservation.__module__ == "polygateway.types"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bogus", ["", "OBSERVED", "yes", "none"])
|
||||||
|
def test_unknown_strings_are_rejected(bogus):
|
||||||
|
"""非法值必须抛 ValueError: 缓存回放与遥测归一化都靠它识别域外取值(设计 §6)。
|
||||||
|
|
||||||
|
两处接住这个 ValueError 后**降级而非作废**(缓存复活内容 + 记 UNKNOWN、遥测
|
||||||
|
照常落行),但降级的前提是构造器真的会拒绝——它一旦放行,域外取值就会一路
|
||||||
|
进到 `LLMResponse` 与遥测列里。
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ThinkingObservation(bogus)
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingCapability:
|
||||||
|
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
|
||||||
|
|
||||||
|
def test_registered_models_carry_evidence(self):
|
||||||
|
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
|
||||||
|
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
|
||||||
|
cap = get_capability(model)
|
||||||
|
assert cap is not None and cap.evidence.strip()
|
||||||
|
|
||||||
|
def test_m3_can_disable_but_m2x_cannot(self):
|
||||||
|
assert get_capability("MiniMax-M3").can_disable is True
|
||||||
|
assert get_capability("MiniMax-M2.7").can_disable is False
|
||||||
|
assert get_capability("MiniMax-M2.5").can_disable is False
|
||||||
|
|
||||||
|
def test_unregistered_model_is_unknown(self):
|
||||||
|
assert get_capability("some-brand-new-model") is None
|
||||||
|
|
||||||
|
def test_register_capability_is_pure(self):
|
||||||
|
table = register_capability("x-1", ThinkingCapability((Effort.NONE, Effort.AUTO), "实测"))
|
||||||
|
assert get_capability("x-1", table=table) is not None
|
||||||
|
assert get_capability("x-1") is None # 默认表未被污染
|
||||||
|
|
||||||
|
def test_default_capabilities_mapping_is_read_only(self):
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveThinking:
|
||||||
|
"""五道关卡(顺序即语义)与 nearest 映射;设计 §4.1。
|
||||||
|
|
||||||
|
每一关都有独立的失败模式,漏测哪一关,判定顺序被调换都不会被抓住——而顺序
|
||||||
|
在本函数里**就是**语义(Phase 4 落进 Phase 5 就丢掉"这个模型根本关不掉")。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# —— Phase 1: 不表态 ——
|
||||||
|
|
||||||
|
def test_phase1_absent_effort_injects_nothing(self):
|
||||||
|
"""没表态就什么都不注入,用模型自己的默认档(与 `none` 严格区分)。"""
|
||||||
|
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
|
||||||
|
assert got.payload == {}
|
||||||
|
assert got.applied_effort is None
|
||||||
|
|
||||||
|
# —— Phase 2: 形态未知 ——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("effort", [Effort.NONE, Effort.AUTO, Effort.HIGH])
|
||||||
|
def test_phase2_unknown_wire_points_to_register(self, effort):
|
||||||
|
"""不知道怎么发就报错并指路;静默放行是 issue #5 修掉的那种欺骗。
|
||||||
|
|
||||||
|
文案必须报出**请求的档位**而非"开/关"方向: `Effort` 是非空字符串,拿它
|
||||||
|
的真值判方向会把 `none` 说成"开启形态未知",指错了排查方向。
|
||||||
|
"""
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="register_provider") as exc:
|
||||||
|
resolve_thinking(_MYSTERY, None, effort, model="kimi-k3")
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "extra_body" in msg
|
||||||
|
assert "kimi-k3" in msg
|
||||||
|
assert effort.value in msg
|
||||||
|
|
||||||
|
def test_phase2_reads_the_form_the_asked_for_tier_needs(self):
|
||||||
|
"""请求 `none` 只需要**关闭**形态: 开启形态未知与这次请求无关。
|
||||||
|
|
||||||
|
旧版 `slot = thinking_on if enable_thinking else thinking_off` 即按请求方向
|
||||||
|
取字段;档位化后一度写成"只看 `on_base`",于是一个已注册了关闭形态的自定义
|
||||||
|
provider 在请求 `none` 时被误拒,还被指向它已经做过的 `register_provider`
|
||||||
|
——指错方向比不指更糟(设计 §2 处置表第 2 条,2026-09-05 独立验证查出)。
|
||||||
|
"""
|
||||||
|
profile = ProviderProfile(
|
||||||
|
name="off_only",
|
||||||
|
thinking=ThinkingWire(
|
||||||
|
off={"thinking": {"type": "disabled"}}, on_base=None, effort_key=None
|
||||||
|
),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
cap = ThinkingCapability((Effort.NONE, Effort.AUTO), "构造: 关得掉,开启形态却未登记")
|
||||||
|
got = resolve_thinking(profile, cap, Effort.NONE, model="x-1")
|
||||||
|
assert got.payload == {"thinking": {"type": "disabled"}}
|
||||||
|
assert got.applied_effort is Effort.NONE
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("effort", [Effort.AUTO, Effort.HIGH])
|
||||||
|
def test_phase2_still_fires_when_the_on_form_is_the_missing_half(self, effort):
|
||||||
|
"""反方向不得被一并放过: 要开推理而开启形态未知,仍须报错并指路注册。"""
|
||||||
|
profile = ProviderProfile(
|
||||||
|
name="off_only",
|
||||||
|
thinking=ThinkingWire(
|
||||||
|
off={"thinking": {"type": "disabled"}}, on_base=None, effort_key=None
|
||||||
|
),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
cap = ThinkingCapability((Effort.NONE, Effort.AUTO, Effort.HIGH), "构造")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="register_provider") as exc:
|
||||||
|
resolve_thinking(profile, cap, effort, model="x-1")
|
||||||
|
assert effort.value in str(exc.value)
|
||||||
|
|
||||||
|
def test_phase2_beats_the_capability_checks(self):
|
||||||
|
"""形态未知时无从注入,能力如何无关紧要——Phase 2 必须先于 4/5。"""
|
||||||
|
cap = ThinkingCapability((Effort.AUTO,), "构造")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="register_provider"):
|
||||||
|
resolve_thinking(_MYSTERY, cap, Effort.NONE, model="whatever")
|
||||||
|
|
||||||
|
# —— Phase 3: 能力未登记 ——
|
||||||
|
|
||||||
|
def test_phase3_unregistered_warns_then_injects(self):
|
||||||
|
"""新模型上线不该被库挡住,但也不该假装成功: 喊一声再尽力注入。"""
|
||||||
|
messages, sink_id = _warnings()
|
||||||
|
try:
|
||||||
|
got = resolve_thinking(get_provider("minimax"), None, Effort.NONE, model="MiniMax-M9")
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert got.payload == {"reasoning_effort": "none"}
|
||||||
|
assert got.applied_effort is Effort.NONE
|
||||||
|
assert any("MiniMax-M9" in m for m in messages)
|
||||||
|
|
||||||
|
def test_phase3_can_be_silenced_on_the_hot_path(self):
|
||||||
|
"""装配期已经喊过一次,逐次调用再喊只会刷屏;判定结果不受影响。"""
|
||||||
|
messages, sink_id = _warnings()
|
||||||
|
try:
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("minimax"),
|
||||||
|
None,
|
||||||
|
Effort.NONE,
|
||||||
|
model="MiniMax-M9",
|
||||||
|
warn_unregistered=False,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert got.payload == {"reasoning_effort": "none"}
|
||||||
|
assert not [m for m in messages if "MiniMax-M9" in m]
|
||||||
|
|
||||||
|
def test_phase3_does_not_validate_tiers(self):
|
||||||
|
"""能力未知就没有清单可比对,拿空清单去拒绝档位等于凭空报错。"""
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("zhipu"),
|
||||||
|
None,
|
||||||
|
Effort.XHIGH,
|
||||||
|
model="glm-9-not-registered",
|
||||||
|
warn_unregistered=False,
|
||||||
|
)
|
||||||
|
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "xhigh"}
|
||||||
|
assert got.applied_effort is Effort.XHIGH
|
||||||
|
|
||||||
|
# —— Phase 4: 关不掉 ——
|
||||||
|
|
||||||
|
def test_phase4_before_phase5(self):
|
||||||
|
"""请求 `none` 而模型关不掉: 文案必须给出可执行替代与 env 键名。
|
||||||
|
|
||||||
|
若落进 Phase 5 的通用分支,报错会退化成"不支持 none,可选 low/high/max",
|
||||||
|
丢掉"这个模型根本关不掉"这个关键信息——下游随后就会去找 extra_body 那条
|
||||||
|
绕过的路,而那正是 issue #20 的成因。
|
||||||
|
"""
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError) as exc:
|
||||||
|
resolve_thinking(get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3")
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "glm-5.3" in msg
|
||||||
|
assert "'low'" in msg, "必须给出 cheapest_effort 的值"
|
||||||
|
assert "REASONING_EFFORT" in msg, "必须给出 env 键名"
|
||||||
|
assert "可选档位" not in msg, "退化成 Phase 5 的通用文案即失去可执行替代"
|
||||||
|
|
||||||
|
def test_phase4_never_maps_even_with_nearest(self):
|
||||||
|
"""`none` 不走映射: 把"关不掉"映射成"开着最低档"就是又一次静默降级。"""
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="REASONING_EFFORT"):
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", ["MiniMax-M2.5", "MiniMax-M2.7"])
|
||||||
|
def test_phase4_only_blocks_the_off_direction(self, model):
|
||||||
|
"""已登记 AUTO 只发开启片段,不由库代选 medium。"""
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("minimax"), get_capability(model), Effort.AUTO, model=model
|
||||||
|
)
|
||||||
|
assert got.payload == {}
|
||||||
|
assert got.applied_effort is Effort.AUTO
|
||||||
|
|
||||||
|
def test_phase4_passes_when_none_is_registered(self):
|
||||||
|
cap = get_capability("MiniMax-M3")
|
||||||
|
got = resolve_thinking(get_provider("minimax"), cap, Effort.NONE, model="MiniMax-M3")
|
||||||
|
assert got.payload == {"reasoning_effort": "none"}
|
||||||
|
assert got.applied_effort is Effort.NONE
|
||||||
|
|
||||||
|
# —— Phase 5: 档位打空 ——
|
||||||
|
|
||||||
|
def test_phase5_lists_tiers_for_tiered_model(self):
|
||||||
|
"""档位型模型: 文案必须列出它真有的档,否则下游只能猜。"""
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError) as exc:
|
||||||
|
resolve_thinking(get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3")
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "medium" in msg and "glm-5.3" in msg
|
||||||
|
assert "可选档位" in msg
|
||||||
|
assert "low" in msg and "high" in msg and "max" in msg
|
||||||
|
|
||||||
|
def test_phase5_says_toggle_only_for_switch_model(self):
|
||||||
|
"""纯开关型模型没有档位,对它说"可选档位"是错的(设计 §3.2 第三个派生量)。
|
||||||
|
|
||||||
|
样本 2026-09-05 由 MiniMax-M3 换成 glm-4.6v: T10 实测 M3 的六个强度值全部生效,
|
||||||
|
它不再是纯开关型;glm-4.6v 是实测证据最硬的 (none, auto) 模型,且 zhipu 的 wire
|
||||||
|
有 effort_key——这两点缺一不可,否则命中的是"该 provider 没有档位键"那条分支。
|
||||||
|
"""
|
||||||
|
cap = get_capability("glm-4.6v") # (none, auto): 能开能关,但没有强度档
|
||||||
|
with pytest.raises(ThinkingUnsupportedError) as exc:
|
||||||
|
resolve_thinking(get_provider("zhipu"), cap, Effort.HIGH, model="glm-4.6v")
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "可选档位" not in msg
|
||||||
|
assert "该模型只有开关" in msg
|
||||||
|
assert "auto" in msg and "none" in msg
|
||||||
|
|
||||||
|
def test_phase5_wording_forks_on_is_tiered(self):
|
||||||
|
"""两条分叉必须真的不同——同一句话套两种模型等于没分叉。"""
|
||||||
|
with pytest.raises(ThinkingUnsupportedError) as tiered:
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider("zhipu"), get_capability("glm-5.3"), Effort.MEDIUM, model="glm-5.3"
|
||||||
|
)
|
||||||
|
with pytest.raises(ThinkingUnsupportedError) as switch:
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider("zhipu"),
|
||||||
|
get_capability("glm-4.6v"),
|
||||||
|
Effort.MEDIUM,
|
||||||
|
model="glm-4.6v",
|
||||||
|
)
|
||||||
|
assert str(tiered.value) != str(switch.value)
|
||||||
|
|
||||||
|
def test_phase5_passes_a_supported_tier(self):
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
got = resolve_thinking(get_provider("zhipu"), cap, Effort.MAX, model="glm-5.3")
|
||||||
|
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "max"}
|
||||||
|
assert got.applied_effort is Effort.MAX
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"provider,model", [("deepseek", "deepseek-v4-pro"), ("minimax", "MiniMax-M3")]
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("fallback", ["error", "nearest"])
|
||||||
|
def test_unregistered_auto_choice_is_rejected(self, provider, model, fallback):
|
||||||
|
"""有开启形态也不代表已登记模型支持 AUTO,nearest 不可代选。"""
|
||||||
|
with pytest.raises(ThinkingUnsupportedError) as exc:
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider(provider),
|
||||||
|
get_capability(model),
|
||||||
|
Effort.AUTO,
|
||||||
|
model=model,
|
||||||
|
fallback=fallback,
|
||||||
|
)
|
||||||
|
assert model in str(exc.value)
|
||||||
|
assert "auto" in str(exc.value)
|
||||||
|
assert "EFFORT_FALLBACK=nearest" not in str(exc.value)
|
||||||
|
|
||||||
|
def test_minimax_explicit_medium_restores_old_wire(self):
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("minimax"), get_capability("MiniMax-M3"), Effort.MEDIUM, model="MiniMax-M3"
|
||||||
|
)
|
||||||
|
assert got.payload == {"reasoning_effort": "medium"}
|
||||||
|
assert got.applied_effort is Effort.MEDIUM
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider", ["openai", "qwen"])
|
||||||
|
def test_unknown_auto_warns_without_promising_effect(self, provider):
|
||||||
|
messages, sink = _warnings()
|
||||||
|
try:
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider(provider), None, Effort.AUTO, model="unregistered-model"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink)
|
||||||
|
assert got.applied_effort is Effort.AUTO
|
||||||
|
assert any("不保证" in str(message) for message in messages)
|
||||||
|
|
||||||
|
# —— nearest 映射(fallback 的逃生口)——
|
||||||
|
|
||||||
|
def test_nearest_ties_go_cheaper(self):
|
||||||
|
"""等距取弱: 省钱优先,库不替下游涨价(一次 medium→max 是数倍账单)。"""
|
||||||
|
cap = get_capability("glm-5.3") # (low, high, max)
|
||||||
|
messages, sink_id = _warnings()
|
||||||
|
try:
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3", fallback="nearest"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "low"}
|
||||||
|
assert any("glm-5.3" in m and "medium" in m and "low" in m for m in messages)
|
||||||
|
|
||||||
|
def test_nearest_ties_go_cheaper_on_the_strong_side_too(self):
|
||||||
|
"""xhigh 与 high/max 位序各差 1,同样取弱侧——规则不因方向而变。"""
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("zhipu"), cap, Effort.XHIGH, model="glm-5.3", fallback="nearest"
|
||||||
|
)
|
||||||
|
assert got.applied_effort is Effort.HIGH
|
||||||
|
|
||||||
|
def test_nearest_goes_up_when_the_only_neighbour_is_stronger(self):
|
||||||
|
"""minimal 之下无档可选,映射必须上行到 low,而不是无解报错。"""
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("zhipu"), cap, Effort.MINIMAL, model="glm-5.3", fallback="nearest"
|
||||||
|
)
|
||||||
|
assert got.applied_effort is Effort.LOW
|
||||||
|
|
||||||
|
def test_nearest_never_turns_reasoning_off(self):
|
||||||
|
"""请求"想得浅一点"绝不能被映射成"别想了": 那是方向反转,不是省钱。"""
|
||||||
|
cap = get_capability("glm-4.6v") # (none, auto)
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("zhipu"), cap, Effort.HIGH, model="glm-4.6v", fallback="nearest"
|
||||||
|
)
|
||||||
|
assert got.applied_effort is Effort.AUTO
|
||||||
|
assert got.payload == {"thinking": {"type": "enabled"}}
|
||||||
|
|
||||||
|
def test_nearest_still_errors_when_no_on_tier_exists(self):
|
||||||
|
"""只能关不能开的模型,映射无解——报错而非挑一个反向的档。"""
|
||||||
|
cap = ThinkingCapability((Effort.NONE,), "构造: 只登记了关闭档")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="only-off"):
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider("minimax"), cap, Effort.HIGH, model="only-off", fallback="nearest"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error_fallback_is_the_default(self):
|
||||||
|
"""默认关闭映射的理由是钱: 静默的 medium→max 在 GLM-5.3 上是数倍账单。"""
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError):
|
||||||
|
resolve_thinking(get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3")
|
||||||
|
|
||||||
|
def test_resolution_reports_applied_effort_after_mapping(self):
|
||||||
|
"""遥测记的必须是**实际**发出去的档,否则压测按档分组时挂在从未发出的档下。"""
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3", fallback="nearest"
|
||||||
|
)
|
||||||
|
assert got.applied_effort is Effort.LOW
|
||||||
|
assert got.applied_effort is not Effort.MEDIUM
|
||||||
|
|
||||||
|
# —— 注入形态 ——
|
||||||
|
|
||||||
|
def test_auto_injects_on_base_only(self):
|
||||||
|
"""`auto` 逐字节等于旧的 `thinking_on`: 开启,但不附任何档位。"""
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("qwen"), get_capability("qwen3.7-plus"), Effort.AUTO, model="qwen3.7-plus"
|
||||||
|
)
|
||||||
|
assert got.payload == {"enable_thinking": True}
|
||||||
|
|
||||||
|
def test_effort_key_none_rejects_a_tier(self):
|
||||||
|
"""qwen 系只有开关没有档位键: 硬塞一个档位只会发出一个厂商不认的字段。"""
|
||||||
|
cap = ThinkingCapability((Effort.NONE, Effort.LOW), "构造: 假设它有档位")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="没有档位键"):
|
||||||
|
resolve_thinking(get_provider("qwen"), cap, Effort.LOW, model="qwen-hypothetical")
|
||||||
|
|
||||||
|
def test_provider_without_an_off_form_says_which_half_is_missing(self):
|
||||||
|
"""`off is None` ≠ `on_base is None`: 前者是"关不了",后者是"不知道怎么发"。"""
|
||||||
|
profile = ProviderProfile(
|
||||||
|
name="no_off",
|
||||||
|
thinking=ThinkingWire(off=None, on_base={}, effort_key="reasoning_effort"),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
cap = ThinkingCapability((Effort.NONE, Effort.LOW), "构造: 能力表说能关,形态却没有")
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="没有关闭形态") as exc:
|
||||||
|
resolve_thinking(profile, cap, Effort.NONE, model="x-1")
|
||||||
|
assert "register_provider" not in str(exc.value), "形态已知,不该指向注册"
|
||||||
|
|
||||||
|
# —— 归一化: 本函数是档位进入库内的第四条入口(设计 §4.4) ——
|
||||||
|
|
||||||
|
def test_a_bare_string_tier_is_normalised_at_the_door(self):
|
||||||
|
"""`resolve_thinking` 在 `__all__` 里,下游直调时传的天然是裸串。
|
||||||
|
|
||||||
|
第三参数本次由 `bool` 换成 `Effort`,而下游最自然的写法是从 JSON/配置读出来
|
||||||
|
的 `"low"`。不在入口归一,`_inject` 撞 `.value` 抛的是 `AttributeError`——
|
||||||
|
一个未文档化、也不属错误四分类的异常(2026-09-05 独立验证查出)。
|
||||||
|
"""
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("zhipu"), get_capability("glm-5.3"), "low", model="glm-5.3"
|
||||||
|
)
|
||||||
|
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "low"}
|
||||||
|
assert got.applied_effort is Effort.LOW
|
||||||
|
|
||||||
|
def test_a_bare_none_string_still_means_the_off_tier(self):
|
||||||
|
"""裸 `"none"` 必须走到关闭形态,而不是被当成某个开启档。
|
||||||
|
|
||||||
|
身份比较 `"none" is Effort.NONE` 恒假,漏归一的后果是**静默判否**:
|
||||||
|
`_wire_unknown_for` 的 `effort is not Effort.NONE` 恒真,于是关闭请求会去看
|
||||||
|
`on_base`——正是设计 §2 处置表第 2 条点名要避免的误判方向。
|
||||||
|
"""
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider("zhipu"), get_capability("glm-5.2"), "none", model="glm-5.2"
|
||||||
|
)
|
||||||
|
assert got.payload == {"thinking": {"type": "disabled"}}
|
||||||
|
assert got.applied_effort is Effort.NONE
|
||||||
|
|
||||||
|
def test_a_bare_none_string_reaches_phase4_on_a_model_that_cannot_disable(self):
|
||||||
|
"""漏归一时 Phase 4 整条被绕过: 关不掉的模型会被静默放行成"开启"。"""
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="无法关闭推理") as exc:
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider("zhipu"), get_capability("glm-5.3"), "none", model="glm-5.3"
|
||||||
|
)
|
||||||
|
assert "'low'" in str(exc.value), "Phase 4 的可执行替代不能丢"
|
||||||
|
|
||||||
|
def test_an_illegal_tier_string_names_this_function_as_the_origin(self):
|
||||||
|
"""非法档位报 `ValueError` 并指回**是哪一处**填错——档位有四条入口,不说清
|
||||||
|
就得让人自己去翻。"""
|
||||||
|
with pytest.raises(ValueError, match="resolve_thinking") as exc:
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider("zhipu"), get_capability("glm-5.3"), "lowest", model="glm-5.3"
|
||||||
|
)
|
||||||
|
assert "非法推理档位" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcileThinking:
|
||||||
|
"""声明 × 观测对账(设计 §4.3): 矛盾出文案,不表态出 None。
|
||||||
|
|
||||||
|
文案本身是被断言对象——判定与日志分离正是为此: 告警内容可直接比对,不必
|
||||||
|
去解析日志格式。
|
||||||
|
|
||||||
|
判据自 2026-09-05 起是**档位**而非布尔(设计 §4.3): `Effort.NONE` 走"要求
|
||||||
|
关闭"一支,其余档走"要求开启"一支。档位化不是换个参数名——文案里写的是本次
|
||||||
|
真正发出去的那一档,而 transport 的节流键正按它分离,两者必须同源。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CAP = ThinkingCapability(
|
||||||
|
(Effort.NONE, Effort.AUTO), "2026-08-02 实测 reasoning_effort=none 可关闭"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_off_but_observed_with_a_registered_capability_blames_the_table(self):
|
||||||
|
"""已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。"""
|
||||||
|
msg = reconcile_thinking(
|
||||||
|
effort=Effort.NONE,
|
||||||
|
observation=ThinkingObservation.OBSERVED,
|
||||||
|
capability=self._CAP,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
)
|
||||||
|
assert msg is not None
|
||||||
|
assert "MiniMax-M3" in msg
|
||||||
|
assert "2026-08-02 实测 reasoning_effort=none 可关闭" in msg
|
||||||
|
assert "register_capability" in msg
|
||||||
|
|
||||||
|
def test_off_but_observed_unregistered_never_claims_a_table_entry(self):
|
||||||
|
"""未登记模型没有"能力表声称"这回事——说它就是撒谎。"""
|
||||||
|
msg = reconcile_thinking(
|
||||||
|
effort=Effort.NONE,
|
||||||
|
observation=ThinkingObservation.OBSERVED,
|
||||||
|
capability=None,
|
||||||
|
model="MiniMax-M9",
|
||||||
|
)
|
||||||
|
assert msg is not None
|
||||||
|
assert "MiniMax-M9" in msg
|
||||||
|
assert "能力表" not in msg
|
||||||
|
assert "register_capability" in msg
|
||||||
|
|
||||||
|
def test_registered_and_unregistered_wordings_differ(self):
|
||||||
|
registered = reconcile_thinking(
|
||||||
|
effort=Effort.NONE,
|
||||||
|
observation=ThinkingObservation.OBSERVED,
|
||||||
|
capability=self._CAP,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
)
|
||||||
|
unregistered = reconcile_thinking(
|
||||||
|
effort=Effort.NONE,
|
||||||
|
observation=ThinkingObservation.OBSERVED,
|
||||||
|
capability=None,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
)
|
||||||
|
assert registered != unregistered
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability", [None, _CAP])
|
||||||
|
def test_on_but_absent_is_a_contradiction(self, capability):
|
||||||
|
"""上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。"""
|
||||||
|
msg = reconcile_thinking(
|
||||||
|
effort=Effort.AUTO,
|
||||||
|
observation=ThinkingObservation.ABSENT,
|
||||||
|
capability=capability,
|
||||||
|
model="qwen3.7-plus",
|
||||||
|
)
|
||||||
|
assert msg is not None
|
||||||
|
assert "qwen3.7-plus" in msg
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability", [None, _CAP])
|
||||||
|
def test_on_but_unknown_admits_it_cannot_confirm(self, capability):
|
||||||
|
"""issue #17 的诚实版本: 明说"我注入了,但我看不见结果"。"""
|
||||||
|
msg = reconcile_thinking(
|
||||||
|
effort=Effort.AUTO,
|
||||||
|
observation=ThinkingObservation.UNKNOWN,
|
||||||
|
capability=capability,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
)
|
||||||
|
assert msg is not None
|
||||||
|
assert "MiniMax-M3" in msg
|
||||||
|
|
||||||
|
def test_off_and_absent_stays_silent(self):
|
||||||
|
"""要求关闭 + 上游明确上报未推理 = 要求被满足,没有可报的矛盾。
|
||||||
|
|
||||||
|
这一格与 `test_off_and_unknown_stays_silent` 的沉默理由**不同**: 那里是
|
||||||
|
"没有证伪力",这里是"正面证实要求已满足"。两者都必须沉默,漏测哪一格,
|
||||||
|
把 Phase 2 的判据写成 `is ABSENT` 之类的反向条件都不会被抓住。
|
||||||
|
"""
|
||||||
|
assert (
|
||||||
|
reconcile_thinking(
|
||||||
|
effort=Effort.NONE,
|
||||||
|
observation=ThinkingObservation.ABSENT,
|
||||||
|
capability=self._CAP,
|
||||||
|
model="qwen3.7-plus",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_off_and_unknown_stays_silent(self):
|
||||||
|
"""UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。"""
|
||||||
|
assert (
|
||||||
|
reconcile_thinking(
|
||||||
|
effort=Effort.NONE,
|
||||||
|
observation=ThinkingObservation.UNKNOWN,
|
||||||
|
capability=self._CAP,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"observation",
|
||||||
|
[ThinkingObservation.OBSERVED, ThinkingObservation.ABSENT, ThinkingObservation.UNKNOWN],
|
||||||
|
)
|
||||||
|
def test_no_request_no_grievance(self, observation):
|
||||||
|
"""调用方不表态,就无从谈"违背"。"""
|
||||||
|
assert (
|
||||||
|
reconcile_thinking(
|
||||||
|
effort=None,
|
||||||
|
observation=observation,
|
||||||
|
capability=self._CAP,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_on_and_observed_is_exactly_what_was_asked_for(self):
|
||||||
|
assert (
|
||||||
|
reconcile_thinking(
|
||||||
|
effort=Effort.AUTO,
|
||||||
|
observation=ThinkingObservation.OBSERVED,
|
||||||
|
capability=self._CAP,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("effort", [Effort.LOW, Effort.HIGH, Effort.MAX])
|
||||||
|
def test_a_strength_tier_is_an_on_request_not_an_off_one(self, effort):
|
||||||
|
"""强度档必须走"要求开启"一支: 观测到推理正是它要的结果,不得报警。
|
||||||
|
|
||||||
|
判据写成真值性(`if not effort`)会在这里翻车——`Effort.NONE` 的取值是
|
||||||
|
非空串 `"none"`,恒为真;那种写法会把每一个强度档都送进"要求关闭"分支,
|
||||||
|
于是"想了"被当成矛盾,而"没想"反倒沉默,告警方向整个颠倒。
|
||||||
|
"""
|
||||||
|
assert (
|
||||||
|
reconcile_thinking(
|
||||||
|
effort=effort,
|
||||||
|
observation=ThinkingObservation.OBSERVED,
|
||||||
|
capability=ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "构造"),
|
||||||
|
model="glm-5.3",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_wording_names_the_tier_that_was_asked_for(self):
|
||||||
|
"""文案要写出**本次这一档**: 节流键按档分离,文案不分档就看不出是哪一档。"""
|
||||||
|
low = reconcile_thinking(
|
||||||
|
effort=Effort.LOW,
|
||||||
|
observation=ThinkingObservation.ABSENT,
|
||||||
|
capability=None,
|
||||||
|
model="glm-5.3",
|
||||||
|
)
|
||||||
|
max_ = reconcile_thinking(
|
||||||
|
effort=Effort.MAX,
|
||||||
|
observation=ThinkingObservation.ABSENT,
|
||||||
|
capability=None,
|
||||||
|
model="glm-5.3",
|
||||||
|
)
|
||||||
|
assert low is not None and max_ is not None
|
||||||
|
assert "low" in low and "max" in max_
|
||||||
|
assert low != max_
|
||||||
|
|
||||||
|
def test_none_and_observed_is_the_issue_20_contradiction(self):
|
||||||
|
"""请求 `none` 却观测到推理 —— issue #20 要恢复的那条报警,判据是**档位相等**。
|
||||||
|
|
||||||
|
与上一条互为对照: 同样是 OBSERVED,`none` 必须喊、强度档必须沉默。把分支
|
||||||
|
条件写反(`is not Effort.NONE`)会让这两条同时红,单有一条则抓不住。
|
||||||
|
"""
|
||||||
|
msg = reconcile_thinking(
|
||||||
|
effort=Effort.NONE,
|
||||||
|
observation=ThinkingObservation.OBSERVED,
|
||||||
|
capability=None,
|
||||||
|
model="glm-5.3",
|
||||||
|
)
|
||||||
|
assert msg is not None and "none" in msg
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffortVocabulary:
|
||||||
|
"""八档封闭词汇(设计 §3.1);`auto` 不可省——9 个纯开关型模型无强度档可填。"""
|
||||||
|
|
||||||
|
def test_none_and_auto_are_distinct_members(self):
|
||||||
|
assert Effort.NONE != Effort.AUTO
|
||||||
|
assert Effort("none") is Effort.NONE
|
||||||
|
assert Effort("auto") is Effort.AUTO
|
||||||
|
|
||||||
|
def test_vocabulary_is_exactly_eight(self):
|
||||||
|
assert len(list(Effort)) == 8
|
||||||
|
|
||||||
|
def test_values_are_wire_literals(self):
|
||||||
|
# 档位值直接写进请求体,改名即改变发出去的字节
|
||||||
|
assert [e.value for e in Effort] == [
|
||||||
|
"none",
|
||||||
|
"auto",
|
||||||
|
"minimal",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"xhigh",
|
||||||
|
"max",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityTierList:
|
||||||
|
"""能力表从 bool 变成档位清单(设计 §3.2);三个派生量不存字段,存了必漂移。"""
|
||||||
|
|
||||||
|
def test_capability_derives_can_disable(self):
|
||||||
|
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").can_disable is True
|
||||||
|
assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").can_disable is False
|
||||||
|
|
||||||
|
def test_cheapest_effort_skips_none(self):
|
||||||
|
# 「关不掉时的可执行替代」取的是除 none 外最弱的一档
|
||||||
|
assert (
|
||||||
|
ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "实测").cheapest_effort
|
||||||
|
is Effort.LOW
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
ThinkingCapability((Effort.NONE, Effort.HIGH, Effort.MAX), "实测").cheapest_effort
|
||||||
|
is Effort.HIGH
|
||||||
|
)
|
||||||
|
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").cheapest_effort is Effort.AUTO
|
||||||
|
assert ThinkingCapability((Effort.AUTO,), "实测").cheapest_effort is Effort.AUTO
|
||||||
|
|
||||||
|
def test_cheapest_effort_is_none_when_only_none(self):
|
||||||
|
# 只能关不能开: 没有可推荐的「最省的开启档」
|
||||||
|
assert ThinkingCapability((Effort.NONE,), "实测").cheapest_effort is None
|
||||||
|
|
||||||
|
def test_is_tiered_excludes_none_and_auto(self):
|
||||||
|
# 纯开关型模型不该被告知「可选档位」——它没有档位
|
||||||
|
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").is_tiered is False
|
||||||
|
assert ThinkingCapability((Effort.AUTO,), "实测").is_tiered is False
|
||||||
|
assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").is_tiered is True
|
||||||
|
|
||||||
|
def test_empty_efforts_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="至少"):
|
||||||
|
ThinkingCapability((), "实测")
|
||||||
|
|
||||||
|
def test_duplicate_efforts_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="重复"):
|
||||||
|
ThinkingCapability((Effort.LOW, Effort.LOW), "实测")
|
||||||
|
|
||||||
|
def test_glm53_cannot_be_disabled(self):
|
||||||
|
# 三源一致(智谱官方文档/cherry-studio/OpenRouter): thinking.type 只接受 enabled
|
||||||
|
cap = get_capability("glm-5.3")
|
||||||
|
assert cap is not None
|
||||||
|
assert cap.can_disable is False
|
||||||
|
assert cap.cheapest_effort is Effort.LOW
|
||||||
|
|
||||||
|
def test_m2_series_still_cannot_be_disabled(self):
|
||||||
|
# 迁移回归: 旧表用 can_disable=False 表达的事实,新表用「none 不在清单里」表达
|
||||||
|
assert get_capability("MiniMax-M2.7").can_disable is False
|
||||||
|
assert get_capability("MiniMax-M2.5").can_disable is False
|
||||||
|
assert get_capability("MiniMax-M3").can_disable is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectiveEffort:
|
||||||
|
"""三层优先级的**唯一**判定处(设计 §4.2): 请求级 > 源级 > 语法糖 > 不表态。
|
||||||
|
|
||||||
|
收口成一个纯函数,是因为它此前在装配守卫与 transport 里各写了一份就地转换:
|
||||||
|
两份各自演化的判定,迟早会在"装配期放行、运行期报错"这种最难查的形态上分叉。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_request_beats_source(self):
|
||||||
|
assert (
|
||||||
|
effective_effort(
|
||||||
|
request_effort=Effort.MAX, source_effort=Effort.LOW, enable_thinking=None
|
||||||
|
)
|
||||||
|
is Effort.MAX
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_beats_sugar(self):
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=Effort.HIGH, enable_thinking=None)
|
||||||
|
is Effort.HIGH
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_none_request_does_not_clear_source(self):
|
||||||
|
"""请求级"没表态"绝不能被读成"要求关闭"——那会静默改掉源级的默认档。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=None)
|
||||||
|
is Effort.LOW
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_request_none_tier_is_an_opinion(self):
|
||||||
|
"""`Effort.NONE` 是一次明确的表态,必须压过源级档位而不是被当成缺省。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(
|
||||||
|
request_effort=Effort.NONE, source_effort=Effort.MAX, enable_thinking=None
|
||||||
|
)
|
||||||
|
is Effort.NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enable_thinking_true_is_auto(self):
|
||||||
|
"""`True` → `auto`(开启但不指定强度),而**不是**旧版硬编码的 medium。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=None, enable_thinking=True)
|
||||||
|
is Effort.AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enable_thinking_false_is_the_none_tier(self):
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=None, enable_thinking=False)
|
||||||
|
is Effort.NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sugar_is_the_last_word_only(self):
|
||||||
|
"""语法糖排在最末: 显式配了档位就以档位为准(矛盾组合已被构造期挡下)。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=True)
|
||||||
|
is Effort.LOW
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_absent_is_no_opinion(self):
|
||||||
|
"""三层都不表态 → None(随模型默认),与 `Effort.NONE` 严格区分。"""
|
||||||
|
assert (
|
||||||
|
effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingWireOwnership:
|
||||||
|
"""开启片段只能表达开启,不能携带隐式强度。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"base",
|
||||||
|
[
|
||||||
|
{"reasoning_effort": "high"},
|
||||||
|
{"reasoning_effort": None},
|
||||||
|
{"depth": "auto"},
|
||||||
|
{"output_config": {"effort": "low"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("effort", [None, Effort.AUTO, Effort.HIGH])
|
||||||
|
def test_on_base_cannot_hide_a_tier(self, base, effort):
|
||||||
|
profile = ProviderProfile(
|
||||||
|
name="custom",
|
||||||
|
thinking=ThinkingWire(off={"depth": "none"}, on_base=base, effort_key="depth"),
|
||||||
|
strip_think_tags=False,
|
||||||
|
)
|
||||||
|
with pytest.raises(ThinkingUnsupportedError, match="on_base"):
|
||||||
|
resolve_thinking(profile, None, effort, model="custom-model")
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingRawOwnership:
|
||||||
|
"""纯规则覆盖标准、自定义根与无意图逃生口。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
{"reasoning_effort": "high"},
|
||||||
|
{"enable_thinking": True},
|
||||||
|
{"thinking": {}},
|
||||||
|
{"thinking_budget": 100},
|
||||||
|
{"reasoning": {}},
|
||||||
|
{"thinkingConfig": {}},
|
||||||
|
{"output_config": {"effort": None}},
|
||||||
|
{"depth.key": None},
|
||||||
|
{"off_control": {}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("effort", [Effort.NONE, Effort.AUTO, Effort.HIGH])
|
||||||
|
def test_control_roots_rejected_without_mutating_input(self, raw, effort):
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from polygateway.thinking import validate_thinking_raw
|
||||||
|
|
||||||
|
wire = ThinkingWire(off={"off_control": False}, on_base={}, effort_key="depth.key")
|
||||||
|
before = deepcopy(raw)
|
||||||
|
with pytest.raises(ThinkingUnsupportedError):
|
||||||
|
validate_thinking_raw(raw, effort=effort, wire=wire, origin="test")
|
||||||
|
assert raw == before
|
||||||
|
validate_thinking_raw(raw, effort=None, wire=wire, origin="test")
|
||||||
|
assert raw == before
|
||||||
|
|
||||||
|
def test_output_format_is_not_effort_unless_wire_owns_root(self):
|
||||||
|
from polygateway.thinking import validate_thinking_raw
|
||||||
|
|
||||||
|
raw = {"output_config": {"format": "json"}, "temperature": 0, "seed": 7}
|
||||||
|
validate_thinking_raw(raw, effort=Effort.AUTO, wire=None, origin="test")
|
||||||
|
wire = ThinkingWire(off=None, on_base={"output_config": {"enabled": True}}, effort_key=None)
|
||||||
|
with pytest.raises(ThinkingUnsupportedError):
|
||||||
|
validate_thinking_raw(raw, effort=Effort.AUTO, wire=wire, origin="test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rejection_explains_how_to_choose_explicitly():
|
||||||
|
"""可执行配置是指路,不由库自动应用其建议。"""
|
||||||
|
with pytest.raises(ThinkingUnsupportedError) as error:
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider("minimax"), get_capability("MiniMax-M3"), Effort.AUTO, model="MiniMax-M3"
|
||||||
|
)
|
||||||
|
assert "REASONING_EFFORT=" in str(error.value)
|
||||||
|
assert "reasoning_effort=Effort." in str(error.value)
|
||||||
|
assert "EFFORT_FALLBACK=nearest" not in str(error.value)
|
||||||
@@ -10,10 +10,12 @@ from polygateway.types import (
|
|||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
BreakerConfig,
|
BreakerConfig,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
|
Effort,
|
||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
ThinkingObservation,
|
||||||
TransportResult,
|
TransportResult,
|
||||||
Usage,
|
Usage,
|
||||||
)
|
)
|
||||||
@@ -33,6 +35,18 @@ def _make_source(**overrides):
|
|||||||
return SourceConfig(**base)
|
return SourceConfig(**base)
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingObservationLayering:
|
||||||
|
"""枚举必须留在最内层,别被后来的重构挪进决策模块。"""
|
||||||
|
|
||||||
|
def test_defined_in_types_not_in_thinking(self):
|
||||||
|
"""`LLMResponse` 拿它当字段类型,定义在 `thinking.py` 会让最内层反向依赖决策层。
|
||||||
|
|
||||||
|
这条不是风格洁癖: import-linter 会判红,但那要等代码写完才发现;本用例
|
||||||
|
把约束前移到类型层面。
|
||||||
|
"""
|
||||||
|
assert ThinkingObservation.__module__ == "polygateway.types"
|
||||||
|
|
||||||
|
|
||||||
class TestLLMResponse:
|
class TestLLMResponse:
|
||||||
def test_eleven_legacy_fields_positional(self):
|
def test_eleven_legacy_fields_positional(self):
|
||||||
"""三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。"""
|
"""三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。"""
|
||||||
@@ -75,6 +89,30 @@ class TestLLMResponse:
|
|||||||
assert filled.model_reported == "MiniMax-Text-01-250321"
|
assert filled.model_reported == "MiniMax-Text-01-250321"
|
||||||
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
|
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
|
||||||
|
|
||||||
|
def test_thinking_observation_defaults_to_unknown(self):
|
||||||
|
"""issue #16/#17: 默认必须是 UNKNOWN——"没信号"不得被伪装成"没推理"。
|
||||||
|
|
||||||
|
默认值取 ABSENT 会让每个不填该字段的构造点(测试 fake、其他 transport)
|
||||||
|
都在替上游做一个它没做过的声明,那正是本 issue 要消灭的静默错觉。
|
||||||
|
"""
|
||||||
|
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||||
|
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
filled = LLMResponse(
|
||||||
|
"c",
|
||||||
|
"t",
|
||||||
|
"m",
|
||||||
|
"p",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
"cid",
|
||||||
|
thinking_observation=ThinkingObservation.OBSERVED,
|
||||||
|
)
|
||||||
|
assert filled.thinking_observation is ThinkingObservation.OBSERVED
|
||||||
|
|
||||||
def test_frozen(self):
|
def test_frozen(self):
|
||||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
@@ -251,6 +289,8 @@ class TestAuxTypes:
|
|||||||
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
|
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
|
||||||
assert s.cached_prompt_tokens is None and s.model_reported is None
|
assert s.cached_prompt_tokens is None and s.model_reported is None
|
||||||
assert s.reasoning_tokens is None
|
assert s.reasoning_tokens is None
|
||||||
|
# issue #16/#17: 不裁定的 transport 只能说"不知道",不能替上游说"没推理"
|
||||||
|
assert s.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
class TestOcrTypes:
|
class TestOcrTypes:
|
||||||
@@ -527,3 +567,187 @@ class TestChatRequestDimensions:
|
|||||||
)
|
)
|
||||||
assert request.tenant_id == "t1"
|
assert request.tenant_id == "t1"
|
||||||
assert request.meta == {"batch": "b-42"}
|
assert request.meta == {"batch": "b-42"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceConfigEffortNormalization:
|
||||||
|
"""源级档位在**构造期**归一成 `Effort`(issue #20;2026-09-05 独立验证查出)。
|
||||||
|
|
||||||
|
库内一律用 `is Effort.NONE` 做身份比较,而 `Effort` 是 `StrEnum`——下游从
|
||||||
|
JSON/配置读出来的天然是裸字符串,不归一就会在**错误路径上**误判并二次崩溃。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_bare_string_tier_is_normalized(self):
|
||||||
|
"""`reasoning_effort="low"` 必须存成 `Effort.LOW`,而不是原样留个 str。"""
|
||||||
|
assert _make_source(reasoning_effort="low").reasoning_effort is Effort.LOW
|
||||||
|
|
||||||
|
def test_whitespace_and_case_are_normalized(self):
|
||||||
|
"""与 `.env` 那条路同口径: 行尾空格与大写写法是常态,档位无大小写语义。"""
|
||||||
|
assert _make_source(reasoning_effort=" LOW ").reasoning_effort is Effort.LOW
|
||||||
|
|
||||||
|
def test_consistent_bare_string_survives_the_contradiction_guard(self):
|
||||||
|
"""设计 §4.2 明说"二者一致则放行",裸字符串写法不得被判成矛盾。
|
||||||
|
|
||||||
|
修复前实测: `("none" is Effort.NONE)` 为假 → 判为矛盾 → 拼文案时 `.value`
|
||||||
|
抛 `AttributeError`,连承诺的 `ValueError` 都拿不到。
|
||||||
|
"""
|
||||||
|
source = _make_source(enable_thinking=False, reasoning_effort="none")
|
||||||
|
assert source.reasoning_effort is Effort.NONE
|
||||||
|
|
||||||
|
def test_contradiction_still_caught_through_a_bare_string(self):
|
||||||
|
"""归一化不得把矛盾一并抹平: `True` + `"none"` 仍是配置错误。"""
|
||||||
|
with pytest.raises(ValueError, match="矛盾"):
|
||||||
|
_make_source(enable_thinking=True, reasoning_effort="none")
|
||||||
|
|
||||||
|
def test_illegal_tier_lists_the_whole_vocabulary(self):
|
||||||
|
"""写错档位的人要的是"那该填什么",故报错必须把八档全摆出来并指回字段。"""
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
_make_source(reasoning_effort="lowest")
|
||||||
|
message = str(exc.value)
|
||||||
|
assert "reasoning_effort" in message
|
||||||
|
assert all(tier.value in message for tier in Effort)
|
||||||
|
|
||||||
|
def test_non_string_tier_is_a_value_error_not_a_crash(self):
|
||||||
|
"""非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。"""
|
||||||
|
with pytest.raises(ValueError, match="推理档位"):
|
||||||
|
_make_source(reasoning_effort=3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallStatsAndContext:
|
||||||
|
"""逻辑调用统计内核(1.3.5 设计 §3/§4)。"""
|
||||||
|
|
||||||
|
def test_call_stats_is_frozen_snapshot(self):
|
||||||
|
from polygateway.types import CallStats
|
||||||
|
|
||||||
|
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
stats.attempts = 3
|
||||||
|
|
||||||
|
def test_context_counts_attempts_and_freezes_elapsed(self):
|
||||||
|
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
clock = _FakeMonotonic()
|
||||||
|
ctx = _CallContext(now=clock)
|
||||||
|
clock.advance(1.5)
|
||||||
|
ctx.register_attempt()
|
||||||
|
ctx.register_attempt()
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.attempts == 2
|
||||||
|
assert stats.total_latency_ms == 1500 # 秒→毫秒,不混用单位
|
||||||
|
|
||||||
|
def test_snapshot_is_repeatable_and_tracks_later_time(self):
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
clock = _FakeMonotonic()
|
||||||
|
ctx = _CallContext(now=clock)
|
||||||
|
first = ctx.snapshot()
|
||||||
|
clock.advance(2.0)
|
||||||
|
second = ctx.snapshot()
|
||||||
|
assert first.total_latency_ms == 0 and second.total_latency_ms == 2000
|
||||||
|
assert first.logical_call_id == second.logical_call_id
|
||||||
|
|
||||||
|
def test_each_context_gets_its_own_logical_id(self):
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
clock = _FakeMonotonic()
|
||||||
|
assert _CallContext(now=clock).logical_call_id != _CallContext(now=clock).logical_call_id
|
||||||
|
|
||||||
|
def test_claim_terminal_is_true_once(self):
|
||||||
|
"""终态去重位: 保证每逻辑调用至多写一条终态行(设计 §6 不变量 I3)。"""
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
ctx = _CallContext(now=_FakeMonotonic())
|
||||||
|
assert ctx.claim_terminal() is True
|
||||||
|
assert ctx.claim_terminal() is False
|
||||||
|
|
||||||
|
def test_chat_request_context_does_not_affect_equality_or_repr(self):
|
||||||
|
"""上下文是库内部件: 进 `compare`/`repr` 会污染既有请求语义与日志。"""
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
ctx = _CallContext(now=_FakeMonotonic())
|
||||||
|
bare = ChatRequest(messages=[{"role": "user", "content": "hi"}])
|
||||||
|
with_ctx = dataclasses.replace(bare, call_context=ctx)
|
||||||
|
assert with_ctx.call_context is ctx
|
||||||
|
assert with_ctx == bare
|
||||||
|
assert "call_context" not in repr(with_ctx)
|
||||||
|
|
||||||
|
def test_replace_preserves_the_same_context_reference(self):
|
||||||
|
"""洋葱各层经 `replace` 派生请求,上下文必须是同一实例而非拷贝。"""
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
ctx = _CallContext(now=_FakeMonotonic())
|
||||||
|
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], call_context=ctx)
|
||||||
|
derived = dataclasses.replace(req, stream=False)
|
||||||
|
assert derived.call_context is ctx
|
||||||
|
|
||||||
|
def test_four_responses_default_call_stats_to_none(self):
|
||||||
|
"""第三方合成响应的 `None` 表示未知,不得伪造 0(设计 §3)。"""
|
||||||
|
from polygateway.types import (
|
||||||
|
EmbeddingResponse,
|
||||||
|
OcrLayoutResult,
|
||||||
|
OcrTextResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = LLMResponse(
|
||||||
|
content="c",
|
||||||
|
thinking="",
|
||||||
|
model="m",
|
||||||
|
provider="p",
|
||||||
|
prompt_tokens=1,
|
||||||
|
completion_tokens=1,
|
||||||
|
latency_ms=1,
|
||||||
|
ttft_ms=None,
|
||||||
|
max_inter_token_ms=None,
|
||||||
|
cache_hit=False,
|
||||||
|
call_id="c1",
|
||||||
|
)
|
||||||
|
emb = EmbeddingResponse(
|
||||||
|
vectors=[],
|
||||||
|
dim=0,
|
||||||
|
model="m",
|
||||||
|
provider="p",
|
||||||
|
prompt_tokens=0,
|
||||||
|
usage_source="measured",
|
||||||
|
latency_ms=0,
|
||||||
|
call_id="c2",
|
||||||
|
source_name="s",
|
||||||
|
)
|
||||||
|
text = OcrTextResult(
|
||||||
|
text="", source_name="s", usage=Usage(0, 0), latency_ms=0, call_id="c3", raw={}
|
||||||
|
)
|
||||||
|
layout = OcrLayoutResult(
|
||||||
|
elements=[],
|
||||||
|
page_sizes=[],
|
||||||
|
source_name="s",
|
||||||
|
usage=Usage(0, 0),
|
||||||
|
latency_ms=0,
|
||||||
|
call_id="c4",
|
||||||
|
raw={},
|
||||||
|
)
|
||||||
|
assert (llm.call_stats, emb.call_stats, text.call_stats, layout.call_stats) == (
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_call_stats_is_exported_from_package_root(self):
|
||||||
|
"""四份平铺字段会漂移,故统计以单一对象出现在公共 API(设计 §3)。"""
|
||||||
|
import polygateway
|
||||||
|
from polygateway.types import CallStats
|
||||||
|
|
||||||
|
assert polygateway.CallStats is CallStats
|
||||||
|
assert "CallStats" in polygateway.__all__
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMonotonic:
|
||||||
|
"""确定性单调钟;不复用 contracts 的 FakeClock 以免 unit 反向依赖契约包。"""
|
||||||
|
|
||||||
|
def __init__(self, start: float = 1000.0) -> None:
|
||||||
|
self.t = start
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.t
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self.t += seconds
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from polygateway.types import (
|
|||||||
USAGE_SOURCES,
|
USAGE_SOURCES,
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
BreakerConfig,
|
BreakerConfig,
|
||||||
|
CallStats,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
EmbeddingTransportResult,
|
EmbeddingTransportResult,
|
||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
@@ -44,6 +45,9 @@ from polygateway.types import (
|
|||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 终态行的快照入参(1.3.5): `emit_terminal_failure` 不再收 `latency_ms`。
|
||||||
|
_MIGRATED_STATS = CallStats(logical_call_id="lcid-mig", attempts=1, total_latency_ms=1)
|
||||||
|
|
||||||
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
|
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
|
||||||
_DOMAIN = sorted(USAGE_SOURCES)
|
_DOMAIN = sorted(USAGE_SOURCES)
|
||||||
|
|
||||||
@@ -134,6 +138,7 @@ async def test_salvage_override_stays_in_domain(usage):
|
|||||||
stream=True,
|
stream=True,
|
||||||
overlay={},
|
overlay={},
|
||||||
call_id="cid",
|
call_id="cid",
|
||||||
|
reasoning_effort=None,
|
||||||
)
|
)
|
||||||
assert result.usage_source in USAGE_SOURCES
|
assert result.usage_source in USAGE_SOURCES
|
||||||
|
|
||||||
@@ -254,13 +259,15 @@ def _resp(usage_source):
|
|||||||
@pytest.mark.parametrize("emitted", _DOMAIN)
|
@pytest.mark.parametrize("emitted", _DOMAIN)
|
||||||
async def test_emit_attempt_success_stays_in_domain(emitted):
|
async def test_emit_attempt_success_stays_in_domain(emitted):
|
||||||
recorder = _MemoryRecorder()
|
recorder = _MemoryRecorder()
|
||||||
await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
|
await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_attempt(
|
||||||
request=_REQ,
|
request=_REQ,
|
||||||
source=_src(),
|
source=_src(),
|
||||||
call_id="cid",
|
call_id="cid",
|
||||||
latency_ms=10,
|
latency_ms=10,
|
||||||
response=_resp(emitted),
|
response=_resp(emitted),
|
||||||
error=None,
|
error=None,
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
|
|
||||||
@@ -268,13 +275,15 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
|
|||||||
async def test_emit_attempt_failed_attempt_stays_in_domain():
|
async def test_emit_attempt_failed_attempt_stays_in_domain():
|
||||||
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
|
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
|
||||||
recorder = _MemoryRecorder()
|
recorder = _MemoryRecorder()
|
||||||
await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
|
await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_attempt(
|
||||||
request=_REQ,
|
request=_REQ,
|
||||||
source=_src(),
|
source=_src(),
|
||||||
call_id="cid",
|
call_id="cid",
|
||||||
latency_ms=10,
|
latency_ms=10,
|
||||||
response=None,
|
response=None,
|
||||||
error="boom",
|
error="boom",
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
|
|
||||||
@@ -282,8 +291,10 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
|
|||||||
@pytest.mark.parametrize("emitted", _DOMAIN)
|
@pytest.mark.parametrize("emitted", _DOMAIN)
|
||||||
async def test_emit_cache_hit_stays_in_domain(emitted):
|
async def test_emit_cache_hit_stays_in_domain(emitted):
|
||||||
recorder = _MemoryRecorder()
|
recorder = _MemoryRecorder()
|
||||||
await TelemetryEmitter(recorder, text_cap=None).emit_cache_hit(
|
await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_cache_hit(
|
||||||
request=_REQ, response=_resp(emitted)
|
request=_REQ,
|
||||||
|
response=_resp(emitted),
|
||||||
|
operation="chat",
|
||||||
)
|
)
|
||||||
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
|
|
||||||
@@ -291,7 +302,11 @@ async def test_emit_cache_hit_stays_in_domain(emitted):
|
|||||||
async def test_emit_terminal_failure_stays_in_domain():
|
async def test_emit_terminal_failure_stays_in_domain():
|
||||||
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
|
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
|
||||||
recorder = _MemoryRecorder()
|
recorder = _MemoryRecorder()
|
||||||
await TelemetryEmitter(recorder, text_cap=None).emit_terminal_failure(
|
await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_terminal_failure(
|
||||||
request=_REQ, call_id="cid", latency_ms=10, error="cancelled"
|
request=_REQ,
|
||||||
|
call_id="cid",
|
||||||
|
error="cancelled",
|
||||||
|
operation="chat",
|
||||||
|
stats=_MIGRATED_STATS,
|
||||||
)
|
)
|
||||||
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from polygateway import EmbeddingClient, GatewayClient, LLMResponse
|
|||||||
from polygateway.config import _SOURCE_FIELDS
|
from polygateway.config import _SOURCE_FIELDS
|
||||||
from polygateway.ocr import OcrClient
|
from polygateway.ocr import OcrClient
|
||||||
from polygateway.providers import register_provider
|
from polygateway.providers import register_provider
|
||||||
from polygateway.telemetry.sqlite import _COLUMNS as TELEMETRY_COLUMNS
|
from polygateway.telemetry.sqlite import COLUMNS as TELEMETRY_COLUMNS
|
||||||
|
|
||||||
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
|
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
|
||||||
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
|
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
@@ -30,6 +31,11 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
TABLE = "llm_calls"
|
TABLE = "llm_calls"
|
||||||
|
|
||||||
|
# --table 的 schema 段白名单。收紧到普通标识符不是为了防注入(目标名走 to_regclass
|
||||||
|
# 的参数化占位,且用 _quote 转义),而是让 --help 里"不支持复杂标识符"这句话与实现
|
||||||
|
# 一致——文档说不支持、实现却照单全收,受害的是照文档做判断的人。
|
||||||
|
_PLAIN_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
|
||||||
|
|
||||||
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
|
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
|
||||||
EXIT_OK = 0
|
EXIT_OK = 0
|
||||||
EXIT_USAGE = 1
|
EXIT_USAGE = 1
|
||||||
@@ -59,6 +65,11 @@ _EPILOG = """\
|
|||||||
时间口径: 截止时刻 = 当前 UTC 时刻 - N 天,删除 created_at < 截止时刻 的行;
|
时间口径: 截止时刻 = 当前 UTC 时刻 - N 天,删除 created_at < 截止时刻 的行;
|
||||||
--older-than-days 0 即"删除此刻之前的全部行"。
|
--older-than-days 0 即"删除此刻之前的全部行"。
|
||||||
|
|
||||||
|
--table: 本脚本只清理表 llm_calls,故 --table 只有 schema 一段可变(写成
|
||||||
|
--table <schema>.llm_calls)。给了它,目标就由参数精确解析、不再经
|
||||||
|
search_path 推断。含点或引号的复杂标识符不支持,此时请不给 --table,
|
||||||
|
退回 search_path 解析那条路径。
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
|
python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
|
||||||
--older-than-days 90 # dry-run,只看会删什么
|
--older-than-days 90 # dry-run,只看会删什么
|
||||||
@@ -114,6 +125,11 @@ def _build_parser() -> _Parser:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
|
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--table",
|
||||||
|
metavar="SCHEMA.NAME",
|
||||||
|
help=f"仅 postgres: 把目标钉死为 <schema>.{TABLE},绕开 search_path 推断",
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -150,10 +166,16 @@ def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
|
|||||||
parser.error("--backend sqlite 不接受 --dsn")
|
parser.error("--backend sqlite 不接受 --dsn")
|
||||||
if args.batch_size is not None:
|
if args.batch_size is not None:
|
||||||
parser.error("--batch-size 仅用于 --backend postgres")
|
parser.error("--batch-size 仅用于 --backend postgres")
|
||||||
|
if args.table is not None:
|
||||||
|
parser.error("--table 仅用于 --backend postgres:SQLite 的库文件即目标,无 schema 可消歧")
|
||||||
|
|
||||||
|
|
||||||
def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
|
def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
|
||||||
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。"""
|
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。
|
||||||
|
|
||||||
|
`--table` 在此解析成 `args.table_schema`(未给则 None): 校验与解析放在同一处,
|
||||||
|
后面的执行路径就只面对一个已经合法的 schema 名,不必再重复判断。
|
||||||
|
"""
|
||||||
if args.dsn is None:
|
if args.dsn is None:
|
||||||
parser.error("--backend postgres 需要 --dsn")
|
parser.error("--backend postgres 需要 --dsn")
|
||||||
if args.path is not None:
|
if args.path is not None:
|
||||||
@@ -164,6 +186,36 @@ def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
|
|||||||
args.batch_size = 1000
|
args.batch_size = 1000
|
||||||
elif args.batch_size < 1:
|
elif args.batch_size < 1:
|
||||||
parser.error("--batch-size 必须 >= 1")
|
parser.error("--batch-size 必须 >= 1")
|
||||||
|
args.table_schema = None if args.table is None else _parse_table(parser, args.table)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_table(parser: _Parser, value: str) -> str:
|
||||||
|
"""校验 `--table SCHEMA.NAME` 并返回 schema 段;任何不合法形态退出 1。
|
||||||
|
|
||||||
|
**表名段为什么不可变**: 只校验"两段、非空"的话,一次手误 `--table audit.events`
|
||||||
|
就会让本脚本对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE。
|
||||||
|
脚本的名字、退出码 3 的分区提示、README 的定位全都围绕遥测表写,它从未声称自己
|
||||||
|
是通用清理器;把这条校验去掉等于在一个拿 DELETE 权限跑的脚本上开静默的口子。
|
||||||
|
"""
|
||||||
|
segments = value.split(".")
|
||||||
|
if len(segments) != 2:
|
||||||
|
parser.error(f"--table 必须是 <schema>.{TABLE} 这样的两段限定名,当前: {value!r}")
|
||||||
|
schema, name = segments
|
||||||
|
# 段内不可能再含 "." (上面按 "." 切成恰好两段),故此处只查其余形态
|
||||||
|
if not schema or not name:
|
||||||
|
parser.error(f"--table 的 schema 段与表名段都不得为空,当前: {value!r}")
|
||||||
|
if not _PLAIN_IDENTIFIER.fullmatch(schema):
|
||||||
|
parser.error(
|
||||||
|
f"--table 的 schema 段只接受普通标识符(字母或下划线开头,其后字母/数字/"
|
||||||
|
f"下划线/$),当前: {value!r};含空格、引号等需要加引号的复杂标识符不支持,"
|
||||||
|
"这种情形请不给 --table,退回 search_path 解析那条路径。"
|
||||||
|
)
|
||||||
|
if name != TABLE:
|
||||||
|
parser.error(
|
||||||
|
f"--table 的表名段必须逐字等于 {TABLE}:本脚本只清理遥测表 {TABLE},"
|
||||||
|
f"不是通用清理器,当前: {value!r}"
|
||||||
|
)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
|
def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
|
||||||
@@ -240,7 +292,9 @@ def _quote(identifier: str) -> str:
|
|||||||
return f'"{escaped}"'
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
|
||||||
async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: int) -> int:
|
async def _run_postgres(
|
||||||
|
dsn: str, cutoff: datetime, apply_: bool, batch_size: int, table_schema: str | None
|
||||||
|
) -> int:
|
||||||
"""PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
|
"""PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
|
||||||
try:
|
try:
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -257,7 +311,7 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
|
|||||||
print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
|
print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
|
||||||
return EXIT_BACKEND
|
return EXIT_BACKEND
|
||||||
try:
|
try:
|
||||||
return await _purge_postgres(conn, cutoff, apply_, batch_size)
|
return await _purge_postgres(conn, cutoff, apply_, batch_size, table_schema)
|
||||||
except asyncpg.PostgresError as exc:
|
except asyncpg.PostgresError as exc:
|
||||||
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
|
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
|
||||||
return EXIT_BACKEND
|
return EXIT_BACKEND
|
||||||
@@ -265,23 +319,41 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
|
|||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
async def _purge_postgres(conn: Any, cutoff: datetime, apply_: bool, batch_size: int) -> int:
|
async def _purge_postgres(
|
||||||
|
conn: Any, cutoff: datetime, apply_: bool, batch_size: int, table_schema: str | None
|
||||||
|
) -> int:
|
||||||
"""已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
|
"""已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
|
||||||
# 先解析目标: to_regclass 走连接自己的 search_path,故必须把解析结果打出来——
|
# 给了 --table 就用引号限定名精确解析(绕开 search_path),否则维持裸表名解析——
|
||||||
# "我删的到底是哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
|
# 后者走连接自己的 search_path,故无论哪条路都必须把解析结果打出来:"我删的到底是
|
||||||
|
# 哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
|
||||||
|
lookup = TABLE if table_schema is None else f"{_quote(table_schema)}.{_quote(TABLE)}"
|
||||||
target = await conn.fetchrow(
|
target = await conn.fetchrow(
|
||||||
"SELECT n.nspname AS schema, c.relname AS name, "
|
"SELECT n.nspname AS schema, c.relname AS name, "
|
||||||
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
|
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
|
||||||
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||||
"WHERE c.oid = to_regclass($1)",
|
"WHERE c.oid = to_regclass($1)",
|
||||||
TABLE,
|
lookup,
|
||||||
)
|
)
|
||||||
if target is None:
|
if target is None:
|
||||||
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
|
# 两条路的诊断方向不同,消息分开写: 显式指定找不到多半是名字/大小写写错了,
|
||||||
|
# search_path 找不到则是连接配置的事。
|
||||||
|
if table_schema is None:
|
||||||
|
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"显式指定的表 {table_schema}.{TABLE} 不存在或当前角色不可见。"
|
||||||
|
"注意: PG 中未加引号建的标识符在 catalog 里是小写。",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return EXIT_BACKEND
|
return EXIT_BACKEND
|
||||||
schema, name = target["schema"], target["name"]
|
schema, name = target["schema"], target["name"]
|
||||||
qualified = f"{_quote(schema)}.{_quote(name)}"
|
qualified = f"{_quote(schema)}.{_quote(name)}"
|
||||||
print(f"目标表: {schema}.{name}")
|
print(f"目标表: {schema}.{name}")
|
||||||
|
if apply_ and table_schema is None:
|
||||||
|
# 只在 --apply 时提示: dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途。
|
||||||
|
print(
|
||||||
|
"注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。"
|
||||||
|
)
|
||||||
if target["partitioned"]:
|
if target["partitioned"]:
|
||||||
print(
|
print(
|
||||||
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
|
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
|
||||||
@@ -347,7 +419,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
|
print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
|
||||||
if args.backend == "sqlite":
|
if args.backend == "sqlite":
|
||||||
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
|
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
|
||||||
return asyncio.run(_run_postgres(args.dsn, cutoff, args.apply, args.batch_size))
|
return asyncio.run(
|
||||||
|
_run_postgres(args.dsn, cutoff, args.apply, args.batch_size, args.table_schema)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user