Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ced49a515 | |||
| 77f9260189 | |||
| 45073486a7 | |||
| dd540496a1 | |||
| c634cab35e | |||
| 1fa91cf73d | |||
| 3a104fcce4 | |||
| a8cca51164 | |||
| b1109e9fe9 | |||
| 2f5abb6a55 | |||
| a1a9212ba1 | |||
| 7adcfff0fa | |||
| 5eb01a0096 | |||
| 48805cb9fb | |||
| 4c135075b3 | |||
| 82f4ec4910 | |||
| 89ff916bc8 | |||
| e5871cccd2 | |||
| 781579bf36 | |||
| acc419a29b | |||
| de7273598e |
@@ -1,5 +1,54 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.1.0(2026-08-06)
|
||||||
|
|
||||||
|
治理后端故障归位为 scope 级不可用(issue #7)。限流/熔断的状态后端(Redis 等)自身故障时,库按降级方向铁律 fail-closed——**整个 scope 一个请求都发不出去**,语义上就是"scope 级暂时不可用"。但 `GovernanceBackendError` 此前是 `PolyGatewayError` 的直接子类,只写 `except GatewayUnavailableError` 的调用方接不住,后果很具体: Redis 抖一下,积压任务一批批消耗业务失败预算,够到上限就进死信——**而那是运维重启一下就好的故障**。
|
||||||
|
|
||||||
|
### 行为变更(**请先读这一条**)
|
||||||
|
|
||||||
|
- **`GovernanceBackendError` 现在能被 `except GatewayUnavailableError` 捕获。** 它改为继承该类,`reason` 恒为新增的 `governance_backend_down`。**下游对后端故障的处置路线因此改变**: 从"落进兜底分支、按业务失败处置"变为"按 scope 级不可用延期重投、不消耗失败预算"。这正是本次修复的目标,但升级前请确认下游的兜底分支没有依赖旧行为(例如靠它触发告警)。既有的 `except GovernanceBackendError` **继续有效**——加父类是扩大捕获面,不是破坏。
|
||||||
|
- **配置写错(源名与限流后端配置不匹配)现在抛 `SourceNotConfiguredError` 而非 `GovernanceBackendError`。** 该类**有意不在** `GatewayUnavailableError` 之下: 那是装配缺陷不是暂时故障,必须消耗失败预算、进死信、让人看见。若随整类归入可重投家族,配置写错的任务会永远重投且无人告警——恰是本次要修的 bug 的镜像。
|
||||||
|
- **`GovernanceBackendError` 的构造签名增加必填 keyword `scope`。** 库内 20 处构造点已全部更新;若下游有自行构造该异常的代码(罕见)需同步补 `scope`。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **`SourceNotConfiguredError`**(公共导出)。源名不在限流后端配置字典中时抛出,正常不可达,属装配缺陷。
|
||||||
|
- **`GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`**,`GovernanceBackendError.retry_after_s` 的默认值。**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),故取保守固定值。**不取 0**: 那会让积压任务零延迟同时冲击已挂掉的后端,把一次故障放大成一场风暴。
|
||||||
|
- **scope 级 `reason` 值域增 `governance_backend_down`**(由 5 值扩为 6 值)。
|
||||||
|
- **README 新增"哪些异常会到达调用方"两列表**。四分类里 `TransientError` / `SourceDeadError` 被重试循环接住、耗尽时包成 `AllSourcesExhausted`,**根本到不了调用方**,而这只看类型树与 docstring 读不出来——曾让下游据此写错整段设计文档。
|
||||||
|
|
||||||
|
### 下游请读
|
||||||
|
|
||||||
|
- **`GovernanceBackendError` 现携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`**,与 `AllSourcesExhausted` 同款;`str(exc)` 仍是原来的诊断串(如 `限流后端 try_acquire 失败: ...`),结构化字段与诊断信息并存,排障不受影响。
|
||||||
|
- **三条闸门路径**(`try_acquire` / `try_enter` / `progress_age_s`)的后端故障会到达调用方;记账路径(`record_success` 等)仍被 `_record_quietly` 降级为 warning,这个分工不变。
|
||||||
|
- **CHSAnalyzer 迁移**: `tracking.py` 一条 `except GatewayUnavailableError` 即覆盖完整,无需为后端故障单列分支(`migrations/chsanalyzer.md` G1 已补注)。
|
||||||
|
|
||||||
|
## 1.0.6(2026-08-02)
|
||||||
|
|
||||||
|
推理开关能力建模与 `reasoning_tokens` 采集。`enable_thinking=False` 此前对 `minimax` / `openai` 两类源**完全不产生效果**——两个 profile 的 thinking 两档皆为空字典,`payload.update({})` 是空操作,而配置方以为关掉了推理。这比"不提供这个开关"更危险:不提供的话调用方会去找别的办法,提供了但静默失效,调用方就带着一个错误的前提往下走。一个下游项目正卡在这上面。
|
||||||
|
|
||||||
|
### 行为变更(**请先读这一条**)
|
||||||
|
|
||||||
|
- **MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效"。** 经实测,MiniMax 认的开关是 `reasoning_effort` 而非 `enable_thinking` / `thinking`(后两者被静默丢弃);现在 `False` 注入 `reasoning_effort: none`、`True` 注入 `medium`。此前依赖"设了 false 但其实没关"这一实际行为的调用方,行为会变。
|
||||||
|
- **`MiniMax-M2.7` / `MiniMax-M2.5` 配 `ENABLE_THINKING=false` 会在装配期报错。** 这两个模型的推理**关不掉**,是模型固有属性(三种参数形态各 15 轮实测全部无效,OpenRouter 与 models.dev 两个外部注册表独立登记为强制推理)。调用方要的是"不推理"的语义保证,给不了就必须说,而不是装出一个骗人的 client。
|
||||||
|
- **`provider=openai` 的源配任何非 `None` 的 `ENABLE_THINKING` 会在装配期报错。** 该段名实践中被复用为任意 OpenAI 兼容厂商的兜底,向未知厂商下发厂商方言参数会 400。要控制推理请 `register_provider` 注册形态,或用 `SourceConfig.extra_body` 直接下发。
|
||||||
|
- **`enable_thinking` 进入缓存指纹。** 它现在真的改变请求体,不进指纹就会出现"关掉推理后重启读到开着推理时的旧响应"。**配了该项的 scope 会有一次性冷启动**;未配的 scope 指纹字面量逐字不变,不受影响。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **`LLMResponse` / `TransportResult` 新增 `reasoning_tokens: int | None`**(issue #6)。推理 token 已计入 `completion_tokens`,故**成本总额一直是对的**——这不是计费缺口,是归因缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分。
|
||||||
|
- **遥测表 `llm_calls` 新增 `reasoning_tokens` 列**,`TelemetryRecorder` 端口由 21 字段扩为 22;补列纪律与 issue #3/#4 逐字相同(排末尾、先探测再 ALTER、失败只逐行降级)。
|
||||||
|
- **`ProviderProfile` 的 thinking 两档类型放宽为 `Mapping | None`**,三值语义互不重叠:`{...}` 已知注入片段 / `{}` 已知无需注入 / `None` **未知**。空字典曾同时承载后两种含义,那正是本次 bug 的根因。
|
||||||
|
- **新增 model 级能力表** `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability`,以及单一判定函数 `resolve_thinking`。形态(参数长什么样)按 provider 变、数年不变一次;能力(能否关闭)按 model 变、每代都变——provider 级的表在物理上表达不了同厂代际差异。每条登记都附实测证据与日期。
|
||||||
|
|
||||||
|
### 下游请读
|
||||||
|
|
||||||
|
- **`reasoning_tokens` 的 `None` 是"本次调用未上报",不是"该源不上报"**,与 `cached_prompt_tokens` 的 NULL 语义**不同**。中转网关在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故判据须写 `in (None, 0)`;**写 `== 0` 的条件永远不成立**——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。
|
||||||
|
- **不要用输出长度反推是否发生了推理。** 两档的 `completion_tokens` 分布是重叠的(实测关闭档最高 46、开启档最低 13),按阈值判两个方向都会误判。唯一可靠的判别量是 `reasoning_tokens`。
|
||||||
|
- **`enable_thinking=True` 对 MiniMax 映射到 `medium` 档。** 它是五档旋钮而库给的是布尔开关,这个映射是库做的选择:`medium` 对应"厂商正常强度",与 qwen 的 `enable_thinking:true`、deepseek 的 `thinking:{enabled}` 同为"不指定预算、由模型自定"的语义。要精确控制档位用 `extra_body={"reasoning_effort": "..."}`,它的优先级高于 profile 注入。
|
||||||
|
- **未登记的模型不会被挡住**,按 provider 形态尽力注入并发一条 warning。新模型上线不该被库拦下,但也不该假装成功;实测后请用 `register_capability` 登记。
|
||||||
|
- **`pricing.py` 一行未改。** 推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
|
||||||
|
|
||||||
## 1.0.5(2026-07-31)
|
## 1.0.5(2026-07-31)
|
||||||
|
|
||||||
采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。
|
采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ make ci # 只读验证(check + test)
|
|||||||
|
|
||||||
> **档位原则(Fable 5 适配,2026-07 调研决策)**: 约束"边界与验收",不规定思考步骤。强制档(MANDATORY)是硬门;其余由模型按 skill description 自判,自判标准是任务实质(规模/风险/是否触及公共承诺),不是省事。硬边界(reference/ 只读、危险命令、提交质量门)由 `.claude/settings.json` 注册的 hooks **确定性执行**,不依赖提示词自觉。
|
> **档位原则(Fable 5 适配,2026-07 调研决策)**: 约束"边界与验收",不规定思考步骤。强制档(MANDATORY)是硬门;其余由模型按 skill description 自判,自判标准是任务实质(规模/风险/是否触及公共承诺),不是省事。硬边界(reference/ 只读、危险命令、提交质量门)由 `.claude/settings.json` 注册的 hooks **确定性执行**,不依赖提示词自觉。
|
||||||
|
|
||||||
|
> [!CRITICAL]
|
||||||
|
> **执行模式: subagent 与 Codex 一律前台(2026-08-06 人类指令)**
|
||||||
|
> 一切 subagent(verifier、`subagent-driven-development` 执行器、Explore 等)与 Codex 调用**必须前台运行**——`Agent` 工具传 `run_in_background: false`,`/codex:rescue` 带 `--wait`,**禁止**后台派发后继续做别的事。
|
||||||
|
> **理由(实测教训)**: 后台完成通知不可靠——管道会掩盖真实退出码(`pytest ... | tail` 让失败跑报成 exit 0),等待脚本的 `pgrep -f` 会自匹配成死循环,于是出现"任务早完成却没人知道"和"任务挂了也没人知道"两种失败,且两种都以"看起来还在跑"的形态呈现,无法从外部区分。前台运行牺牲并行度换取状态确定性,这个交换在本项目是划算的。
|
||||||
|
> **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。
|
||||||
|
|
||||||
### Phase 1: 规划与设计
|
### Phase 1: 规划与设计
|
||||||
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
|
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
|
||||||
2. 功能产生运行时数据时**必须**调用 `structured-logging`。
|
2. 功能产生运行时数据时**必须**调用 `structured-logging`。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install test lint format check ci wiki
|
.PHONY: install test lint format check ci wiki wiki-check
|
||||||
|
|
||||||
ENV := PolyGateway
|
ENV := PolyGateway
|
||||||
|
|
||||||
@@ -24,3 +24,10 @@ ci: check test
|
|||||||
|
|
||||||
wiki:
|
wiki:
|
||||||
conda run -n $(ENV) python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
|
conda run -n $(ENV) python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||||
|
|
||||||
|
# 用户文档站(Gitea Wiki)与源码的机械对齐校验。wiki 是独立仓库,须显式给路径:
|
||||||
|
# make wiki-check WIKI=~/PolyGateway.wiki
|
||||||
|
# 不并入 ci: 仓库里没有 wiki,自动跳过等于静默降级(违 P5),宁可让人显式跑。
|
||||||
|
wiki-check:
|
||||||
|
@test -n "$(WIKI)" || (echo "用法: make wiki-check WIKI=<PolyGateway.wiki 克隆路径>" && exit 1)
|
||||||
|
conda run -n $(ENV) python3 tools/check_wiki_alignment.py --wiki $(WIKI)
|
||||||
|
|||||||
@@ -124,6 +124,21 @@ except RequestRejectedError:
|
|||||||
|
|
||||||
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
|
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
|
||||||
|
|
||||||
|
### 哪些异常会到达调用方
|
||||||
|
|
||||||
|
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
||||||
|
|
||||||
|
| 会到达调用方 | 库内吸收(不必 catch) |
|
||||||
|
|---|---|
|
||||||
|
| `GatewayUnavailableError` 族——`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` | `TransientError`(退避后换源重试,耗尽即转为 `AllSourcesExhausted`) |
|
||||||
|
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
|
||||||
|
| `ResultInvalidError` | |
|
||||||
|
| `SourceNotConfiguredError` | |
|
||||||
|
|
||||||
|
**`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。
|
||||||
|
|
||||||
|
**`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。
|
||||||
|
|
||||||
## 配置参考
|
## 配置参考
|
||||||
|
|
||||||
配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.env.example),约定速览:
|
配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.env.example),约定速览:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "polygateway"
|
name = "polygateway"
|
||||||
version = "1.0.5"
|
version = "1.1.0"
|
||||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -376,8 +376,14 @@ flowchart TB
|
|||||||
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
|
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
|
||||||
| `ResultInvalidError` | 调用成功但内容不可解析(JSON 修不好、ZIP 缺关键文件) | ❌(仅 D14 结构化阶梯的有界带反馈重问,不入 transport 重试计数) | ❌ | ❌(熔断记**成功**) |
|
| `ResultInvalidError` | 调用成功但内容不可解析(JSON 修不好、ZIP 缺关键文件) | ❌(仅 D14 结构化阶梯的有界带反馈重问,不入 transport 重试计数) | ❌ | ❌(熔断记**成功**) |
|
||||||
| `CircuitOpenError` / `AllSourcesExhausted` | 开路 / 全源耗尽 | 调用方决定: wait / fail-fast 可配 | — | — |
|
| `CircuitOpenError` / `AllSourcesExhausted` | 开路 / 全源耗尽 | 调用方决定: wait / fail-fast 可配 | — | — |
|
||||||
|
| `GovernanceBackendError` | 限流/熔断**状态后端自身**故障(Redis 挂等);降级方向 fail-closed,故一个请求都发不出去 | 调用方决定(同 scope 级: 延期重投) | — | — |
|
||||||
|
| `SourceNotConfiguredError` | 源名不在限流后端配置字典中——**装配缺陷**,非调用失败,正常不可达 | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
**scope 级不可用的结构化语义(2026-07-20,CHS 迁移缺口 G1;2026-07-20 M1 设计勘误修订)**: `AllSourcesExhausted`/`CircuitOpenError` 必须携带结构化字段——`retry_after_s: float`(**非可选**,承 CHS `ProviderUnavailableError` 同款,0 表示可立即重试;取各源冷却与 Retry-After 的最小值)、`reason` 枚举、`per_source_reasons: dict[str, str]`。reason 两层值域(M1 设计 §3 勘误: 本节初版所列 7 值与 CHS `errors.py:143-153` 实际值域不符,重组如下)——scope 级 `reason`: circuit_open / retry_exhausted / stalled / quota_exhausted / no_sources;`per_source_reasons` 值: network_error / timeout / rate_limited / source_dead / circuit_open / cooldown。CHS 的"scope 级不可用 → arq 延期重投、不消耗业务失败预算"(`workers/tracking.py:406-428`)依赖 `retry_after_s` 复现。
|
**scope 级不可用的结构化语义(2026-07-20,CHS 迁移缺口 G1;2026-07-20 M1 设计勘误修订)**: `AllSourcesExhausted`/`CircuitOpenError` 必须携带结构化字段——`retry_after_s: float`(**非可选**,承 CHS `ProviderUnavailableError` 同款,0 表示可立即重试;取各源冷却与 Retry-After 的最小值)、`reason` 枚举、`per_source_reasons: dict[str, str]`。reason 两层值域(M1 设计 §3 勘误: 本节初版所列 7 值与 CHS `errors.py:143-153` 实际值域不符,重组如下)——scope 级 `reason`: circuit_open / retry_exhausted / stalled / quota_exhausted / no_sources / **governance_backend_down**(2026-08-06 增,见下);`per_source_reasons` 值: network_error / timeout / rate_limited / source_dead / circuit_open / cooldown。CHS 的"scope 级不可用 → arq 延期重投、不消耗业务失败预算"(`workers/tracking.py:406-428`)依赖 `retry_after_s` 复现。
|
||||||
|
|
||||||
|
**治理后端故障归位(2026-08-06,Gitea issue #7;设计 `designs/2026-08-06-governance-backend-error-design.md`)**: `GovernanceBackendError` 自 M2 引入分布式后端时新增,但**当时未回补本表**,于是它在"调用方视角的分类学"里一直没有位置——本次归位同时补上这个遗漏。它此前是 `PolyGatewayError` 的直接子类,而语义上 fail-closed 意味着整个 scope 发不出任何请求,正是 scope 级不可用;下游只写 `except GatewayUnavailableError` 会把它落进兜底分支,导致"Redis 抖一下 → 积压任务消耗业务失败预算 → 进死信",而那是运维重启即可恢复的故障。现改为继承 `GatewayUnavailableError`,`reason` 恒为 `governance_backend_down`,`retry_after_s` 默认取常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`——**不取 0**,因为后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),而 0 会让积压任务零延迟同时冲击已挂掉的后端。
|
||||||
|
|
||||||
|
同批拆出 `SourceNotConfiguredError`: 限流后端 `_cfg()` 遇到源名不在配置字典中时原先也抛 `GovernanceBackendError`,但那是装配缺陷而非后端故障。若随整类归入"可延期重投",配置写错的任务会**永远重投、永不进死信、无人告警**——恰是本次要修的 bug 的镜像。故它有意留在 `GatewayUnavailableError` 之外,让缺陷消耗失败预算并浮出水面。它与四分类的关系见 §6.3 之外的第三论域说明: 四分类的论域是 transport 层翻译的**调用失败**(§6.2),scope 级不可用回答"整个 scope 还能不能用",而装配缺陷根本不该进入治理循环被"决定"。
|
||||||
|
|
||||||
### 6.2 翻译规则(transport 层职责)
|
### 6.2 翻译规则(transport 层职责)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-08-02-thinking-capability-design
|
||||||
|
title: "推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)"
|
||||||
|
date: 2026-08-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)
|
||||||
|
|
||||||
|
> 类型:design|日期:2026-08-02|状态:待人类确认
|
||||||
|
> 事实基础见 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`(本文所有实测引用均出自该文)。
|
||||||
|
> 本设计经 2026-08-02 充分讨论后直接给出单一方案,不列备选。
|
||||||
|
|
||||||
|
## 1. 问题
|
||||||
|
|
||||||
|
**issue #5——静默失效。** `SourceConfig.enable_thinking` 是给上层的统一推理开关,靠 `providers.py` 的 `ProviderProfile.thinking_on/thinking_off` 落地。`minimax` 与 `openai` 两格皆为空 dict,`_build_payload` 的 `payload.update({})` 是空操作:`enable_thinking=False` 对这两类源**完全不产生效果**,而配置方以为关掉了。
|
||||||
|
|
||||||
|
这不是理论缺陷。`dissect/.env:84,99` 两个 scope 均写 `ENABLE_THINKING=false`,并在 `:67-70` 记为明确阻塞项——Phase-0 要求关闭思维链以隔离变量。
|
||||||
|
|
||||||
|
**issue #6——归因缺口。** `usage.completion_tokens_details.reasoning_tokens` 未被采集。成本总额正确(推理 token 已含在 `completion_tokens` 内),但"本次调用有多少钱花在推理上"无法区分,而这正是 dissect 要测的因子的主要成本通道。
|
||||||
|
|
||||||
|
**两者的耦合。** #6 是 #5 的验收仪器:修完 #5 后判断"这次是否真的没推理",靠正文长度不可靠,靠 `reasoning_content` 也不行(MiniMax 非流式恒为空、正文无 `<think>` 标签)。因此 **#6 先落地,#5 的测试断言它**。
|
||||||
|
|
||||||
|
## 2. 根因
|
||||||
|
|
||||||
|
空 dict 同时承载了两种语义:「本 provider 无需注入任何参数」与「我们不知道本 provider 怎么表达」。二者混同,就只能靠"表里没有 = 不发"兜底,静默失效随之产生。
|
||||||
|
|
||||||
|
更深一层:`ProviderProfile` 的注册单位是 **provider**,而"能否关闭推理"是 **model** 的属性。实测证明同一 provider 内部代际差异是决定性的——MiniMax-M3 可关,M2.7 / M2.5 **固有不可关**(三种参数形态实测全部无效,OpenRouter 与 models.dev 独立登记为 mandatory)。provider 级的表在物理上表达不了这件事。
|
||||||
|
|
||||||
|
业界佐证:注册单位下沉到 model 级的(LiteLLM、models.dev、LangChain、OpenRouter、Helicone)都有显式失败通道;仍停在 provider 级的(Portkey、LlamaIndex)恰是失败语义最差的两家,均静默丢弃。**注册粒度与失败语义是同一个问题的两面。**
|
||||||
|
|
||||||
|
## 3. 决策摘要
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| D1 | **形态留 provider 级,能力下沉 model 级**。形态 = 参数长什么样(数年不变);能力 = 能否关闭(每代都变) |
|
||||||
|
| D2 | **「未知 / 不支持 / 不干预」必须是三个不同的值**,落在三个不同层次 |
|
||||||
|
| D3 | **遇到"关不掉"的模型报错,不静默放行**;报错在装配期,请求期兜底 |
|
||||||
|
| D4 | **「开」的默认档定 `medium`,允许 per-source 覆盖**(经已有 `extra_body`,不新增字段) |
|
||||||
|
| D5 | `enable_thinking` **纳入缓存指纹**(配套,必做) |
|
||||||
|
| D6 | `reasoning_tokens` 的文档措辞为「**本次调用**未上报」,非「该源未上报」(配套,必做) |
|
||||||
|
|
||||||
|
D4 的依据:业界对「开」映射到哪一档**无语义共识**(LiteLLM 用 2 的幂、OpenRouter 用百分比、Helicone 一律折半),唯一的工程共识是**该映射必须是可覆盖的常量**。选 `medium` 是因为 qwen 的 `enable_thinking:true` 与 deepseek 的 `thinking:{enabled}` 都不指定预算、由模型自定,`medium` 是五档中语义最接近"厂商正常强度"的一档;选 `high` 等于库替所有下游做"加钱换质量"的业务判断,违反零业务假设。
|
||||||
|
|
||||||
|
## 4. 数据模型
|
||||||
|
|
||||||
|
### 4.1 形态层(provider 级)
|
||||||
|
|
||||||
|
`ProviderProfile` 两档由 `dict` 放宽为 `dict | None`:
|
||||||
|
|
||||||
|
| 值 | 含义 | 当前实例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `{...}` | 已知的注入片段 | qwen / deepseek / minimax |
|
||||||
|
| `{}` | 已知**无需注入**即处于该档 | 无(保留为自然零值) |
|
||||||
|
| `None` | **未知**:库不知道该 provider 如何表达 | `openai` 两档 |
|
||||||
|
|
||||||
|
```python
|
||||||
|
"minimax": ProviderProfile(
|
||||||
|
name="minimax",
|
||||||
|
thinking_on={"reasoning_effort": "medium"},
|
||||||
|
thinking_off={"reasoning_effort": "none"},
|
||||||
|
strip_think_tags=False,
|
||||||
|
),
|
||||||
|
"openai": ProviderProfile(
|
||||||
|
name="openai", thinking_on=None, thinking_off=None, strip_think_tags=False,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
`openai` 填 `None` 而非补 `reasoning_effort`,理由是该段名在实践中已被复用为**任意 OpenAI 兼容厂商的兜底**(`dissect/.env:116` 把 `kimi-k3` 挂在 `provider=openai` 下)。向未知厂商下发 `reasoning_effort` 会招致 400;标为未知则让误配在装配期显式暴露。真·OpenAI 推理模型的使用者走 `register_provider`——这正是 D11 承诺的"新 provider = 一个条目"。
|
||||||
|
|
||||||
|
qwen / deepseek 两条实测正确,**不动**。
|
||||||
|
|
||||||
|
### 4.2 能力层(model 级,新增)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
|
||||||
|
can_disable: bool
|
||||||
|
evidence: str
|
||||||
|
```
|
||||||
|
|
||||||
|
登记表键为模型名精确匹配,**只登记在用的模型**,未登记即"未知"并走退化路径:
|
||||||
|
|
||||||
|
| 模型 | `can_disable` | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10,`reasoning_effort=none` 稳定关闭 |
|
||||||
|
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter `mandatory:true` |
|
||||||
|
| `MiniMax-M2.5` | `False` | 同上 |
|
||||||
|
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
|
||||||
|
| `deepseek-v4-pro` | `True` | 实测 `thinking:{disabled}` 关闭 |
|
||||||
|
|
||||||
|
注入方式沿用 D11 的纯函数注册纪律:`get_capability(model, *, table=None)` 与 `register_capability(...)` 返回新表,经 `capabilities` 参数注入,与现有 `registry` 参数同形,**不引入模块级可变状态**。
|
||||||
|
|
||||||
|
**不引入 models.dev / LiteLLM 的 JSON 作为运行时依赖**——违反依赖极简与纯 asyncio 中立(import 期发网络请求)。二者仅作为写表时的对照参考;本次三条 MiniMax 实测与它们的登记 100% 吻合,这本身就是表可信的旁证。
|
||||||
|
|
||||||
|
### 4.3 三个值的层次归属(D2)
|
||||||
|
|
||||||
|
| 语义 | 载体 | 层次 |
|
||||||
|
|---|---|---|
|
||||||
|
| **不干预**(调用方不表态) | `SourceConfig.enable_thinking is None` | 调用方意图 |
|
||||||
|
| **未知**(库不知道怎么表达) | `ProviderProfile` 该档为 `None` | 形态层 |
|
||||||
|
| **不支持**(模型做不到) | `ThinkingCapability.can_disable is False` | 能力层 |
|
||||||
|
|
||||||
|
三者不可互相替代:不干预是意图缺失,未知是知识缺失,不支持是能力缺失。当前实现把后两者塌缩成空 dict,是 issue #5 的根因。
|
||||||
|
|
||||||
|
## 5. 判定与失败语义(D3)
|
||||||
|
|
||||||
|
单一判定函数收口,形态层与能力层在此相遇:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def resolve_thinking(profile, capability, enable_thinking) -> Mapping[str, Any]:
|
||||||
|
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(由调用点翻译为领域错误)。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
真值表:
|
||||||
|
|
||||||
|
| # | 条件 | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | `enable_thinking is None` | 不注入。与 `False` 严格区分 |
|
||||||
|
| R2 | 形态层该档为 `None` | **报错**,文案指路 `register_provider` 或 `extra_body` |
|
||||||
|
| R3 | `enable_thinking is False` 且 `can_disable is False` | **报错**:调用方要的是"不推理"的语义保证,给不了必须说 |
|
||||||
|
| R4 | 模型未登记(能力未知) | 按形态层注入 + `loguru.warning`,不阻断 |
|
||||||
|
| R5 | 其余 | 按形态层注入 |
|
||||||
|
|
||||||
|
R3 与 R4 的极性相反,这是刻意的,借鉴 LiteLLM 的两极性纪律:**"关不掉"用错的后果是下游带着错误前提做实验(opt-in,从严);"未登记"多为新模型上线(opt-out,从宽)**,误拒会让库成为升级路上的绊脚石。
|
||||||
|
|
||||||
|
### 5.1 报错位置:两处,共用同一份判定
|
||||||
|
|
||||||
|
| 位置 | 异常 | 覆盖 |
|
||||||
|
|---|---|---|
|
||||||
|
| `client.py:from_settings`(`:248` 已在此解析 profiles) | `ValueError`(装配期) | `from_env` / `from_settings` 两条工厂路径,即 90% 场景 |
|
||||||
|
| `OpenAICompatTransport` | `RequestRejectedError`(四分类之一,不重试不换源) | 构造函数全量注入路径 |
|
||||||
|
|
||||||
|
这不是重复判定:`get_provider` 现在就是同一形态(`client.py:248` + `openai_compat.py:313`)。双点校验的必要性来自 issue #1 的教训——**装配守卫必须任何构造路径都生效**。
|
||||||
|
|
||||||
|
**绝不在 `_build_payload` 里抛裸 `ValueError`**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`。
|
||||||
|
|
||||||
|
## 6. reasoning_tokens 采集(issue #6)
|
||||||
|
|
||||||
|
照搬 issue #3 的 `_coerce_cached_tokens` 形态:只收非负整数,显式排除 `bool`(`isinstance(True, int)` 为真,放行会把 `True` 记成 1)。
|
||||||
|
|
||||||
|
`LLMResponse` / `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`——字段顺序是公共承诺(`types.py:1-5`),只增不删不改名。
|
||||||
|
|
||||||
|
流式与非流式对称取值:`completion_tokens_details` 在最后的 usage 帧里,`missing_done="salvage"` 打捞路径拿不到时记 `None` 而非 `0`(现有代码天然满足:`sink` 无 usage 时 `_coerce_*` 返回 `None`)。
|
||||||
|
|
||||||
|
**`pricing.py` 一行不改**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。这是归因缺口,不是计费缺口。
|
||||||
|
|
||||||
|
**缓存路径无需改动**:`CacheMW._rehydrate` 按 `_RESPONSE_FIELDS` 动态过滤(`cache.py:28,133`),旧条目缺该字段自动落 `None`,语义正确。
|
||||||
|
|
||||||
|
### 6.1 语义澄清(D6)
|
||||||
|
|
||||||
|
实测三家在未推理时都是**整个 `completion_tokens_details` 对象缺失**,无一上报 `0`。且 new-api 在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage,把 ctd 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。因此:
|
||||||
|
|
||||||
|
- docstring 写「**本次调用**未上报」,**不可**写「该源未上报」
|
||||||
|
- 下游判据必须是 `reasoning_tokens in (None, 0)`,写 `== 0` 的条件永远不成立
|
||||||
|
- 这三句要同时进 docstring、CHANGELOG 与 wiki
|
||||||
|
|
||||||
|
## 7. 缓存指纹配套(D5)
|
||||||
|
|
||||||
|
`build_model_fingerprint`(`client.py:63-80`)当前只摘要 `(model, extra_body)`。#5 一旦让 thinking 真正改变请求体,就会出现"关掉推理后重启读到开着推理时的旧缓存"——issue #4 为 `temperature` 写过逐字相同的理由。
|
||||||
|
|
||||||
|
做法:marks 的判据由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`,摘要对象并入该值。**全源不配 `enable_thinking` 时字面量与现值逐字相同,不触发存量缓存冷启动**;dissect 会有一次性冷启动,这是正确行为(旧缓存来自推理开着的调用)。
|
||||||
|
|
||||||
|
## 8. 落点清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `providers.py` | 两档放宽为 `dict \| None`;填 minimax、`openai` 改 `None`;新增 `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability` / `resolve_thinking` |
|
||||||
|
| `transports/openai_compat.py` | `_build_payload` 两分支收敛为一行 `resolve_thinking(...)`;新增 `_coerce_reasoning_tokens`;流式 `:401` 与非流式 `:485` 填值;构造函数收 `capabilities` |
|
||||||
|
| `client.py` | `from_settings` / `from_env` 加 `capabilities`;`:248` 后加装配守卫;`build_model_fingerprint` 纳入 `enable_thinking` |
|
||||||
|
| `types.py` | `LLMResponse` / `TransportResult` 尾部加 `reasoning_tokens` |
|
||||||
|
| `middleware/retry.py` | `_build_response` 透传 |
|
||||||
|
| `ports.py` | `record_llm_call` 21 → 22 字段 |
|
||||||
|
| `telemetry/{sqlite,postgres}.py` | 建表列 + `_BACKFILL_COLUMNS` 迁移 + `_COLUMNS`,**新列排末尾**(两处注释均有明文要求) |
|
||||||
|
| `middleware/telemetry.py` | `_record` + 三个 `emit_*` 入口 |
|
||||||
|
|
||||||
|
## 9. 测试策略
|
||||||
|
|
||||||
|
本次改动的正确性**与具体模型强相关**,mock 只能验证代码路径、无法验证"这个参数在这个模型上是否真的关掉了推理"。因此核心行为**必须由真实 API 多轮调用验证**。
|
||||||
|
|
||||||
|
### 9.1 三层分工
|
||||||
|
|
||||||
|
| 层 | 内容 | 是否门控合并 |
|
||||||
|
|---|---|---|
|
||||||
|
| unit | `resolve_thinking` 真值表(R1–R5)、`_coerce_reasoning_tokens` 形态防御、注入优先级、装配守卫报错、缓存指纹变化与不变性 | **是**(CI 可跑) |
|
||||||
|
| integration | 遥测两后端新列写入与 ALTER 迁移 | **是** |
|
||||||
|
| **e2e(真实 API)** | 见 9.2 | 打 `slow` 标记被默认排除;**合并前必须 `-m slow` 真跑并存档报告** |
|
||||||
|
|
||||||
|
不让本组阻断 CI 的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404,另有一次 `network_error` 连续三次耗尽源导致 L2 假红。让外部波动阻断合并,会把测试变成噪声源。
|
||||||
|
|
||||||
|
**实现机制**:给本组打项目既有的 `slow` 标记。`pyproject.toml` 的 `addopts = "-m 'not slow'"` 默认排除它(该配置的注释原文:「慢速测试,CI 按需跑」),合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。实测效果:`make ci` 由 7 分钟降至 91 秒。
|
||||||
|
|
||||||
|
**一处必须澄清的事实**:`make test` 跑的是 `pytest tests/`,**包含 `tests/e2e/`**——只要 `.env` 有凭据,既有的轻量 e2e 冒烟就会真跑。所以「e2e 不进 CI」这句对本项目**并不成立**,只有打了 `slow` 的才被排除;本节初稿写成前者,是错的。「不自动门控」也不等于「可跳过」——沿用既有口径(`tests/e2e/test_smoke_gateway.py:22` 的 reason 写着「验收前必须真跑」)。
|
||||||
|
|
||||||
|
### 9.2 e2e 覆盖矩阵
|
||||||
|
|
||||||
|
沿用既有 e2e 约定:`dotenv_values(".env")` + `pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`,结构化报告输出至 `tests/outputs/e2e/`。
|
||||||
|
|
||||||
|
| # | 场景 | 源 | 轮数 | 判据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| L1 | `enable_thinking=False` | MiniMax-M3 | ≥10 | 每轮 `completion_tokens < 30` 且 `reasoning_tokens` 恒 `None` |
|
||||||
|
| L2 | `enable_thinking=True` | MiniMax-M3 | ≥10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
|
||||||
|
| L3 | `enable_thinking=None` | MiniMax-M3 | ≥10 | 不注入任何 thinking 参数(基线) |
|
||||||
|
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | ≥5 | 实发 `high`,profile 的 `medium` 被覆盖 |
|
||||||
|
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 ≥10 | 同 L1 / L2(库默认 `stream=True`,这是主路径) |
|
||||||
|
| L6 | `enable_thinking=False` | qwen | ≥10 | 关闭 |
|
||||||
|
| L7 | `enable_thinking=False` | deepseek | ≥10 | 关闭 |
|
||||||
|
| L8 | **能力表漂移哨兵** | 全部登记模型 | 各 ≥5 | 实测行为与 `can_disable` 声明一致 |
|
||||||
|
| L9 | `enable_thinking=False` + M2.7 → 装配期报错 | — | — | 纯本地,无需真实调用 |
|
||||||
|
|
||||||
|
轮数由环境变量可调高,默认 ≥10。总量约 100–150 次调用。
|
||||||
|
|
||||||
|
### 9.3 三条必须遵守的测试纪律
|
||||||
|
|
||||||
|
**(a)判别量只能是 `reasoning_tokens`。**(2026-08-02 e2e 实测修正:本节初稿写的是"主判据用 `completion_tokens`",被数据推翻。)两档的输出长度分布**重叠**——关闭档实测最高 46(模型偶尔把解题过程写进正文),开启档最低 13(medium 档想得少的轮次),按长度阈值判两个方向都会误判;而 `reasoning_tokens` 在同一批 30 轮里干净分开。`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路。另配一个不含魔数的确定性锚点:关闭档 `prompt_tokens` 严格小于开启档(实测 194 < 207)。
|
||||||
|
|
||||||
|
**(b)多轮 + 计数判定,不用单轮判定。** 关闭方向要求**每轮**都满足(关掉后 `completion_tokens` 极稳定,实测 4–10);开启方向只要求**多数轮**满足(推理量方差大)。
|
||||||
|
|
||||||
|
**(c)源不可用必须跳过并显式记录为"未覆盖",不得静默计入通过。** 报告里要能一眼看出哪些矩阵行没跑到。
|
||||||
|
|
||||||
|
### 9.4 漂移哨兵(L8)的定位
|
||||||
|
|
||||||
|
能力表过期是必然事件(LiteLLM 有过 `gpt-5.1-mini` 漏登记导致误拒的真实事故)。L8 用真实调用反向校验每条登记,是这张表的**过期告警**——模型升级后若 `can_disable` 声明失真,这里会先炸。建议纳入发版前清单定期执行。
|
||||||
|
|
||||||
|
## 10. 明确不做
|
||||||
|
|
||||||
|
不为中转的观测漂移在库内加任何机制(多轮取众数、渠道探测、重试到拿到 `reasoning_tokens`)——中转路由不受请求参数影响,探测结果不可迁移,属 YAGNI 违规;该问题在运维侧解决,写入 wiki 前提。
|
||||||
|
|
||||||
|
不改 `SourceConfig` 的公开字段形态:`enable_thinking` 保持 `bool | None`。分档需求走已有的 `extra_body` / `overlay`,两条路径已进缓存 key 与 `sampling` 遥测列,新增字段则要额外接这两处,是隐藏成本。
|
||||||
|
|
||||||
|
不动 qwen / deepseek 的 profile;不碰 `pricing.py`;不引入任何新依赖。
|
||||||
|
|
||||||
|
## 11. 验收标准
|
||||||
|
|
||||||
|
1. `ENABLE_THINKING=false` + MiniMax-M3 → 请求体含 `reasoning_effort: none`,响应 `reasoning_tokens is None`,真实 API 多轮验证
|
||||||
|
2. `ENABLE_THINKING=false` + MiniMax-M2.7 → **装配期报错**,文案说明该模型无法关闭推理
|
||||||
|
3. `ENABLE_THINKING` 任意非 `None` + `provider=openai` → **装配期报错**,指路 `register_provider` / `extra_body`
|
||||||
|
4. 未登记模型 + 任意 `enable_thinking` → 正常注入 + 一条 warning
|
||||||
|
5. `extra_body={"reasoning_effort":"high"}` 仍覆盖 profile 注入
|
||||||
|
6. 流式与非流式均能采到 `reasoning_tokens`;打捞路径记 `None` 而非 `0`
|
||||||
|
7. 改 `enable_thinking` → 缓存 key 变化;不配该项的存量 scope key 逐字不变
|
||||||
|
8. 遥测两后端新列可写、旧库经 ALTER 迁移后可写
|
||||||
|
9. e2e 报告存档于 `tests/outputs/e2e/`,矩阵覆盖情况可核
|
||||||
|
|
||||||
|
每条均需"先失败后通过"的证据(测试结果门)。
|
||||||
|
|
||||||
|
## 12. 影响与风险
|
||||||
|
|
||||||
|
**这是行为变更,不是纯修复。** MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效",CHANGELOG 须醒目标注;dissect 会有一次性缓存冷启动。
|
||||||
|
|
||||||
|
**dissect 的 Phase-0 实验设计需调整。** M2.7 上做不了"开思考 vs 关思考"的对照——这是模型固有属性,任何库层改动都无法改变。可行替代是只在 M3 上做该对照,或将因子改为"高档 vs 低档"。此结论须同步给 dissect。
|
||||||
|
|
||||||
|
**能力表的正确性依赖实测,且经中转。** 三条 MiniMax 结论均在自建 new-api 中转下取得,直连官方端点未验证;表中每条 `evidence` 须写明这一点。若下游改为直连,L8 漂移哨兵是发现失真的第一道防线。
|
||||||
|
|
||||||
|
**新增两处失败面。**(本段两次修正:初稿只列了 `openai` 那一处、遗漏 M2.x;二稿又把 M2.x 那处写成「合并即打挂 dissect」,同样不准确——见下。)
|
||||||
|
|
||||||
|
其一是 `provider=openai` + 配了 `ENABLE_THINKING`,经全仓与 dissect 检索当前无此用法(dissect 的 K3 scope 用 `provider=openai` 但未配该项)。
|
||||||
|
|
||||||
|
其二是**关不掉推理的模型 + `ENABLE_THINKING=false`**,而 `dissect/.env:80,85` 正是 `MiniMax-M2.7` + `false`。准确的影响是:**dissect 升到 1.0.6 之后**,该 scope 装配会抛 `ValueError`;它当前跑着的版本不受本次发布影响。但 `dissect/requirements.txt:7` 声明的是 `polygateway>=1.0.1,<1.1` —— 一个**范围**而非精确 pin,`1.0.6` 落在范围内,所以任何一次 `pip install -U`、重建环境或 CI 重装依赖都会**自动**装上它,无需谁刻意升级。换言之不是「突然挂」,而是「下次装依赖时挂」。
|
||||||
|
|
||||||
|
这是本设计的**预期行为**(给不了「不推理」的语义保证就必须说),dissect 侧的处置是改配置:该对照只能在 M3 上做,或把因子改为「高档 vs 低档」。
|
||||||
|
|
||||||
|
**三个参考下游零破坏**:VT / CHS / GovDoc 的 thinking 用法均为二元,本方案不改公开字段形态。
|
||||||
|
|
||||||
|
## 13. 另立 issue(不在本次范围)
|
||||||
|
|
||||||
|
`kimi-k3` 拒绝 `temperature=0`(400),而 400 归 `RequestRejectedError` 不重试不换源,下游统一下发 `temperature=0` 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。
|
||||||
|
|
||||||
|
`qwen` 的 `strip_think_tags=True` 已过时(实测走 `reasoning_content`,正文无 `<think>` 标签),无害死代码,可顺带清理或另记。
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 治理后端故障归位为 scope 级不可用设计(Issue #7)
|
||||||
|
|
||||||
|
- **日期**: 2026-08-06
|
||||||
|
- **来源**: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败,基于 1.0.1 源码核查)
|
||||||
|
- **状态**: **已批准(2026-08-06)**,待 `writing-plans`
|
||||||
|
- **触发档位**: 强制(变更 `errors.py` 公共错误类型树 = 库对下游的承诺)
|
||||||
|
- **方案范围**: 人类已选定方向 A′ 并明确要求单一方案,故本文不列平行备选,仅在 §4 记录被否决路线及否决理由
|
||||||
|
|
||||||
|
## 1. 目标与非目标
|
||||||
|
|
||||||
|
| | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **G1** | `GovernanceBackendError` 归入 `GatewayUnavailableError` 之下,使"该延期重投的失败"在类型上闭合——调用方一条 `except GatewayUnavailableError` 覆盖完整,漏接在物理上不可能 |
|
||||||
|
| **G2** | 把混在同一类里的**装配期缺陷**("未知源")拆出去,使其**不**被误判为可重投 |
|
||||||
|
| **G3** | `retry_after_s` 取非零值,避免后端故障期间下游零延迟批量重投形成忙循环 |
|
||||||
|
| **G4** | 公开错误面文档化:README 增"会到达调用方 / 库内吸收"两列表,`ARCHITECTURE.md` §6.1 回补缺失的 `GovernanceBackendError` 行 |
|
||||||
|
| **非目标** | 不改 fail-closed 降级方向(限流/熔断后端不可用 → 报错而非放行,库铁律不动);不改后端重连/健康探测;不新增配置项;不改 `TransientError`/`SourceDeadError` 的库内吸收行为 |
|
||||||
|
|
||||||
|
### 1.1 Issue 前提的四处修正(按 1.0.6 源码核实)
|
||||||
|
|
||||||
|
| Issue 原文 | 实际情况 |
|
||||||
|
|---|---|
|
||||||
|
| 泄漏路径为 `try_enter` / `try_acquire` 两条 | **三条**。`middleware/retry.py:216` 每轮循环开头的 `progress_age_s()` 同样在 catch 之外,直达调用方 |
|
||||||
|
| (未提及构造点数量) | 全库 **22 处** `raise GovernanceBackendError`,分布于 4 个文件 |
|
||||||
|
| 方向 A 只需改类型树 | 其中 **2 处语义完全不同**(见 §3.4),整类归入"可重投"会制造镜像 bug |
|
||||||
|
| `retry_after_s` 取 0,「docstring 已写 0 = 可立即重试,语义上是通的」 | 语义通,**工程上不通**。见 §3.2 |
|
||||||
|
|
||||||
|
另需记录一处根因:`ARCHITECTURE.md:372-378` §6.1 的错误分类表里 `GovernanceBackendError` **一次都没出现**。它是 M2 引入分布式后端时新增的,当时未回补架构表,于是它在"调用方视角的分类学"中从来就没有位置——README 的遗漏是这个遗漏的下游后果。
|
||||||
|
|
||||||
|
## 2. 影响面的决定性前提(改动安全性的依据)
|
||||||
|
|
||||||
|
| 事实 | 证据 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 库内仅一处 `except GatewayUnavailableError` | `middleware/telemetry.py:250`,写法为 `except (GatewayUnavailableError, GovernanceBackendError)` | 变成父子关系后该处由"并列捕获"退化为"父类捕获",**行为逐字不变**,库内零回归 |
|
||||||
|
| 加父类是纯扩大 | 下游既有 `except GovernanceBackendError` 全部照旧命中 | 不违反 CLAUDE.md §4.3「已被下游消费的公共类型只增不删不改名」 |
|
||||||
|
| `QuotaGate`/`BreakerGate` 是后端异常的唯一入口 | 两类 docstring 自述,三处装配 `retry.py:186` / `ocr.py:122` / `embedding.py:123` | scope 注入点收敛为 2 个类、3 处装配 |
|
||||||
|
| 三个装配点都持有 `self._scope` | `retry.py:183`、`ocr.py:116`、`embedding.py:118` | 注入无需新增上游参数传递链 |
|
||||||
|
|
||||||
|
## 3. 选定方案
|
||||||
|
|
||||||
|
### 3.1 类型树变更
|
||||||
|
|
||||||
|
`SCOPE_REASONS` 增枚举值 `governance_backend_down`;`GovernanceBackendError` 改继承 `GatewayUnavailableError`,`reason` 恒为该值(与 `CircuitOpenError` 恒为 `circuit_open` 同构,是本库已有的表达手法)。
|
||||||
|
|
||||||
|
构造签名保持"首参为 message"的位置参数形态,以免 22 处构造点与既有测试全部改写:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GovernanceBackendError(GatewayUnavailableError):
|
||||||
|
def __init__(self, message, *, scope, retry_after_s=GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
||||||
|
source_name=None):
|
||||||
|
super().__init__(scope=scope, reason="governance_backend_down",
|
||||||
|
retry_after_s=retry_after_s, source_name=source_name)
|
||||||
|
self.args = (message,) # 见 §3.5
|
||||||
|
```
|
||||||
|
|
||||||
|
`scope` 为必填 keyword(P4 显式优于隐式:它在三层调用点全部可得,给默认值只会掩盖装配疏漏)。
|
||||||
|
|
||||||
|
### 3.2 `retry_after_s` 的取值(本设计的核心权衡)
|
||||||
|
|
||||||
|
Issue 建议取 0。**否决**:下游 `schedule_retry(after_s=0)` 会立刻重投,Redis 挂掉期间队列里积压的任务将以零延迟批量重投,对着一个已经挂掉的后端打忙循环——把一次故障放大成一场风暴。这与本 issue 想修的问题同源:都是"分类正确但处置参数错误"。
|
||||||
|
|
||||||
|
已考虑并否决的两个替代取值:
|
||||||
|
|
||||||
|
| 取值 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| 复用 `BackpressureConfig.poll_interval_s`(与 `quota_exhausted` 同源,`retry.py:299` 有先例) | 该值只有三个装配点持有,后端层 11 处构造点拿不到;为此给 `RedisLimiter`/`RedisBreaker` 增构造参数,是让后端层去持有"重投策略"——违反 P7(决策逻辑与状态存储分离),后端只该知道"我坏了",不该知道这在治理上意味着什么 |
|
||||||
|
| 新增配置项 `PGW_GOVERNANCE_BACKEND_RETRY_AFTER_S` | YAGNI。目前无任何下游表达过需要调它;真需要时下游可完全忽略 `exc.retry_after_s` 用自有退避 |
|
||||||
|
|
||||||
|
**选定**:`errors.py` 模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,作为构造默认值,docstring 写明理由——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),取一个保守固定值;下游若有自己的退避策略可忽略此值。本库对 scope 级异常硬编码语义值已有先例(`retry.py:206` 的 `no_sources` 取 `0.0`)。
|
||||||
|
|
||||||
|
它**不是环境配置项**,故不落 CLAUDE.md §4.5「严禁硬编码默认值」的论域——§4.5 约束的是 `pydantic-settings` + `.env` 管辖的工程配置(超时、并发、限额),而本常量是异常自身携带的语义默认值,与 `no_sources` 取 `0.0` 同性质。docstring 需显式写明这一点,避免后来者误加环境键。
|
||||||
|
|
||||||
|
### 3.3 `scope` 的三层来源
|
||||||
|
|
||||||
|
| 层 | 构造点数 | scope 来源 | 改动 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `backends/redis/limiter.py` | 6 | `self._scope`(`:170`) | 补 `scope=self._scope` |
|
||||||
|
| `backends/redis/breaker.py` | 5 | `self._scope`(`:291`) | 补 `scope=self._scope` |
|
||||||
|
| `middleware/breaker.py` `BreakerGate` | 5 | **需注入** | 构造函数增 `scope: str`,三处装配传 `self._scope` |
|
||||||
|
| `middleware/ratelimit.py` `QuotaGate` | 4 | **需注入** | 同上 |
|
||||||
|
|
||||||
|
包装器对后端自抛异常的 `except GovernanceBackendError: raise` 原样放行**保持不变**——后端层已填好 scope,重建实例只会制造"同一异常构造两次"的怪味且覆盖值相同。
|
||||||
|
|
||||||
|
### 3.4 "未知源"拆分为独立错误类
|
||||||
|
|
||||||
|
`backends/memory/limiter.py:92` 与 `backends/redis/limiter.py:198` 的 `_cfg()` 在源名不在配置字典中时抛 `GovernanceBackendError`。**这不是后端故障**,是限流后端拿到的源列表与治理循环的对不上——装配期缺陷,正常不可达。
|
||||||
|
|
||||||
|
若随整类归入"延期重投、不扣失败预算",配置写错的任务将**永远重投、永远不进死信**,运维永远收不到告警——正是本 issue 要修的 bug 的镜像。
|
||||||
|
|
||||||
|
新增 `SourceNotConfiguredError(PolyGatewayError)`,**有意不放在** `GatewayUnavailableError` 之下:下游默认按"任务的错"处置 → 扣失败预算 → 进死信 → 人能看见。这是缺陷该有的可见性。该类进 `__init__.py` 公共导出(下游可选择性识别,但不识别也能得到正确处置)。
|
||||||
|
|
||||||
|
### 3.5 message 保全
|
||||||
|
|
||||||
|
`GatewayUnavailableError.__init__` 会把 message 覆盖为 `f"{scope} 网关暂时不可用: {reason}"`,而 22 处构造点携带的诊断串(如 `限流后端 try_acquire 失败: {exc}`)是排障的主要线索,不可丢。方案是 `super().__init__()` 后覆写 `self.args = (message,)`,使 `str(exc)` 仍为原诊断串,而 `scope`/`reason`/`retry_after_s` 作为结构化字段并存。父类不动——它的 message 生成逻辑对 `CircuitOpenError`/`AllSourcesExhausted` 仍然正确。
|
||||||
|
|
||||||
|
## 4. 被否决的路线
|
||||||
|
|
||||||
|
| 路线 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| **B: 只补文档,类型树不动** | 正确性依赖每个下游都读到那句话。本库下游不止一个,且本 issue 本身就是"文档读不出来"引发的——同一个失效模式不能用同一种药治 |
|
||||||
|
| **C: 类型树不动,在 RetryMW 边界包成 `AllSourcesExhausted`** | 比 A′ 更具破坏性:下游现有 `except GovernanceBackendError` 会直接失效。加父类是扩大,换类型是破坏 |
|
||||||
|
| **D: 后端层不再构造该异常,原始异常穿透由包装器统一翻译**(初评时倾向,已否决) | `backends/redis/limiter.py:133,151` 的 `RedisPermit.release/settle` 依赖 `except GovernanceBackendError` 实现**释放侧降级**(失败只 warning 不冒泡)。原始 redis 异常穿透后该处接不住,会破坏这条既有降级行为;改为 `except Exception` 则违反 P5 |
|
||||||
|
|
||||||
|
## 5. 行为审计(既有行为逐条标注)
|
||||||
|
|
||||||
|
| 既有行为 | 出处 | 处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 限流/熔断后端不可用 → 报错而非放行(fail-closed) | 库铁律 | **保留**,一字不改 |
|
||||||
|
| 记账路径后端故障降级为 warning | `middleware/retry.py:404` `_record_quietly` | **保留**。仅闸门路径需要到达调用方 |
|
||||||
|
| permit `release`/`settle` 失败降级 warning | `redis/limiter.py:133,151` | **保留**(§4 路线 D 因此被否决) |
|
||||||
|
| 遥测对后端故障发 `emit_terminal_failure` | `middleware/telemetry.py:250` | **保留**,父子关系后由父类分支承接,行为不变 |
|
||||||
|
| `except GovernanceBackendError: raise` 原样放行 | 包装器 9 处 | **保留** |
|
||||||
|
| "未知源"抛 `GovernanceBackendError` | `memory/limiter.py:92`、`redis/limiter.py:198` | **替换**为 `SourceNotConfiguredError`(§3.4) |
|
||||||
|
| `str(exc)` 为诊断串 | 22 处 | **保留**(§3.5 显式保全) |
|
||||||
|
|
||||||
|
## 6. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 回答 |
|
||||||
|
|---|---|
|
||||||
|
| **并发与取消** | 不适用于新增并发路径。异常构造是纯同步无状态操作,不引入共享状态。`CancelledError` 穿透路径完全不受影响——本设计不新增任何 `except` 子句,`_record_quietly` 中 `except asyncio.CancelledError`(`:402`)先于 `except GovernanceBackendError`(`:404`)的顺序不动 |
|
||||||
|
| **降级方向** | 不变。fail-closed 是本类存在的理由,本设计只改"它被归入哪一类",不改"它是否被抛出" |
|
||||||
|
| **幂等与重复** | 异常类型变更不涉及幂等性。需注意的是下游行为改变:同一次后端故障从"扣失败预算"变为"延期重投",重投次数由下游队列策略决定——这正是期望的变更,已在 CHANGELOG 行为变更段声明 |
|
||||||
|
| **持久化与原子性** | 无持久化改动。遥测落库路径(`emit_terminal_failure`)的字段与调用时机均不变 |
|
||||||
|
|
||||||
|
## 7. 错误处理与测试策略
|
||||||
|
|
||||||
|
新失败面只有一个:`SourceNotConfiguredError`,它落在四分类之外。这**不违反** CLAUDE.md §4.2「一切失败必须落入四分类」——该铁律的论域是 **transport 层翻译的调用失败**(`ARCHITECTURE.md` §6.2 的翻译规则表逐条对应 HTTP 状态码与解析失败),而本库已有一整族异常合法地处在四分类之外:`GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` 都不是四分类之一,`ARCHITECTURE.md` §6.1 把它们单列一行,因为它们回答的是另一个问题——"整个 scope 还能不能用",而非"这一次调用怎么失败的"。
|
||||||
|
|
||||||
|
`SourceNotConfiguredError` 属于第三个论域:**装配缺陷**(配置与治理循环不一致,正常不可达)。四分类决定重试/换源/熔断,而装配缺陷根本不该进入治理循环去被"决定",它应当立刻失败并让人看见。将其塞进四分类中的任何一类都会赋予它一份不该有的治理语义(如 `RequestRejectedError` 会让下游以为请求本身有问题、去修请求)。§9 Q1 保留了"复用 `RequestRejectedError`"作为备选供人类权衡。
|
||||||
|
|
||||||
|
| 测试 | 位置 | 先失败后通过的证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GovernanceBackendError` 可被 `except GatewayUnavailableError` 接住 | `tests/unit/test_errors.py` | 改前 `pytest.raises(GatewayUnavailableError)` 必失败 |
|
||||||
|
| 三条泄漏路径(`try_acquire`/`try_enter`/`progress_age_s`)抛出的异常携带正确 `scope` 与非零 `retry_after_s` | `tests/unit/test_backpressure.py` — **三条桩都需新增**(Codex 审计划时核出: `:176-186` 是记账侧 `record_success`/`record_failure`/`mark_progress` 的降级桩,不是闸门路径;`progress_age_s` 仅 `:243-257` 覆盖包装行为、不验 scope) | 改前无 `scope` 属性,`AttributeError` |
|
||||||
|
| `str(exc)` 仍为原诊断串 | `tests/unit/test_errors.py` | 防 §3.5 回归 |
|
||||||
|
| 未知源抛 `SourceNotConfiguredError` 且**不是** `GatewayUnavailableError` | 改 `tests/unit/test_redis_key_layout.py:70-74`;内存版**当前无覆盖,需新增** | 改前抛 `GovernanceBackendError`,断言"不是 scope 级"必失败 |
|
||||||
|
| Redis 真实掉线时准入侧行为 | `tests/integration/test_redis_cross_connection.py:228-245`(真实 Redis,不 mock) | 断言由 `GovernanceBackendError` 收紧为"是 `GatewayUnavailableError` 且 `reason == governance_backend_down`" |
|
||||||
|
|
||||||
|
## 8. 影响面清单
|
||||||
|
|
||||||
|
| 类别 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **源码** | `errors.py`(新常量+新类+继承变更)、`backends/redis/limiter.py`(7)、`backends/redis/breaker.py`(5)、`backends/memory/limiter.py`(1)、`middleware/breaker.py`(6:构造函数+5 处)、`middleware/ratelimit.py`(5)、`middleware/retry.py`/`ocr.py`/`embedding.py`(各 1 行装配)、`__init__.py`(导出新类) |
|
||||||
|
| **测试** | `tests/unit/test_errors.py`、`test_backpressure.py`、`test_redis_key_layout.py`、`tests/integration/test_redis_cross_connection.py` |
|
||||||
|
| **文档** | `README.md` §"错误模型"增两列表 + `GovernanceBackendError` 行;`ARCHITECTURE.md` §6.1 回补该类并记录本次归位;`migrations/chsanalyzer.md` G1 条目补注;`CHANGELOG.md` 1.1.0;按 `docs-convention.md` §2 同步 Gitea Wiki |
|
||||||
|
| **版本** | **1.1.0**。有行为变更(下游对后端故障的处置路线改变)但无 API 破坏(加父类是扩大),按语义化版本走 minor |
|
||||||
|
| **下游** | CHSAnalyzer3 当前在 1.0.1。升级后 `except GatewayUnavailableError` 即覆盖后端故障,其现有 `except GovernanceBackendError`(若有)继续有效,无需改代码即可获得修复 |
|
||||||
|
|
||||||
|
### 8.1 执行顺序(单一事实源纪律)
|
||||||
|
|
||||||
|
`ARCHITECTURE.md` 是架构单一事实源,`SCOPE_REASONS` 新增值域与 `GovernanceBackendError` 的归位都与其 §6.1 现状冲突。因此 **§6.1 的修订必须先于或同批于代码实现落地**,不得"先改代码、事后补文档"。具体为:人类批准本设计后,`writing-plans` 的第一项任务即为修订 `ARCHITECTURE.md` §6.1(补 `GovernanceBackendError` 与 `SourceNotConfiguredError` 行、scope 级 reason 值域增 `governance_backend_down`、记录本次归位的理由与日期),与实现同一分支、同批提交。
|
||||||
|
|
||||||
|
## 9. 待人类确认的决策点
|
||||||
|
|
||||||
|
(编号用 Q 前缀,避免与 `ARCHITECTURE.md` 的架构决策 D1–D14 混淆)
|
||||||
|
|
||||||
|
**三点均已由人类拍板(2026-08-06),全部采纳本文的选择:**
|
||||||
|
|
||||||
|
| # | 决策 | 裁定 | 被否决的备选及理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Q1 | "未知源"归到哪 | ✅ **拆为 `SourceNotConfiguredError`**,不在 `GatewayUnavailableError` 之下(§3.4) | ① 沿用 `GovernanceBackendError`——配置写错的任务将无限重投、永不进死信、无人发现;② 复用 `RequestRejectedError`——治理行为与选定方案**完全等价**,但名称误导:下游会去查 prompt 而非配置文件 |
|
||||||
|
| Q2 | `retry_after_s` 取值 | ✅ **常量 `5.0`**(§3.2) | 取 0 会让积压任务零延迟同时冲击已挂掉的后端,把一次故障放大成风暴 |
|
||||||
|
| Q3 | 新类是否公共导出 | ✅ **导出**(进 `__init__.py`) | 不导出则下游无法给"配置写错"单独接告警,而导出无成本 |
|
||||||
|
|
||||||
|
## 10. 审批记录
|
||||||
|
|
||||||
|
| 阶段 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| Claude 自审 | 已完成(全部结论对应本会话内 grep/read 输出;§3.5 的 `self.args` 保全机制经 conda 环境实跑验证) |
|
||||||
|
| Codex 独立审 | 已完成(2026-08-06),4 条意见逐条核验见下 |
|
||||||
|
| 人类审批 | ✅ **已批准(2026-08-06)**。方向 A′ 于设计前即由人类选定;Q1–Q3 三个决策点逐条拍板,全部采纳本文选择(见 §9)。可进入 `writing-plans` |
|
||||||
|
|
||||||
|
### 10.1 Codex 意见的核验结果
|
||||||
|
|
||||||
|
| 意见 | 判定 | 处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| ARCHITECTURE §6.1 未同步前实施违反单一事实源(判为阻塞) | **实质成立**,但性质是执行顺序而非设计缺陷——§8 本已把 §6.1 回补列入影响面 | 新增 §8.1 明确"架构文档修订先于/同批于实现" |
|
||||||
|
| §6.1 错误分类表未承认 `GovernanceBackendError`(判为阻塞) | **与上条同源**,且 §1.1 已自陈此为根因 | 同上,由 §8.1 覆盖 |
|
||||||
|
| `SourceNotConfiguredError` 落在四分类外违反 §4.2 铁律(判为阻塞) | **部分成立**:铁律论域被误读——`GatewayUnavailableError` 族本就合法处在四分类之外(§6.1 单列一行)。但原文表述确会引起该疑虑 | §7 补写三个论域的划分论证;§9 Q1 增列"复用 `RequestRejectedError`"备选交人类权衡 |
|
||||||
|
| 硬编码常量与 §4.5 存在张力(建议性) | **成立** | §3.2 补写"非环境配置项"及 docstring 要求 |
|
||||||
|
| Q 编号与架构 D1–D14 混淆(建议性) | **成立** | §9 决策点编号由 `D` 改为 `Q` |
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:governance-backend-error
|
||||||
|
title: "治理后端故障归位为 scope 级不可用(Issue #7)"
|
||||||
|
date: 2026-08-06
|
||||||
|
---
|
||||||
|
|
||||||
|
# 治理后端故障归位为 scope 级不可用(Issue #7)
|
||||||
|
|
||||||
|
全文见 `2026-08-06-governance-backend-error-design.md`。来源: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败)。**状态: 已批准(2026-08-06,人类逐条拍板 Q1/Q2/Q3),待 `writing-plans`。**
|
||||||
|
|
||||||
|
问题: 限流/熔断状态后端故障时库 fail-closed,一个请求都发不出去——语义上就是 scope 级不可用,但 `GovernanceBackendError` 是 `PolyGatewayError` 的**直接子类**,只写 `except GatewayUnavailableError` 的调用方接不住,于是 Redis 抖一下,积压任务一批批消耗业务失败预算进死信,而那是运维重启就好的故障。
|
||||||
|
|
||||||
|
## 选定方案
|
||||||
|
|
||||||
|
| 决策 | 选定 | 关键理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| A 类型树 | `GovernanceBackendError` 改继承 `GatewayUnavailableError`,`SCOPE_REASONS` 增 `governance_backend_down`,`reason` 恒为该值 | 加父类是**扩大**不是破坏(既有 `except GovernanceBackendError` 照旧命中);库内仅 `telemetry.py:250` 一处捕父类且已并列写两者,**零回归** |
|
||||||
|
| B `retry_after_s` | 模块常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,非环境配置项 | 后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻);取 0 会让积压任务零延迟批量重投,把一次故障放大成风暴 |
|
||||||
|
| C scope 来源 | 后端层用 `self._scope`;`QuotaGate`/`BreakerGate` 构造函数注入,三处装配(`retry.py`/`ocr.py`/`embedding.py`)各传一行 | 两个包装器是后端异常的唯一入口,注入点收敛;三处装配本就持有 `self._scope` |
|
||||||
|
| D 未知源拆分 | `_cfg()` 的 2 处改抛新增的 `SourceNotConfiguredError`,**有意不放在** `GatewayUnavailableError` 之下 | 那是装配缺陷不是后端故障;随整类归入"可重投"会让配置写错的任务永远重投、永不进死信——本 issue 要修的 bug 的镜像 |
|
||||||
|
| E message 保全 | `super().__init__()` 后覆写 `self.args = (message,)` | 父类会把 message 覆盖为 `f"{scope} 网关暂时不可用: {reason}"`,而 22 处构造点的诊断串是排障主线索。机制已实跑验证 |
|
||||||
|
|
||||||
|
## 被否决的备选
|
||||||
|
|
||||||
|
| 备选 | 否决原因 |
|
||||||
|
|---|---|
|
||||||
|
| B(issue 原议): 只补文档,类型树不动 | 正确性依赖每个下游都读到那句话;本 issue 本身就是"文档读不出来"引发的,同一失效模式不能用同一种药治 |
|
||||||
|
| C: 在 RetryMW 边界包成 `AllSourcesExhausted` | 比选定方案更具破坏性——下游现有 `except GovernanceBackendError` 直接失效 |
|
||||||
|
| D: 后端层不再构造该异常,原始异常穿透由包装器统一翻译 | 初评时倾向。`redis/limiter.py:133,151` 的 `RedisPermit.release/settle` 依赖 `except GovernanceBackendError` 实现**释放侧降级**,穿透后接不住会破坏该既有行为;改 `except Exception` 则违反 P5 |
|
||||||
|
| `retry_after_s` 复用 `BackpressureConfig.poll_interval_s` | 该值只有三个装配点持有,为此给后端加构造参数等于让状态存储层持有重投策略,违反 P7 |
|
||||||
|
| 新增配置项 `PGW_GOVERNANCE_BACKEND_RETRY_AFTER_S` | YAGNI;无下游表达过需要,真需要时下游可忽略该字段用自有退避 |
|
||||||
|
|
||||||
|
## 对 issue 前提的四处修正
|
||||||
|
|
||||||
|
泄漏路径是**三条**不是两条(`retry.py:216` 的 `progress_age_s()` 同样在 catch 之外);构造点 **22 处**;其中 2 处语义完全不同(未知源);`retry_after_s=0` 语义通但工程不通。
|
||||||
|
|
||||||
|
根因记录: `ARCHITECTURE.md` §6.1 错误分类表里 `GovernanceBackendError` **一次都没出现**——它是 M2 引入分布式后端时新增的,当时未回补架构表,于是它在"调用方视角的分类学"中从来没有位置,README 的遗漏是这个遗漏的下游后果。
|
||||||
|
|
||||||
|
## 独立审查修正(2026-08-06, Codex)
|
||||||
|
|
||||||
|
4 条意见逐条核验: 两条"架构文档未同步"实质成立但性质是执行顺序 → 新增 §8.1 钉死"`ARCHITECTURE.md` §6.1 修订先于/同批于实现";"新错误类违反四分类铁律"**部分成立**——铁律论域被误读(`GatewayUnavailableError` 族本就合法处在四分类之外),但原表述确会引起疑虑 → §7 补写三论域划分论证,并把"复用 `RequestRejectedError`"增列为待人类权衡的备选;两条建议性意见(常量非配置项的说明、决策编号 `D`→`Q` 防与架构 D1–D14 混淆)已采纳。
|
||||||
|
|
||||||
|
相关: [[m2-distributed]]、[[m1-core-design]]、[[m25-resilience]]
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-08-02-thinking-switch-and-reasoning-tokens
|
||||||
|
title: "推理开关与 reasoning_tokens: 供应商实测与业界做法"
|
||||||
|
date: 2026-08-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理开关与 reasoning_tokens:供应商实测与业界做法
|
||||||
|
|
||||||
|
> 类型:findings(事实基础)|日期:2026-08-02|来源:issue #5 / #6 调研
|
||||||
|
> 本文只记录**已验证的事实与其证据**,设计取舍见 `designs/2026-08-02-thinking-capability-design.md`。
|
||||||
|
> 本文的价值不限于这两条 issue——「同一语义、形态因模型而异」是本库长期要面对的一类问题,此处的结论与方法可复用。
|
||||||
|
|
||||||
|
## 1. 实验环境与方法
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 端点 | 自建 new-api 中转(`newapi.iomgaa.online/v1`,OpenAI 兼容) |
|
||||||
|
| 参数 | `temperature=0`、`max_tokens=800`、非流式为主,流式单独验证 |
|
||||||
|
| 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" |
|
||||||
|
| 判据 | `usage.completion_tokens_details.reasoning_tokens`(**唯一可靠的判别量**,见 §2.5) |
|
||||||
|
| 旁证 | `prompt_tokens` 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 |
|
||||||
|
|
||||||
|
**方法论要点(可复用)**:判断一个参数"是否被上游真正消费",`prompt_tokens` 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。
|
||||||
|
|
||||||
|
## 2. MiniMax:真开关是 `reasoning_effort`
|
||||||
|
|
||||||
|
### 2.1 M3 参数矩阵(非流式)
|
||||||
|
|
||||||
|
| 注入参数 | prompt | completion | reasoning_tokens | 判定 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 默认(不传) | 194 | 4 | 无 ctd | 不推理 |
|
||||||
|
| `reasoning_effort=none` | 194 | 10 | 无 ctd | 不推理 |
|
||||||
|
| `reasoning_effort=minimal` | **207** | 129 | 123 | 推理 |
|
||||||
|
| `reasoning_effort=low` | **207** | 98 | 93 | 推理 |
|
||||||
|
| `reasoning_effort=medium` | **207** | 183 | 177 | 推理 |
|
||||||
|
| `reasoning_effort=high` | **207** | 158 | 142 | 推理 |
|
||||||
|
| `thinking={"type":"enabled"}` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||||
|
| `thinking={"type":"disabled"}` | 194 | 4 | 无 ctd | **被静默丢弃** |
|
||||||
|
| `enable_thinking=true` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||||
|
| `enable_thinking=false` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||||
|
|
||||||
|
`prompt_tokens` 194→207 的 13 token 差是硬证据:`reasoning_effort` 被消费时模型注入了推理指令;另四种写法 prompt 恒为 194,参数根本没到达模型。
|
||||||
|
|
||||||
|
### 2.2 `none` 是被识别的真值,不是被当非法值丢弃
|
||||||
|
|
||||||
|
这是一个必须排除的伪解释——若中转把不认识的值直接丢掉,`none` 的表现会与"不传"无异,我们就会误以为它生效。
|
||||||
|
|
||||||
|
反证实验:传乱码值 `reasoning_effort="xyzzy"` → 返回 200、prompt=207、reasoning_tokens=180。**未知值不但没被丢弃,反而开启了推理。** 既然无效值的行为是"开推理",而 `none` 的行为是"不推理",两者不同,`none` 就必然是被识别的枚举值。
|
||||||
|
|
||||||
|
对照组:完全未知的**键** `zzz_bogus_param=1` → prompt=194、无 ctd、无报错,确认未知**键**才会被静默吞掉。
|
||||||
|
|
||||||
|
### 2.3 M2.7 / M2.5 的推理关不掉
|
||||||
|
|
||||||
|
三种参数形态各 3 次,`completion_tokens` 全部落在推理区间:
|
||||||
|
|
||||||
|
| 模型 | 默认(基线) | `reasoning_effort=none` | `thinking:{disabled}` | `thinking:{adaptive}` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| MiniMax-M2.7 | 372/283/285 | 275/301/248 | 310/190/219 | 299/269/246 |
|
||||||
|
| MiniMax-M2.5 | 273/–/256 | 363/353/264 | 286/278/320 | 278/228/259 |
|
||||||
|
|
||||||
|
真关闭应为 5–10("23 12" 两个数字),实测无一接近。
|
||||||
|
|
||||||
|
**三个独立外部来源与实测完全吻合**:
|
||||||
|
|
||||||
|
| 来源 | M3 | M2.7 / M2.5 |
|
||||||
|
|---|---|---|
|
||||||
|
| OpenRouter `/api/v1/models` 的 `reasoning` 描述符 | `mandatory: false` | **`mandatory: true`** |
|
||||||
|
| models.dev 的 `reasoning_options` | `[{"type":"toggle"}]`(二元可控) | `[]`(有推理但无控制手段) |
|
||||||
|
| MiniMax 官方仓库 issue #121 | — | "M2.7 不允许关闭思考",无官方回复 |
|
||||||
|
|
||||||
|
**结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。
|
||||||
|
|
||||||
|
### 2.4 M3 的稳定性
|
||||||
|
|
||||||
|
同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。
|
||||||
|
|
||||||
|
### 2.5 输出长度不是有效判别量(2026-08-02 e2e 补测,各 15 轮)
|
||||||
|
|
||||||
|
初版判据用 `completion_tokens` 阈值区分推理开关,被自己的数据证伪:
|
||||||
|
|
||||||
|
| 档位 | `completion_tokens` 观测范围 | `reasoning_tokens` |
|
||||||
|
|---|---|---|
|
||||||
|
| 关闭(`reasoning_effort=none`) | 4 – **46** | 15/15 轮为 `None` |
|
||||||
|
| 开启(`medium`) | **13** – 186 | 15/15 轮 > 0 |
|
||||||
|
|
||||||
|
**两档的输出长度分布重叠**:关闭档偶尔到 46(模型没照做「只输出两个数字」,把解题过程写进了正文——那是正文不是推理);开启档最低到 13(medium 档想得少的轮次)。按长度阈值判,两个方向都会误判。
|
||||||
|
|
||||||
|
而 `reasoning_tokens` 在同一批 30 轮里干净分开。**这条对下游同样成立**:想判断某次调用是否发生了推理,只能看 `reasoning_tokens`,不能看输出长度。
|
||||||
|
|
||||||
|
另有一个不含魔数的确定性锚点:同一模型上关闭档的 `prompt_tokens` 严格小于开启档(实测 194 < 207),因为供应商在开启时向模板注入了推理指令。这是相对比较,供应商改模板也不会失效。
|
||||||
|
|
||||||
|
## 3. qwen / deepseek:现有 profile 正确
|
||||||
|
|
||||||
|
| 模型 | `enable_thinking=false` | `thinking:{disabled}` | `reasoning_effort=none` | 现有 profile |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| qwen3.7-plus | ✅ 关闭(compl 5) | ✅ 关闭 | ✅ 关闭 | `enable_thinking` — **正确** |
|
||||||
|
| deepseek-v4-pro | ❌ 无效(仍推理 198) | ✅ 关闭(compl 3) | ✅ 关闭 | `thinking:{type}` — **正确** |
|
||||||
|
|
||||||
|
两点附带事实:
|
||||||
|
|
||||||
|
- **`reasoning_effort=none` 在三家都有效**,但这很可能是中转做了参数归一化。**不可据此认为可以统一发一个参数**——下游若直连供应商官方端点,该假设大概率不成立。翻译表必须一家一行。
|
||||||
|
- **qwen 的 `strip_think_tags=True` 已过时**:实测 qwen 走 `reasoning_content` 字段,正文中无 `<think>` 标签。无害,但属于死代码。
|
||||||
|
- **非流式没有 400**:DashScope 系"`enable_thinking` 仅支持流式"的限制经中转不存在。直连时是否仍存在未验证。
|
||||||
|
|
||||||
|
## 4. new-api 中转的三个行为(会污染观测)
|
||||||
|
|
||||||
|
这一节对任何经中转做实测的场景都适用,值得单独记住。
|
||||||
|
|
||||||
|
**(a)不校验参数值。** `reasoning_effort="xyzzy"` 返回 200 并当作"开推理"处理。**意味着"靠上游报错兜底"的设计模式在此失效**——Bedrock 式的"最小交集 + 裸逃生口"在这里等于零保护。
|
||||||
|
|
||||||
|
**(b)静默丢弃未知键。** 默认路径是 struct round-trip(`ConvertRequest` 返回 struct 再 `json.Marshal`),未知键在第一次序列化就消失。new-api 有 per-channel 的 `pass_through_body_enabled` 开关可改变此行为。
|
||||||
|
|
||||||
|
**(c)上游不返回 usage 时用本地 tokenizer 补算并整体替换。** 补算出的 usage 只有三个标量,`completion_tokens_details` 为零值。这直接解释了实测中的双峰现象:
|
||||||
|
|
||||||
|
| 现象 | 解释 |
|
||||||
|
|---|---|
|
||||||
|
| 同一请求 10 次:`prompt=74` 者 6 次不上报 `reasoning_tokens`,`prompt=72` 者 4 次上报,从不交叉 | `74` = 本地估算值,`72` = 上游真值;补算路径吃掉了 ctd |
|
||||||
|
|
||||||
|
**这不是多渠道路由**(MiniMax 侧为单渠道单密钥),也不是配置错误,而是上游偶发不返回 usage 时的兜底逻辑。中转日志中的 `local_count_tokens` 标志可现场确认。
|
||||||
|
|
||||||
|
**对库的直接影响**:`reasoning_tokens` 缺失**不能**解释为"该源不上报这个字段",只能解释为"**本次调用未上报**"。下游若按前者建立统计口径会算错。
|
||||||
|
|
||||||
|
## 5. 业界如何建模"同一语义、形态因模型而异"
|
||||||
|
|
||||||
|
调研覆盖 LiteLLM、OpenRouter、models.dev、LangChain、Vercel AI SDK、AWS Bedrock Converse、Portkey、Helicone、LlamaIndex、new-api/one-api。
|
||||||
|
|
||||||
|
### 5.1 核心共识:形态按 provider,能力按 model
|
||||||
|
|
||||||
|
| 概念 | 变化频率 | 应归属层次 |
|
||||||
|
|---|---|---|
|
||||||
|
| **形态**:参数长什么样(`enable_thinking` / `thinking.type` / `reasoning_effort`) | 协议方言,一个供应商数年不变 | provider 级 |
|
||||||
|
| **能力**:能否关闭、有几档、默认开不开 | 模型属性,同一供应商每代都变 | **model 级** |
|
||||||
|
|
||||||
|
注册单位的分布很能说明问题:LiteLLM(2986 条目)、models.dev(5949 条)、LangChain、OpenRouter(细到 endpoint)、Helicone 全部下沉到 model 级;**仍停在 provider 级的只有 Portkey 与 LlamaIndex,而这两家恰是失败语义最差的两家(均静默丢弃)**。二者相关不是偶然:注册单位不够细,就只能靠"表里没有 = 不发"来兜底,而这正是静默失效的成因。
|
||||||
|
|
||||||
|
### 5.2 失败语义的四种谱系
|
||||||
|
|
||||||
|
| 语义 | 代表 | 适用前提 |
|
||||||
|
|---|---|---|
|
||||||
|
| 默认报错 + 可配置降级开关 | LiteLLM(`UnsupportedParamsError` + `drop_params`) | 有 model 级能力表可依据 |
|
||||||
|
| 软降级 + 显式 warning 通道 | Vercel AI SDK(丢弃参数并 push `warnings[]`) | 调用方愿意读 warning |
|
||||||
|
| 静默忽略 + 可选路由过滤 | OpenRouter(默认忽略;`require_parameters:true` 改为排除不支持的上游) | 网关自己拥有路由权 |
|
||||||
|
| 硬失败(透传给上游报错) | Bedrock(`inferenceConfig` 4 字段交集 + `additionalModelRequestFields` 裸透传) | **上游会诚实报错** |
|
||||||
|
|
||||||
|
**选型时先问"我的上游会不会诚实报错"**。若不会(如本项目的中转),最后一种直接出局,静默类也不能选。
|
||||||
|
|
||||||
|
### 5.3 表会过期,这是公理
|
||||||
|
|
||||||
|
LiteLLM 有过真实事故(issue #27351:`gpt-5.1-mini` 漏登记导致 `temperature` 被误拒)。它的应对是**两种相反极性**,值得直接借鉴:
|
||||||
|
|
||||||
|
- **opt-in 能力**(用错会 400 或悄悄花钱):未登记 → 视作不支持 → 拒绝
|
||||||
|
- **opt-out 能力**(多半支持,误拒代价大):未登记 → 放行 → 只有表里显式写 `false` 才拒
|
||||||
|
|
||||||
|
维护方式上,LiteLLM/models.dev 靠社区 PR + CI 校验,LangChain 靠"上游拉取 + 本地增补 + 代码生成"。**对内部库而言唯一现实的答案是:谁实测出来谁登记,登记必须附实测证据与日期。**
|
||||||
|
|
||||||
|
### 5.4 「布尔开关 → 多档旋钮」无语义共识
|
||||||
|
|
||||||
|
| 系统 | effort → 预算的换算 |
|
||||||
|
|---|---|
|
||||||
|
| LiteLLM | 一组 2 的幂(1024/2048/4096/8192/16384),全部可用环境变量覆盖;gemini 各型号还另有分叉 |
|
||||||
|
| OpenRouter | `max_tokens` 的百分比(≈80%/50%/20%) |
|
||||||
|
| Helicone | 一律 `max_tokens/2`,完全不看档位 |
|
||||||
|
| LangChain | 明确不保证跨 provider 可比 |
|
||||||
|
|
||||||
|
**唯一对齐的是"关"**:`none` / `disabled` / `thinking:{type:"disabled"}` / OpenRouter `effort:"none"` 语义一致。"开"那一端没有任何标准。
|
||||||
|
|
||||||
|
**工程共识只有一条:这个映射必须是可覆盖的常量,不是可推导的公式。** 业界所有人都在拍脑袋,区别只在拍完让不让调用方改。
|
||||||
|
|
||||||
|
### 5.5 Vercel AI SDK 的一处设计值得单记
|
||||||
|
|
||||||
|
它的推理档位枚举里有一个 `'provider-default'`,与 `'none'`(明确关闭)严格区分。这与本库 `enable_thinking` 的三态(`None` 不干预 / `True` / `False`)是同一思想——**"调用方不表态"必须是一个独立的值,不能与任何具体档位混同**。本库这一点原本就做对了,应保持。
|
||||||
|
|
||||||
|
## 6. 附带发现(不属本次范围,建议另立 issue)
|
||||||
|
|
||||||
|
**kimi-k3 拒绝 `temperature=0`**:返回 `400 invalid temperature: only 1 is supported`(另有渠道回 `only 0.6`)。本库把 400 归入 `RequestRejectedError`——不重试、不换源。若下游统一下发 `temperature=0`,此类源会 100% 硬失败。这与本次两条 issue 同源:**供应商能力差异未被建模**。
|
||||||
|
|
||||||
|
**中转渠道可用性会波动**:kimi 渠道在 429 后被中转下线,随后返回 `404 Model not supported by any channel`。任何依赖真实 API 的测试都必须容忍源不可用(跳过并给出明确原因),而不是失败。
|
||||||
|
|
||||||
|
## 7. 未能证实
|
||||||
|
|
||||||
|
1. **MiniMax 官方文档对 `reasoning_effort` 的一手定义**:官方文档站三次抓取均失败。M2.x 关不掉有三处佐证,但官方原文未取得。另有二手来源称 MiniMax 原生开关是 `thinking:{type:"adaptive"/"disabled"}`——**该说法已被本次实测证伪**(M2.7/M2.5 上两种写法均无效),但"中转是否对 `reasoning_effort` 做了改写"仍未排除。直连官方端点复测可彻底澄清。
|
||||||
|
2. **qwen 直连 DashScope 时非流式 `enable_thinking` 是否仍报 400**:仅验证了经中转的行为。
|
||||||
|
3. **new-api 走本地补算的确切触发条件**:读到了补算分支与 `local_count_tokens` 标记,未逐条比对所有渠道类型。双峰现象与该解释高度吻合,但未在日志中直接验证。
|
||||||
|
4. **能力表条目对非本次实测模型的正确性**:qwen / deepseek 只测了各一个型号,同系其他型号未验证。
|
||||||
|
|
||||||
|
## 8. 对后续开发的指导
|
||||||
|
|
||||||
|
1. **判定参数是否生效,优先看 `prompt_tokens` 而非输出长度**(§1)。
|
||||||
|
2. **排除"无效值被静默丢弃"必须做反证实验**:传一个乱码值,看它的行为是否与目标值不同(§2.2)。
|
||||||
|
3. **经中转做的任何实测都要标注"经中转,直连未验证"**,并写进注释(§3、§7)。
|
||||||
|
4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。
|
||||||
|
5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。
|
||||||
|
6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。
|
||||||
|
7. **判断"是否发生了推理"只能看 `reasoning_tokens`,不能看输出长度**(§2.5)——两档的 `completion_tokens` 分布是重叠的,长度阈值两个方向都会误判。
|
||||||
@@ -130,6 +130,16 @@
|
|||||||
"id": "plan:sampling-params-plan",
|
"id": "plan:sampling-params-plan",
|
||||||
"label": "采样参数透传实现计划(issue #4)",
|
"label": "采样参数透传实现计划(issue #4)",
|
||||||
"type": "plan"
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:governance-backend-error",
|
||||||
|
"label": "治理后端故障归位为 scope 级不可用(Issue #7)",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:governance-backend-error",
|
||||||
|
"label": "实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)",
|
||||||
|
"type": "plan"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -223,6 +233,27 @@
|
|||||||
"relation": "implements",
|
"relation": "implements",
|
||||||
"evidence": "11 个任务逐条覆盖设计的决策 A-G 与 §5 的 14 条测试清单",
|
"evidence": "11 个任务逐条覆盖设计的决策 A-G 与 §5 的 14 条测试清单",
|
||||||
"added": "2026-07-31T16:59:35.657367+00:00"
|
"added": "2026-07-31T16:59:35.657367+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "finding:2026-08-02-thinking-switch-and-reasoning-tokens",
|
||||||
|
"target": "design:2026-08-02-thinking-capability-design",
|
||||||
|
"relation": "supports",
|
||||||
|
"evidence": "供应商实测与业界调研为该设计的形态/能力分层与失败语义提供事实依据",
|
||||||
|
"added": "2026-08-02T09:38:57.033054+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-08-02-thinking-capability",
|
||||||
|
"target": "design:2026-08-02-thinking-capability-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "T1-T10 逐条实现设计的 D1-D6 六个决策与 §11 九条验收标准",
|
||||||
|
"added": "2026-08-02T09:49:48.126539+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:governance-backend-error",
|
||||||
|
"target": "design:governance-backend-error",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "T1-T5 逐任务实现设计 §3 的五项决策与 §8 影响面清单",
|
||||||
|
"added": "2026-08-06T08:08:51.865565+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+12
-5
@@ -1,8 +1,8 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-08-01 01:58 UTC
|
> 自动生成,更新时间:2026-08-06 08:11 UTC
|
||||||
|
|
||||||
## design (20)
|
## design (23)
|
||||||
- [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`
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
||||||
- [2026-07-31-response-observability-fields-design](designs/2026-07-31-response-observability-fields-design.md) `design:2026-07-31-response-observability-fields-design`
|
- [2026-07-31-response-observability-fields-design](designs/2026-07-31-response-observability-fields-design.md) `design:2026-07-31-response-observability-fields-design`
|
||||||
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
|
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
|
||||||
|
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
|
||||||
- [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`
|
||||||
@@ -22,9 +23,11 @@
|
|||||||
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
||||||
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
||||||
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||||
|
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||||
|
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
||||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||||
|
|
||||||
## finding (11)
|
## finding (12)
|
||||||
- [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`
|
||||||
@@ -36,8 +39,9 @@
|
|||||||
- [M4 迁移验收(GovDoc+CHS)](findings/m4-acceptance.md) `finding:m4-acceptance`
|
- [M4 迁移验收(GovDoc+CHS)](findings/m4-acceptance.md) `finding:m4-acceptance`
|
||||||
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline`
|
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline`
|
||||||
- [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`
|
||||||
|
|
||||||
## plan (16)
|
## plan (19)
|
||||||
- [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`
|
||||||
@@ -46,6 +50,7 @@
|
|||||||
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
|
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
|
||||||
- [2026-07-31-response-observability-fields](plans/2026-07-31-response-observability-fields.md) `plan:2026-07-31-response-observability-fields`
|
- [2026-07-31-response-observability-fields](plans/2026-07-31-response-observability-fields.md) `plan:2026-07-31-response-observability-fields`
|
||||||
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
|
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
|
||||||
|
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
|
||||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||||
- [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`
|
||||||
@@ -53,10 +58,12 @@
|
|||||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||||
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||||
|
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
||||||
|
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
||||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||||
|
|
||||||
## schema (1)
|
## schema (1)
|
||||||
- [表结构: llm_calls(遥测 21 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
- [表结构: llm_calls(遥测 22 字段)](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`
|
||||||
|
|||||||
@@ -73,3 +73,20 @@
|
|||||||
- [2026-07-31 16:59 UTC] 重建索引: 50 篇页面
|
- [2026-07-31 16:59 UTC] 重建索引: 50 篇页面
|
||||||
- [2026-07-31 17:01 UTC] 重建索引: 50 篇页面
|
- [2026-07-31 17:01 UTC] 重建索引: 50 篇页面
|
||||||
- [2026-08-01 01:58 UTC] 重建索引: 50 篇页面
|
- [2026-08-01 01:58 UTC] 重建索引: 50 篇页面
|
||||||
|
- [2026-08-02 09:38 UTC] 重建索引: 52 篇页面
|
||||||
|
- [2026-08-02 09:38 UTC] 新增边: finding:2026-08-02-thinking-switch-and-reasoning-tokens --supports--> design:2026-08-02-thinking-capability-design
|
||||||
|
- [2026-08-02 09:38 UTC] 新增 finding: 推理开关与 reasoning_tokens 供应商实测与业界做法 (finding:2026-08-02-thinking-switch-and-reasoning-tokens)
|
||||||
|
- [2026-08-02 09:38 UTC] 新增 design: 推理开关能力建模与 reasoning_tokens 采集 issue #5+#6 (design:2026-08-02-thinking-capability-design)
|
||||||
|
- [2026-08-02 09:39 UTC] 重建 Query Pack: 29 字符
|
||||||
|
- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面
|
||||||
|
- [2026-08-02 09:49 UTC] 新增边: plan:2026-08-02-thinking-capability --implements--> design:2026-08-02-thinking-capability-design
|
||||||
|
- [2026-08-02 09:49 UTC] 新增 plan: 推理开关能力建模与 reasoning_tokens 采集实施计划 (plan:2026-08-02-thinking-capability)
|
||||||
|
- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面
|
||||||
|
- [2026-08-02 10:55 UTC] 重建索引: 53 篇页面
|
||||||
|
- [2026-08-02 10:55 UTC] 更新 finding: 补 §2.5 输出长度不是有效判别量(e2e 各 15 轮实测)
|
||||||
|
- [2026-08-06 06:37 UTC] 新增 design: 治理后端故障归位为 scope 级不可用(Issue #7) (design:governance-backend-error)
|
||||||
|
- [2026-08-06 06:38 UTC] 重建索引: 55 篇页面
|
||||||
|
- [2026-08-06 08:08 UTC] 新增 plan: 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7) (plan:governance-backend-error)
|
||||||
|
- [2026-08-06 08:08 UTC] 新增边: plan:governance-backend-error --implements--> design:governance-backend-error
|
||||||
|
- [2026-08-06 08:08 UTC] 重建索引: 57 篇页面
|
||||||
|
- [2026-08-06 08:11 UTC] 重建索引: 57 篇页面
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ stack = ExtractionProviderStack(
|
|||||||
| R4 | 六道闸+契约 5 条、服务器时钟窗口、settle 落 acquire 窗口、transient 按 est 保守结算 | M2 | §7.3 大体覆盖 |
|
| R4 | 六道闸+契约 5 条、服务器时钟窗口、settle 落 acquire 窗口、transient 按 est 保守结算 | M2 | §7.3 大体覆盖 |
|
||||||
| R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 |
|
| R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 |
|
||||||
| R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 |
|
| R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 |
|
||||||
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常 | M2 | 已闭 |
|
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常。**2026-08-06 补(issue #7,库 1.1.0)**: 治理后端故障(`GovernanceBackendError`,Redis 挂等 fail-closed 情形)此前**不在**该族内,`except GatewayUnavailableError` 接不住,会落进 `_TERMINAL` 兜底而消耗业务失败预算;现已归入该族(`reason=governance_backend_down`,`retry_after_s` 默认 5.0),tracking.py 一条 except 即覆盖完整,**无需为它单列分支**。同批新增的 `SourceNotConfiguredError`(源名与配置不匹配的装配缺陷)**有意在族外**,应当落进 `_TERMINAL` 让配置错误浮出水面 | M2 | 已闭 |
|
||||||
| **G2** | ✅ 已闭(2026-07-30 核实): `est_tokens` 已进 ARCH §7.7 SourceConfig 字段清单,且 §7.3 `try_acquire` 的 est 来源已定义为 `SourceConfig.effective_est_tokens()`(显式值优先,否则按 `tpm // 60` 派生)。双职责一并拆开:该字段只剩 TPM 预扣的可选调优覆盖,usage 缺失不再由它兜底(见 §7 行 151 的推翻判定) | M2 | 已闭 |
|
| **G2** | ✅ 已闭(2026-07-30 核实): `est_tokens` 已进 ARCH §7.7 SourceConfig 字段清单,且 §7.3 `try_acquire` 的 est 来源已定义为 `SourceConfig.effective_est_tokens()`(显式值优先,否则按 `tpm // 60` 派生)。双职责一并拆开:该字段只剩 TPM 预扣的可选调优覆盖,usage 缺失不再由它兜底(见 §7 行 151 的推翻判定) | M2 | 已闭 |
|
||||||
| **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 |
|
| **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 |
|
||||||
| **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 |
|
| **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 |
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-08-02-thinking-capability
|
||||||
|
title: "推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)"
|
||||||
|
date: 2026-08-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)
|
||||||
|
|
||||||
|
**目标**:让 `enable_thinking` 对每个源要么真实生效、要么显式报错,并采集 `reasoning_tokens` 以区分推理开销与生成开销。
|
||||||
|
|
||||||
|
**方案概述**:`ProviderProfile` 保留为「形态层」(参数长什么样,按 provider),新增 model 级「能力层」声明该模型能否关闭推理;两层在单一判定函数 `resolve_thinking` 相遇,装配期与请求期共用。同时照搬 issue #3 的 `_coerce_cached_tokens` 采集 `reasoning_tokens`,并把 `enable_thinking` 纳入缓存指纹。
|
||||||
|
|
||||||
|
**涉及技术**:Python 3.11 frozen dataclass、`MappingProxyType` 只读注册表、httpx、SQLite/PostgreSQL DDL 迁移、pytest。
|
||||||
|
|
||||||
|
**依据文档**:设计 `designs/2026-08-02-thinking-capability-design.md`;事实基础 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`。
|
||||||
|
|
||||||
|
**保真校验**:本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 文件 | 动作 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/polygateway/types.py` | 修改 | `LLMResponse` / `TransportResult` 尾部各加 `reasoning_tokens` |
|
||||||
|
| `src/polygateway/providers.py` | 修改 | 形态层放宽为 `dict \| None`;新增能力层与 `resolve_thinking` |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | 修改 | 采集 `reasoning_tokens`;`_build_payload` 接入 `resolve_thinking`;收 `capabilities` |
|
||||||
|
| `src/polygateway/middleware/retry.py` | 修改 | `_build_response` 透传 `reasoning_tokens` |
|
||||||
|
| `src/polygateway/ports.py` | 修改 | `record_llm_call` 21 → 22 字段 |
|
||||||
|
| `src/polygateway/telemetry/sqlite.py` | 修改 | 建表列 + `_BACKFILL_COLUMNS` + `_COLUMNS`(新列排末尾) |
|
||||||
|
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` + 三个 `emit_*` 入口 |
|
||||||
|
| `src/polygateway/client.py` | 修改 | `capabilities` 参数贯通;装配守卫;缓存指纹纳入 `enable_thinking` |
|
||||||
|
| `tests/e2e/test_thinking_live.py` | 新建 | 真实 API 矩阵 L1–L9 |
|
||||||
|
| `CHANGELOG.md` / `research-wiki/schemas/llm-calls.md` | 修改 | 行为变更说明与字段表 21 → 22 |
|
||||||
|
|
||||||
|
**任务顺序不可调换**:T1–T3 先把 `reasoning_tokens` 打通(#6 是 #5 的验收仪器),T4–T7 再改推理开关,T8 用真实 API 验证,T9 收尾文档。
|
||||||
|
|
||||||
|
## 关键接口(跨任务消费,此处给出实际代码)
|
||||||
|
|
||||||
|
`providers.py` 新增:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
|
||||||
|
|
||||||
|
can_disable: bool
|
||||||
|
evidence: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_capability(
|
||||||
|
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
||||||
|
) -> ThinkingCapability | None:
|
||||||
|
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定退化)。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def register_capability(
|
||||||
|
model: str,
|
||||||
|
capability: ThinkingCapability,
|
||||||
|
*,
|
||||||
|
base: Mapping[str, ThinkingCapability] | None = None,
|
||||||
|
) -> dict[str, ThinkingCapability]:
|
||||||
|
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_thinking(
|
||||||
|
profile: ProviderProfile,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
enable_thinking: bool | None,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(调用点翻译为领域错误)。
|
||||||
|
|
||||||
|
model 只用于错误与告警文案: 报错必须能定位到具体模型才有可操作性,
|
||||||
|
而 capability 为 None(未登记)时无从从别处取得模型名。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`resolve_thinking` 的判定顺序(**顺序即语义,不可调换**):
|
||||||
|
|
||||||
|
| 步 | 条件 | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `enable_thinking is None` | 返回 `{}`(不干预) |
|
||||||
|
| 2 | 对应档 `slot is None` | `ValueError`:形态未知,指路 `register_provider` / `extra_body` |
|
||||||
|
| 3 | `capability is None` | `loguru.warning` 后返回 `slot`(能力未登记,从宽放行) |
|
||||||
|
| 4 | `enable_thinking is False` 且 `capability.can_disable is False` | `ValueError`:该模型无法关闭推理 |
|
||||||
|
| 5 | 其余 | 返回 `slot` |
|
||||||
|
|
||||||
|
第 2 步必须先于第 4 步:形态未知时无从注入,能力如何无关紧要。第 3 步先于第 4 步:未登记模型无 `can_disable` 可读。
|
||||||
|
|
||||||
|
`transports/openai_compat.py` 新增:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _coerce_reasoning_tokens(usage: Any) -> int | None:
|
||||||
|
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
### T1 — `reasoning_tokens` 进入类型与采集路径
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `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`
|
||||||
|
|
||||||
|
**行为**:`LLMResponse` 与 `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`(字段顺序是公共承诺,见 `types.py:1-5`,只增不删不改名)。新增 `_coerce_reasoning_tokens`,语义与 `_coerce_cached_tokens`(`openai_compat.py:161-177`)逐条对齐:非 `dict` 返回 `None`;`completion_tokens_details` 非 `dict` 返回 `None`;`bool` 显式排除(`isinstance(True, int)` 为真,放行会把 `True` 记成 1);负数返回 `None`;`0` 如实保留。流式(`:401` 附近)取 `sink.get("usage")`、非流式(`:485` 附近)取 `body.get("usage")`,与 `cached_prompt_tokens` 同处填值。`retry.py:_build_response` 透传。
|
||||||
|
|
||||||
|
**docstring 措辞**(必须逐字,理由见 findings §4c):`None` = **本次调用**未上报,**不可**写「该源未上报」——中转在上游不返回 usage 时会本地补算并吃掉该字段。
|
||||||
|
|
||||||
|
**验收**:非流式与流式响应含 `completion_tokens_details.reasoning_tokens: 7` → `reasoning_tokens == 7`;该键为 `0` → `0`(不与 `None` 混同);`completion_tokens_details` 缺失 / 非 dict / 值为 `True` / 值为 `-1` → 均为 `None`;`missing_done="salvage"` 打捞路径(无 usage 帧)→ `None` 而非 `0`。
|
||||||
|
|
||||||
|
**测试证据**:先加断言 → 失败(字段不存在)→ 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v` → 全部 PASS。
|
||||||
|
|
||||||
|
### T2 — 遥测端口 21 → 22 字段与两后端落库
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/ports.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`
|
||||||
|
|
||||||
|
**行为**:`record_llm_call` 在 `sampling` 之后追加 `reasoning_tokens: int | None`(不设默认值——库外无第三方实现者,见 `ports.py:250` 注释)。两个后端在建表 DDL、`_BACKFILL_COLUMNS`(sqlite)/ 迁移语句列表(postgres)、`_COLUMNS` 三处各加一项,**新列必须排在末尾**(两文件均有明文注释:旧表只能 ALTER 追加,新建库若插在前面会与迁移路径的物理列序分叉)。`middleware/telemetry.py` 的 `_record` 加参数,三个 `emit_*` 入口按 `cached_prompt_tokens` 的既有形态填值:`emit_attempt` 用 `response.reasoning_tokens if response else None`,`emit_cache_hit` 原样回放,`emit_terminal_failure` 填 `None`。
|
||||||
|
|
||||||
|
**不改 `pricing.py`**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
|
||||||
|
|
||||||
|
**验收**:新建库与经 ALTER 迁移的旧库物理列序一致;`reasoning_tokens=7` / `0` / `None` 三种值各自如实落库(`0` 与 `NULL` 可区分);遥测写失败仍降级为 warning 不冒泡。
|
||||||
|
|
||||||
|
**测试证据**:先扩字段清单断言 → 失败 → 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS;`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS 或按既有约定 SKIP(无 PG 凭据时)。
|
||||||
|
|
||||||
|
### T3 — 提交点:issue #6 完整可用
|
||||||
|
|
||||||
|
- [ ] 运行 `conda run -n PolyGateway make ci`,确认全绿后提交。提交信息类型 `feat`,正文说明 `LLMResponse` 新增字段与遥测端口 21 → 22。此处独立成一个提交,便于 #6 单独回滚。
|
||||||
|
|
||||||
|
**测试证据**:本任务不引入新行为,证据即 T1 与 T2 各自的「先失败后通过」记录;提交前需确认这两组记录都已产生,不得以 `make ci` 全绿代替。
|
||||||
|
|
||||||
|
### T4 — 形态层放宽与 profile 修正
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
|
||||||
|
|
||||||
|
**行为**:`ProviderProfile.thinking_on` / `thinking_off` 类型由 `dict[str, Any]` 改为 `Mapping[str, Any] | None`。三值语义写进类 docstring:`{...}` = 已知注入片段;`{}` = 已知无需注入即处于该档;`None` = **未知**(库不知道该 provider 如何表达)。删除现有 docstring 里「两档皆空 ⇒ 不产生任何效果」那段(`providers.py:23-25` 与 `:50-51`)——它正是把「不支持」与「未知」编码成同一个值的根因。
|
||||||
|
|
||||||
|
`minimax` 填 `thinking_on={"reasoning_effort": "medium"}`、`thinking_off={"reasoning_effort": "none"}`;`openai` 两档改 `None`。qwen / deepseek **不动**(实测正确)。两处均加注释写明:取值依据 2026-08-02 经自建 new-api 中转的实测,直连官方端点未验证。
|
||||||
|
|
||||||
|
**验收**:`get_provider("minimax").thinking_off == {"reasoning_effort": "none"}`;`get_provider("openai").thinking_on is None`;qwen / deepseek 两档与改动前逐字相同。
|
||||||
|
|
||||||
|
**测试证据**:`tests/unit/test_providers.py:29,34` 现有断言锁的是空字典,先改成新期望 → 失败 → 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
|
||||||
|
|
||||||
|
### T5 — 能力层与 `resolve_thinking`
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
|
||||||
|
|
||||||
|
**行为**:按「关键接口」一节的签名实现 `ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`。注册表用 `MappingProxyType` 只读,注册走纯函数返回新表(不修改共享状态,纯 asyncio 中立铁律),与既有 `register_provider`(`providers.py:84-90`)同形。
|
||||||
|
|
||||||
|
`DEFAULT_CAPABILITIES` 首发五条,`evidence` 逐条写明实测日期与样本量:
|
||||||
|
|
||||||
|
| 键 | `can_disable` | `evidence` 要点 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10,`reasoning_effort=none` 稳定关闭 |
|
||||||
|
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter 登记 `mandatory:true` |
|
||||||
|
| `MiniMax-M2.5` | `False` | 同上 |
|
||||||
|
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
|
||||||
|
| `deepseek-v4-pro` | `True` | 实测 `thinking:{"type":"disabled"}` 关闭 |
|
||||||
|
|
||||||
|
**验收**:`resolve_thinking` 五条判定各有一例;未登记模型返回 `slot` 并产生一条 warning(**loguru 不经标准 logging,pytest 的 `caplog` 抓不到**——必须复用项目既有写法 `logger.add(messages.append, level="WARNING")`,见 `tests/unit/test_config.py:29-31`);`enable_thinking=False` + `MiniMax-M2.7` 抛 `ValueError` 且消息含模型名与"无法关闭"字样;`enable_thinking` 任意非 `None` + `openai` profile 抛 `ValueError` 且消息含 `register_provider` 与 `extra_body` 两个指路词;`register_capability` 不修改 `DEFAULT_CAPABILITIES`。
|
||||||
|
|
||||||
|
**测试证据**:先写五条判定的参数化测试 → 失败(函数不存在)→ 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
|
||||||
|
|
||||||
|
### T6 — transport 接入与请求期兜底
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/transports/openai_compat.py`;改测 `tests/unit/test_openai_compat.py`
|
||||||
|
|
||||||
|
**行为**:`OpenAICompatTransport.__init__` 增加 `capabilities: Mapping[str, ThinkingCapability] | None = None`,与既有 `registry` 参数同形并存于 `self._capabilities`。`_build_payload` 的两个 `if` 分支(`:293-296`)收敛为两行——先 `capability = get_capability(source.model, table=self._capabilities)`,再 `payload.update(resolve_thinking(profile, capability, source.enable_thinking, model=source.model))`;其后 `payload.update(source.extra_body)` 与 `payload.update(overlay)` 两行**顺序不变**(顺序即优先级,issue #4 决策 A)。`complete()` 中把构造 payload 的 `ValueError` 翻译为 `RequestRejectedError`(四分类之一,不重试不换源)。
|
||||||
|
|
||||||
|
**绝不在 `_build_payload` 内抛裸 `ValueError` 让它冒泡**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`。
|
||||||
|
|
||||||
|
**验收**:`enable_thinking=True` + minimax 源 → 请求体含 `reasoning_effort: "medium"`;`False` → `"none"`;`None` → 请求体无 `reasoning_effort` 键;`extra_body={"reasoning_effort":"high"}` 时实发 `high`(覆盖 profile);`enable_thinking=False` + M2.7 源经 transport 调用 → `RequestRejectedError` 而非裸 `ValueError`。
|
||||||
|
|
||||||
|
**测试证据**:扩 `tests/unit/test_openai_compat.py:418-431` 的三态参数化,加 minimax 用例 → 失败 → 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → PASS。
|
||||||
|
|
||||||
|
### T7 — 装配守卫、参数贯通与缓存指纹
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/client.py`;改测 `tests/unit/test_cache.py`(指纹相关)、新增装配守卫测试至 `tests/unit/test_config.py`
|
||||||
|
|
||||||
|
**行为**(三件事,同一文件):
|
||||||
|
|
||||||
|
其一,`from_settings` 与 `from_env` 各增加 `capabilities` 参数并透传给 `OpenAICompatTransport`;`from_settings` 在已解析 `profiles` 之后(`client.py:248`)加装配守卫:对 `zip(sources, profiles, strict=True)` 的每一对,先 `get_capability(src.model, table=capabilities)` 取能力,再调用一次 `resolve_thinking(prof, cap, src.enable_thinking, model=src.model)` 并丢弃返回值——只为让配置错误在装配期即抛 `ValueError`。守卫与 transport 内的判定共用同一函数,不复制逻辑——这与 `get_provider` 在 `client.py:248` 与 `openai_compat.py:313` 双点调用的既有形态一致。
|
||||||
|
|
||||||
|
其二,`build_model_fingerprint`(`client.py:63-80`)把 `enable_thinking` 纳入摘要。实现必须保持既有不变量——**全源不配 `enable_thinking` 时指纹字面量与改动前逐字相同**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fingerprint_mark(s: SourceConfig) -> str:
|
||||||
|
parts: list[Any] = [s.model, dict(s.extra_body)]
|
||||||
|
if s.enable_thinking is not None: # 仅在表态时追加,保证存量指纹字面量不变
|
||||||
|
parts.append(s.enable_thinking)
|
||||||
|
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
筛选条件由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`。
|
||||||
|
|
||||||
|
其三,为守卫补测:`enable_thinking=False` + `provider=minimax` + `model=MiniMax-M2.7` 的 `GatewaySettings` 经 `from_settings` → `ValueError`;`provider=openai` + 任意非 `None` 的 `enable_thinking` → `ValueError`。
|
||||||
|
|
||||||
|
**验收**:装配期报错两例;改 `enable_thinking` → 指纹变化;只配 `extra_body`、不配 `enable_thinking` 的源 → 指纹与改动前逐字相同(用硬编码的历史字面量断言,防回归)。
|
||||||
|
|
||||||
|
**测试证据**:先写三条断言 → 失败 → 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_config.py -v` → PASS;随后 `conda run -n PolyGateway make ci` → 全绿。
|
||||||
|
|
||||||
|
### T8 — 真实 API e2e 矩阵
|
||||||
|
|
||||||
|
- [ ] **文件**:新建 `tests/e2e/test_thinking_live.py`
|
||||||
|
|
||||||
|
**行为**:沿用既有 e2e 约定(`tests/e2e/test_smoke_gateway.py:19-26`)——`dotenv_values(".env")` 读凭据、`skipif(not _HAS_SOURCE, ...)`、结构化报告写入 `tests/outputs/e2e/`。**不新造开关机制**:另加项目既有的 `slow` 标记,靠 `pyproject.toml` 的 `addopts = "-m 'not slow'"` 把本组挡在 `make ci` 之外(137 次真实调用、约 7 分钟,且判据是统计性的,网络抖动会造成假红——执行期实测撞到过一次 `network_error` 耗尽源)。合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。
|
||||||
|
|
||||||
|
**源映射**:L1–L5、L8 的 MiniMax 行用现有的 `LLM__MINIMAX__1__*`(`MODEL=MiniMax-M3`);M2.7 / M2.5 行经 `dataclasses.replace(source, model=...)` 派生,不新增 `.env` 键。**L6 / L7 目前无对应源**——`.env` 里只有 MINIMAX 与 MONKEY 两类;需新增 `{SCOPE}__QWEN__1__*` 与 `{SCOPE}__DEEPSEEK__1__*`(同一中转 `BASE_URL` 与密钥,仅 `MODEL` 不同)。未配置时按既有 `skipif` 约定跳过,并在报告中记为「未覆盖」,**不得静默计入通过**。
|
||||||
|
|
||||||
|
覆盖矩阵(轮数经环境变量可调,默认值如下):
|
||||||
|
|
||||||
|
| # | 场景 | 源 | 轮数 | 判据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| L1 | `enable_thinking=False` | MiniMax-M3 | 10 | **每轮** `completion_tokens < 30`(主判据)且 `reasoning_tokens in (None, 0)`(辅判据,与下游口径一致) |
|
||||||
|
| L2 | `enable_thinking=True` | MiniMax-M3 | 10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
|
||||||
|
| L3 | `enable_thinking=None` | MiniMax-M3 | 10 | 请求体无 `reasoning_effort` 键 |
|
||||||
|
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | 5 | 实发 `high` |
|
||||||
|
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 10 | 同 L1 / L2(`stream=True` 是库的默认主路径) |
|
||||||
|
| L6 | `enable_thinking=False` | qwen | 10 | 每轮 `completion_tokens < 30` |
|
||||||
|
| L7 | `enable_thinking=False` | deepseek | 10 | 每轮 `completion_tokens < 30` |
|
||||||
|
| L8 | 能力表漂移哨兵 | 全部登记模型 | 各 5 | 实测行为与 `can_disable` 声明一致 |
|
||||||
|
| L9 | M2.7 + `enable_thinking=False` → 装配期报错 | — | — | 纯本地,无需真实调用 |
|
||||||
|
|
||||||
|
**三条必须遵守的测试纪律**:
|
||||||
|
|
||||||
|
其一,**判别量只能是 `reasoning_tokens`**。(执行时按 e2e 实测修正:本条初稿写的是「主判据用 `completion_tokens`」,被数据推翻——两档的输出长度分布**重叠**,关闭档实测最高 46、开启档最低 13,按长度阈值判两个方向都会误判。)`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路(findings §4c、§2.5)。
|
||||||
|
|
||||||
|
其二,**关闭方向要求每轮满足,开启方向只要求多数轮满足**。中转吃掉 ctd 时开启方向可能偶尔观测不到,关闭方向不受影响。
|
||||||
|
|
||||||
|
其四,**必须有不依赖输出侧噪声的锚点**:L2b 比较两档的 `prompt_tokens`(相对比较,无魔数),L3b 用非法值反证 `none` 是被识别而非被静默丢弃——后者正是 issue #5 的原始故障形态,不排除它,关闭方向的证据就只到「未回归」,够不到「已生效」。
|
||||||
|
|
||||||
|
其三,**源不可用必须跳过并在报告中显式记为「未覆盖」**,不得静默计入通过(实测中 kimi 渠道 429 后被中转下线并返回 404)。报告要能一眼看出哪些矩阵行没跑到。
|
||||||
|
|
||||||
|
**报告内容**(`tests/outputs/e2e/test_thinking_live_<ts>.md`):逐轮记录实际注入的 thinking 片段、`prompt_tokens` / `completion_tokens` / `reasoning_tokens`、单轮判定结果;逐行记录矩阵编号、通过或跳过及其原因;文末给出总调用次数与时间戳。原始数字必须落盘——结论可以复核,才算证据。
|
||||||
|
|
||||||
|
**验收**:矩阵九行全部有结论(通过 / 明确跳过),报告落盘 `tests/outputs/e2e/`。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -v -s` → PASS,人工核对报告。
|
||||||
|
|
||||||
|
### T9 — 文档同步与收尾
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `CHANGELOG.md`、`research-wiki/schemas/llm-calls.md`
|
||||||
|
|
||||||
|
**行为**:CHANGELOG 必须醒目标注这是**行为变更而非纯修复**——MiniMax 源的 `ENABLE_THINKING` 从「无效」变为「生效」,且配了该项的 scope 会有一次性缓存冷启动。同时写明 `reasoning_tokens` 的语义:`None` = 本次调用未上报,下游判据须为 `in (None, 0)`,写 `== 0` 永远不成立。`schemas/llm-calls.md` 的字段表由 21 改 22,新增行说明该列。
|
||||||
|
|
||||||
|
Gitea Wiki(独立仓库)**本任务内必须同步**:按 `docs-convention.md` §2「新公共 API / 新能力」一行,需改 `参考-公共API`(`LLMResponse` 新字段)与相关指南页;该表把同步绑定在**变更**上而非发版上,不可推迟。`Home.md` 的版本号与安装命令等发版项不在本计划范围。
|
||||||
|
|
||||||
|
**验收**:CHANGELOG 含行为变更与冷启动两处提示;schema 文档字段数与 `_COLUMNS` 长度一致。
|
||||||
|
|
||||||
|
**验证**:人工核对;`conda run -n PolyGateway make ci` → 全绿。
|
||||||
|
|
||||||
|
## 完成后的独立验证
|
||||||
|
|
||||||
|
按 `verification-before-completion` 的强制档,本计划跨多文件,合并前须派**全新上下文**的 verifier subagent 逐条核对设计 §11 的九条验收标准与本计划各任务的测试证据,不得自审代替。
|
||||||
|
|
||||||
|
### T10 — 同步结论给 dissect
|
||||||
|
|
||||||
|
- [ ] **动作**:在本分支合并时,向 dissect 提一条 issue 或在其 `ROADMAP` 风险表中记录下述结论,并确认对方已读。
|
||||||
|
|
||||||
|
**验收**:dissect 侧存在可追溯的记录(issue 编号或文档行号),不以口头告知为准。
|
||||||
|
|
||||||
|
## 需要同步给下游的结论
|
||||||
|
|
||||||
|
`MiniMax-M2.7` / `M2.5` 的推理**关不掉**是模型固有属性,任何库层改动都无法改变。dissect 的 Phase-0 若要做「开思考 vs 关思考」对照,只能在 M3 上做,或把因子改为「高档 vs 低档」。此结论须在本分支合并时同步给 dissect。
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)
|
||||||
|
|
||||||
|
- **设计**: `research-wiki/designs/2026-08-06-governance-backend-error-design.md`(已批准 2026-08-06,Q1/Q2/Q3 逐条拍板)
|
||||||
|
- **分支**: `feat/issue-7-governance-backend-error`
|
||||||
|
- **目标**: 让"限流/熔断后端故障"在类型上落入 `GatewayUnavailableError`,使调用方一条 `except` 覆盖完整;同时把混在同一类里的装配缺陷拆出去,避免配置写错的任务永远重投。
|
||||||
|
- **方案概述**: `GovernanceBackendError` 改继承 `GatewayUnavailableError`(新 reason `governance_backend_down`,`retry_after_s` 默认 5.0);两处"未知源"改抛新增的 `SourceNotConfiguredError`(**不**在 scope 级家族内);`scope` 由后端层 `self._scope` 与两个 gate 包装器注入。
|
||||||
|
- **涉及技术**: Python 3.11+,pytest(含真实 Redis 的 integration),radon/ruff 门禁。
|
||||||
|
|
||||||
|
## 保真校验(适用)
|
||||||
|
|
||||||
|
本计划触及 ARCHITECTURE.md §1.4 索引的移植蓝本:错误分类(`reference/CHSAnalyzer/app/domain/errors.py`)与限流/熔断(`reference/CHSAnalyzer/app/coordination/`)。
|
||||||
|
|
||||||
|
本次**有意变更**的语义只有一条,已在设计 §3.1 声明:`GovernanceBackendError` 的类型归属(CHS 的 `LimiterError` 是独立异常,本库将其提升为 scope 级不可用的一员)。除此之外,下列承自 CHS 的语义**不得被顺带改动**,每个任务完成前逐条自查:
|
||||||
|
|
||||||
|
| 不得改动 | 出处 |
|
||||||
|
|---|---|
|
||||||
|
| `retry_after_s` 非可选、`0 = 可立即重试` | `errors.py:74-78` |
|
||||||
|
| `SCOPE_REASONS` 既有 5 值与 `SOURCE_REASONS` 既有 7 值 | `errors.py:7-20` |
|
||||||
|
| fail-closed 降级方向(限流/熔断后端挂 → 报错而非放行) | 库铁律 |
|
||||||
|
| 记账路径降级为 warning、闸门路径上抛的分工 | `middleware/retry.py:404` |
|
||||||
|
| `RedisPermit.release/settle` 的释放侧降级 | `backends/redis/limiter.py:133,151` |
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责 | 动作 |
|
||||||
|
|---|---|---|
|
||||||
|
| `research-wiki/ARCHITECTURE.md` | 架构单一事实源 §6.1 错误分类表 | 修改(**必须先行**,见设计 §8.1) |
|
||||||
|
| `src/polygateway/errors.py` | 错误类型树内核 | 修改: 新常量、新 reason、新类、继承变更 |
|
||||||
|
| `src/polygateway/__init__.py` | 公共 API 面 | 修改: 导出新类 |
|
||||||
|
| `src/polygateway/backends/redis/limiter.py` | Redis 限流后端 | 修改: 6 处补 scope、1 处换新类 |
|
||||||
|
| `src/polygateway/backends/redis/breaker.py` | Redis 熔断后端 | 修改: 5 处补 scope |
|
||||||
|
| `src/polygateway/backends/memory/limiter.py` | 内存限流后端 | 修改: 1 处换新类 |
|
||||||
|
| `src/polygateway/middleware/ratelimit.py` | `QuotaGate` 包装器 | 修改: 构造增 scope、4 处补 scope |
|
||||||
|
| `src/polygateway/middleware/breaker.py` | `BreakerGate` 包装器 | 修改: 构造增 scope、5 处补 scope |
|
||||||
|
| `src/polygateway/middleware/retry.py` / `ocr.py` / `embedding.py` | 三处 gate 装配 | 修改: 各 2 行传 scope |
|
||||||
|
| `tests/unit/test_errors.py` | 错误类型契约 | 修改 |
|
||||||
|
| `tests/unit/test_backpressure.py` | 后端故障传播 | 修改 |
|
||||||
|
| `tests/unit/test_redis_key_layout.py` | 未知源行为 | 修改 |
|
||||||
|
| `tests/integration/test_redis_cross_connection.py` | 真实 Redis 掉线 | 修改 |
|
||||||
|
| `README.md` / `research-wiki/migrations/chsanalyzer.md` / `CHANGELOG.md` / `pyproject.toml` | 文档与版本 | 修改 |
|
||||||
|
|
||||||
|
## 关键接口(跨任务消费,此处写死)
|
||||||
|
|
||||||
|
`errors.py` 新增与变更部分:
|
||||||
|
|
||||||
|
```python
|
||||||
|
GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0
|
||||||
|
"""治理后端故障的建议重投间隔(秒)。
|
||||||
|
|
||||||
|
**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期
|
||||||
|
时刻),故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让
|
||||||
|
积压任务零延迟同时冲击已挂掉的后端(issue #7 §3.2)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceNotConfiguredError(PolyGatewayError):
|
||||||
|
"""源名不在限流后端的配置字典中: 装配缺陷,正常不可达。
|
||||||
|
|
||||||
|
**有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是
|
||||||
|
"配置写错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置
|
||||||
|
错误的任务永远重投、永不告警(issue #7 §3.4)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceBackendError(GatewayUnavailableError):
|
||||||
|
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。
|
||||||
|
|
||||||
|
继承 `GatewayUnavailableError`: fail-closed 时一个请求都发不出去,语义
|
||||||
|
上即 scope 级不可用,调用方一条 except 即可覆盖(issue #7)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
retry_after_s: float = GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
||||||
|
source_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
scope=scope,
|
||||||
|
reason="governance_backend_down",
|
||||||
|
retry_after_s=retry_after_s,
|
||||||
|
source_name=source_name,
|
||||||
|
)
|
||||||
|
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
|
||||||
|
# 携带的诊断串是排障主线索,必须保住(设计 §3.5,机制已实跑验证)
|
||||||
|
self.args = (message,)
|
||||||
|
```
|
||||||
|
|
||||||
|
两个 gate 包装器的构造签名(`scope` 为 keyword-only 必填):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class QuotaGate:
|
||||||
|
def __init__(self, limiter: RateLimiter, *, scope: str) -> None:
|
||||||
|
self._limiter = limiter
|
||||||
|
self._scope = scope
|
||||||
|
|
||||||
|
|
||||||
|
class BreakerGate:
|
||||||
|
def __init__(self, gate: ProviderGate, *, scope: str) -> None:
|
||||||
|
self._gate = gate
|
||||||
|
self._scope = scope
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
### - [ ] T1: ARCHITECTURE §6.1 回补(必须先行)
|
||||||
|
|
||||||
|
**文件**: `research-wiki/ARCHITECTURE.md`(§6.1,约 372-380 行)
|
||||||
|
|
||||||
|
**行为**: 在错误分类表补两行——`GovernanceBackendError`(scope 级不可用,reason 恒为 `governance_backend_down`)与 `SourceNotConfiguredError`(装配缺陷,不重试不换源,消耗失败预算);scope 级 `reason` 值域由 5 值扩为 6 值,增 `governance_backend_down`。同时记录本次归位的理由与日期,并说明根因(该类是 M2 引入分布式后端时新增,当时未回补本表)。
|
||||||
|
|
||||||
|
**为什么先行**: `ARCHITECTURE.md` 是单一事实源,新 reason 值域与其现状冲突;先改代码后补文档等于让实现与事实源脱节(设计 §8.1)。
|
||||||
|
|
||||||
|
**验收**: §6.1 表格含上述两行;reason 值域文字与 `errors.py` 将要写入的 `SCOPE_REASONS` 逐字一致。
|
||||||
|
|
||||||
|
**测试要求**: 纯文档,无测试证据要求。
|
||||||
|
|
||||||
|
**验证**: `grep -n "governance_backend_down\|SourceNotConfiguredError" research-wiki/ARCHITECTURE.md` → 至少各 1 处命中。
|
||||||
|
|
||||||
|
**提交**: `docs: admit governance backend failures into the scope-level error model`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] T2: errors.py 纯增量(新常量、新 reason、新类)+ 导出
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/errors.py`、`src/polygateway/__init__.py`;改 `tests/unit/test_errors.py`
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. 加模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`(docstring 逐字见上文"关键接口");
|
||||||
|
2. `SCOPE_REASONS` 增 `"governance_backend_down"`;
|
||||||
|
3. 新增 `SourceNotConfiguredError(PolyGatewayError)`(定义逐字见上文);
|
||||||
|
4. `__init__.py` 的 import 块与 `__all__` 各增 `SourceNotConfiguredError`(`__all__` 保持字母序: `SourceDeadError` → **`SourceNotConfiguredError`** → `TransientError`,即插在 `SourceDeadError` **之后**)。
|
||||||
|
|
||||||
|
**本任务不动 `GovernanceBackendError`**——它是纯增量,不破坏任何既有调用点,可独立提交且全套件保持通过。
|
||||||
|
|
||||||
|
**测试要求(先失败后通过)**:
|
||||||
|
- 新增用例断言 `SourceNotConfiguredError` **不是** `GatewayUnavailableError` 的子类,且是 `PolyGatewayError` 的子类。改前该类不存在 → `ImportError`;改后 PASS。
|
||||||
|
- 新增用例断言 `"governance_backend_down" in SCOPE_REASONS`,且 `GatewayUnavailableError(scope="llm", reason="governance_backend_down", retry_after_s=0.0)` 可构造。改前 `reason` 校验抛 `ValueError` → 用例失败;改后 PASS。
|
||||||
|
- 新增用例断言 `from polygateway import SourceNotConfiguredError` 可用。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS;`conda run -n PolyGateway pytest tests/ -q` → 与改动前同样全绿(纯增量不应影响任何既有用例)。
|
||||||
|
|
||||||
|
**提交**: `feat: add SourceNotConfiguredError and the governance backend reason`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] T3: `GovernanceBackendError` 归位 + 22 处构造点 + scope 注入(原子)
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/errors.py`、`backends/redis/limiter.py`、`backends/redis/breaker.py`、`backends/memory/limiter.py`、`middleware/ratelimit.py`、`middleware/breaker.py`、`middleware/retry.py`、`ocr.py`、`embedding.py`;改 `tests/unit/test_errors.py`、`tests/unit/test_backpressure.py`、`tests/unit/test_redis_key_layout.py`、`tests/integration/test_redis_cross_connection.py`
|
||||||
|
|
||||||
|
**为什么必须原子**: `scope` 是必填 keyword,继承变更与全部构造点若分批提交,中间状态会 `TypeError`,门禁跑不过。
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
1. `errors.py`: `GovernanceBackendError` 改继承 `GatewayUnavailableError` 并覆写 `__init__`(逐字见上文"关键接口")。
|
||||||
|
|
||||||
|
2. **两处未知源改抛新类**(设计 §3.4,Q1 已拍板):
|
||||||
|
|
||||||
|
| 位置 | 改为 |
|
||||||
|
|---|---|
|
||||||
|
| `backends/redis/limiter.py:198` | `raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")` |
|
||||||
|
| `backends/memory/limiter.py:92` | 同上 |
|
||||||
|
|
||||||
|
3. **后端层 11 处补 `scope=self._scope`**(该属性已存在: redis limiter `:170`、redis breaker `:291`、memory limiter 同名字段):
|
||||||
|
- `backends/redis/limiter.py` 的 `:250 / :268 / :275 / :286 / :298 / :305`(6 处)
|
||||||
|
- `backends/redis/breaker.py` 的 `:370 / :388 / :410 / :422 / :432`(5 处)
|
||||||
|
|
||||||
|
4. **两个 gate 包装器**: 构造函数改为上文"关键接口"的签名;`QuotaGate` 4 处(`ratelimit.py:30/38/46/54`)与 `BreakerGate` 5 处(`breaker.py:26/36/46/54/62`)的 `raise` 补 `scope=self._scope`。
|
||||||
|
- 各方法开头的 `except GovernanceBackendError: raise` **保持不变**(后端层已填好 scope,重建实例只会重复构造,设计 §3.3)。
|
||||||
|
|
||||||
|
5. **三处装配各传 scope**(三处的 `self._scope` 均已在装配前赋值,无需调整顺序):
|
||||||
|
|
||||||
|
| 文件 | 行 | 改为 |
|
||||||
|
|---|---|---|
|
||||||
|
| `middleware/retry.py` | 186-187 | `QuotaGate(limiter, scope=self._scope)` / `BreakerGate(gate, scope=self._scope)` |
|
||||||
|
| `ocr.py` | 122-123 | 同款 |
|
||||||
|
| `embedding.py` | 123-124 | 同款 |
|
||||||
|
|
||||||
|
**测试要求(先失败后通过,逐条对应)**:
|
||||||
|
|
||||||
|
| 用例 | 文件 | 改前为何失败 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GovernanceBackendError` 可被 `except GatewayUnavailableError` 接住,且 `reason == "governance_backend_down"`、`retry_after_s == 5.0` | `tests/unit/test_errors.py` | 改前非其子类,`pytest.raises(GatewayUnavailableError)` 不匹配 |
|
||||||
|
| `str(exc)` 仍为构造时的诊断串(防 §3.5 回归) | `tests/unit/test_errors.py` | 改前无该风险但改后若漏写 `self.args` 即失败,是回归护栏 |
|
||||||
|
| 三条泄漏路径(`try_acquire` / `try_enter` / `progress_age_s`)抛出的异常带正确 `scope`、且可被 `except GatewayUnavailableError` 接住 | `tests/unit/test_backpressure.py` — **三条都要新增桩**。现状: `progress_age_s` 只有 `TestQuotaGateProgressAge`(`:243-257`)覆盖包装行为、不验 scope;`try_acquire`(`QuotaGate`)与 `try_enter`(`BreakerGate`)**完全无桩** | 改前异常无 `scope` 属性 → `AttributeError`;两条新路径改前无覆盖 |
|
||||||
|
| 未知源抛 `SourceNotConfiguredError`,且断言它**不是** `GatewayUnavailableError` | 改 `tests/unit/test_redis_key_layout.py:70-74`(`test_unknown_source_rejected`,现断言 `GovernanceBackendError`);内存版**当前无对应用例,需新增**一条同款(`backends/memory/limiter.py:92` 的 `_cfg("nope")`) | 改前 redis 版类型断言失败;内存版改前无覆盖(该分支从未被测过) |
|
||||||
|
| Redis 真实掉线时准入侧抛 scope 级异常且 `reason == "governance_backend_down"` | `tests/integration/test_redis_cross_connection.py:228-245`(真实 Redis,不 mock) | 改前无 `reason` 属性 |
|
||||||
|
|
||||||
|
**必须同批更新的既有测试构造点**(新签名为 keyword-only 必填,漏改即 `TypeError: missing required keyword-only argument`,门禁直接红):
|
||||||
|
|
||||||
|
| 位置 | 现状 | 改为 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tests/unit/test_backpressure.py:176 / :181 / :186` | `raise GovernanceBackendError("redis 抖动")` | 补 `scope=`(任意测试 scope,如 `"llm"`) |
|
||||||
|
| `tests/unit/test_errors.py:89` | `exc = GovernanceBackendError("redis down")` | 同上;该用例现断言它**不属于**可重试分类,须一并改为断言它**是** `GatewayUnavailableError` |
|
||||||
|
| `tests/unit/test_backpressure.py:255 / :257` | `QuotaGate(_L())` / `QuotaGate(_Broken())` | `QuotaGate(_L(), scope="llm")` 等 |
|
||||||
|
|
||||||
|
**保真校验检查点**: 提交前对照上文"保真校验"五条逐条自查,确认无一被顺带改动。特别核对 `RedisPermit.release/settle`(`redis/limiter.py:133,151`)的 `except GovernanceBackendError` 仍能接住释放侧失败——该处是设计 §4 否决"让原始异常穿透"路线的直接原因。
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
- `conda run -n PolyGateway pytest tests/unit tests/contracts -v` → 全 PASS
|
||||||
|
- `conda run -n PolyGateway pytest tests/integration -v` → 全 PASS(需真实 Redis)
|
||||||
|
- `conda run -n PolyGateway pytest tests/ -q` → `0 failed`
|
||||||
|
- `conda run -n PolyGateway radon cc src -n C -s` → 无输出
|
||||||
|
- `make lint` → import-linter 契约全绿(本次不新增跨层依赖,应无变化)
|
||||||
|
|
||||||
|
**提交**: `fix: reparent governance backend failures under GatewayUnavailableError (issue #7)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] T4: 公开错误面文档(issue #7 第二诉求)
|
||||||
|
|
||||||
|
**文件**: 改 `README.md`(§"错误模型(四分类)",约 114-125 行)、`research-wiki/migrations/chsanalyzer.md`
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. README 增一张两列表,明确区分**会到达调用方**与**库内吸收**:
|
||||||
|
|
||||||
|
| 会到达调用方 | 库内吸收 |
|
||||||
|
|---|---|
|
||||||
|
| `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError`) | `TransientError` |
|
||||||
|
| `RequestRejectedError` | `SourceDeadError` |
|
||||||
|
| `ResultInvalidError` | |
|
||||||
|
| `SourceNotConfiguredError` | |
|
||||||
|
|
||||||
|
2. 在该表下补一句说明: `TransientError` / `SourceDeadError` 的 docstring 描述的是**库内治理行为**,它们被 `middleware/retry.py:365` 接住并在预算耗尽时包成 `AllSourcesExhausted`,**不会**到达调用方——issue #7 记载下游曾据此写错整段设计文档。
|
||||||
|
3. `migrations/chsanalyzer.md` 的 G1 条目补注:后端故障现已并入 `GatewayUnavailableError`,项目侧 `except GatewayUnavailableError` 一条即覆盖完整,无需为 `GovernanceBackendError` 单列分支。
|
||||||
|
|
||||||
|
**验收**: 调用方仅读 README 即可判断该 catch 什么,无需读 `middleware/retry.py`。
|
||||||
|
|
||||||
|
**测试要求**: 纯文档,无测试证据要求。
|
||||||
|
|
||||||
|
**验证**: `grep -n "库内吸收" README.md` → 命中。
|
||||||
|
|
||||||
|
**提交**: `docs: publish which errors reach callers and which the library absorbs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] T5: 版本 1.1.0 + CHANGELOG + Wiki 同步
|
||||||
|
|
||||||
|
**文件**: 改 `pyproject.toml`(version)、`src/polygateway/__init__.py`(`__version__`)、`CHANGELOG.md`;按 `research-wiki/docs-convention.md` §2 同步 Gitea Wiki
|
||||||
|
|
||||||
|
**行为**: 版本 `1.0.6` → `1.1.0`(有行为变更但无 API 破坏:加父类是扩大)。CHANGELOG 需写明:
|
||||||
|
|
||||||
|
- **行为变更**: 后端故障从"落入调用方兜底分支"变为"被 `except GatewayUnavailableError` 捕获";下游据此把它按"延期重投、不消耗失败预算"处置,这正是修复目标,但**处置路线确实变了**,升级前须确认下游的兜底分支没有依赖它。
|
||||||
|
- **新增**: `SourceNotConfiguredError`(公共导出)、`GOVERNANCE_BACKEND_RETRY_AFTER_S`、scope 级 reason `governance_backend_down`。
|
||||||
|
- **下游请读**: `GovernanceBackendError` 现携带 `scope` / `reason` / `retry_after_s`(默认 5.0)/`per_source_reasons`;`str(exc)` 仍是原诊断串,结构化字段并存。配置写错(源名不匹配)现在抛 `SourceNotConfiguredError` 而非 `GovernanceBackendError`,它**不**属于可重投家族——这是有意的,目的是让装配缺陷进死信而不是永远重投。
|
||||||
|
|
||||||
|
**验收**: 版本三处一致(`pyproject.toml` / `__init__.py` / CHANGELOG 标题);CLAUDE.md §6 要求"版本 bump 提交不得裸发",故本任务必须与 wiki 同步同批。
|
||||||
|
|
||||||
|
**测试要求**: 无行为变更,`pytest tests/ -q` 保持全绿即可。
|
||||||
|
|
||||||
|
**验证**: `grep -n "1.1.0" pyproject.toml src/polygateway/__init__.py CHANGELOG.md` → 三处命中。
|
||||||
|
|
||||||
|
**提交**: `chore: release 1.1.0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成后
|
||||||
|
|
||||||
|
按 CLAUDE.md §3 Phase 2,合并前须派**全新上下文**的 verifier subagent 做独立验证(`verification-before-completion`),并按新规则**前台运行**。随后走 `finishing-a-development-branch` 决定合并方式,并在 Gitea 关闭 issue #7。
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:governance-backend-error
|
||||||
|
title: "实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)"
|
||||||
|
date: 2026-08-06
|
||||||
|
---
|
||||||
|
|
||||||
|
# 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)
|
||||||
|
|
||||||
|
全文见 `2026-08-06-governance-backend-error-plan.md`。实现设计 [[governance-backend-error]](已批准 2026-08-06)。
|
||||||
|
|
||||||
|
## 五个任务
|
||||||
|
|
||||||
|
| # | 任务 | 关键约束 |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | `ARCHITECTURE.md` §6.1 回补两行 + reason 值域扩为 6 值 | **必须先行**——单一事实源纪律,先改代码后补文档等于让实现与事实源脱节(设计 §8.1) |
|
||||||
|
| T2 | `errors.py` 纯增量: 新常量、新 reason、`SourceNotConfiguredError` + 顶层导出 | 刻意不动 `GovernanceBackendError`,故全套件保持通过,可独立提交 |
|
||||||
|
| T3 | `GovernanceBackendError` 归位 + 22 处构造点 + gate scope 注入 + 全部受影响测试 | **必须原子**: `scope` 是必填 keyword,分批提交的中间状态会 `TypeError` |
|
||||||
|
| T4 | README 公开错误面两列表 + 迁移文档补注 | issue #7 的第二诉求,作者认为比第一条更值得改 |
|
||||||
|
| T5 | 版本 1.1.0 + CHANGELOG + Wiki 同步 | 加父类是扩大不是破坏,故 minor 而非 major |
|
||||||
|
|
||||||
|
## 保真校验(适用)
|
||||||
|
|
||||||
|
触及 ARCHITECTURE §1.4 的移植蓝本(CHS `app/domain/errors.py` 与 `app/coordination/`)。本次**有意变更**的语义仅一条(`GovernanceBackendError` 的类型归属);`retry_after_s` 非可选语义、两个 reason 值域、fail-closed 方向、记账/闸门分工、`RedisPermit` 释放侧降级五条**不得被顺带改动**,每任务完成前逐条自查。
|
||||||
|
|
||||||
|
## 独立审查修正(2026-08-06, Codex)
|
||||||
|
|
||||||
|
4 条意见全部核实属实并已折回:
|
||||||
|
|
||||||
|
1. **T3 测试证据定位错误**(重要)——原写"复用 `test_backpressure.py:176-186` 的注入桩"覆盖三条泄漏路径,实测那三个桩是 `record_success`/`record_failure`/`mark_progress` 的**记账侧降级**,与闸门路径无关;`try_acquire`/`try_enter` 全无覆盖。已改为"三条桩都要新增"并写明现状。
|
||||||
|
2. **T3 漏了既有测试构造点**(重要)——新签名 keyword-only 必填,`test_backpressure.py:176/181/186`、`test_errors.py:89` 的裸 `GovernanceBackendError("...")` 与 `:255/:257` 的 `QuotaGate(_L())` 漏改即 `TypeError`。已补一张同批更新清单。
|
||||||
|
3. **`__all__` 插入位置写反**(次要)——按字母序应在 `SourceDeadError` **之后**而非之前。已改。
|
||||||
|
4. **设计中 telemetry 行号过时**(次要)——`:210` → `:250`,系本分支加 `_AttemptUsage` 造成的漂移。设计与摘要页已同步更新。
|
||||||
|
|
||||||
|
Codex 同时独立核实了计划的可执行性锚点: 22 处构造点、三处 gate 装配、后端层 `self._scope` 位置、README/ARCH 章节行号,均与 `src/` 现状相符。
|
||||||
|
|
||||||
|
相关: [[governance-backend-error]](design)、[[m2-distributed]]
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Query Pack
|
# Query Pack
|
||||||
|
|
||||||
> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。
|
> 自动生成,请勿手动编辑。
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
type: schema
|
type: schema
|
||||||
node_id: schema:llm-calls
|
node_id: schema:llm-calls
|
||||||
title: "表结构: llm_calls(遥测 21 字段)"
|
title: "表结构: llm_calls(遥测 22 字段)"
|
||||||
date: 2026-07-20
|
date: 2026-07-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# 表结构: llm_calls(遥测 21 字段)
|
# 表结构: llm_calls(遥测 22 字段)
|
||||||
|
|
||||||
|
|
||||||
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
||||||
@@ -27,6 +27,7 @@ date: 2026-07-20
|
|||||||
| cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 |
|
| cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 |
|
||||||
| 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 = **本次调用**未上报 |
|
||||||
|
|
||||||
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ 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)。
|
||||||
|
|
||||||
`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 入口的取值必须各自定死,否则同一列在不同行含义不同:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from polygateway.errors import (
|
|||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
ResultInvalidError,
|
ResultInvalidError,
|
||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
|
SourceNotConfiguredError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
from polygateway.ocr import OcrClient
|
from polygateway.ocr import OcrClient
|
||||||
@@ -31,7 +32,7 @@ from polygateway.types import (
|
|||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.0.5"
|
__version__ = "1.1.0"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROFILES",
|
"DEFAULT_PROFILES",
|
||||||
@@ -58,6 +59,7 @@ __all__ = [
|
|||||||
"ResultInvalidError",
|
"ResultInvalidError",
|
||||||
"SourceConfig",
|
"SourceConfig",
|
||||||
"SourceDeadError",
|
"SourceDeadError",
|
||||||
|
"SourceNotConfiguredError",
|
||||||
"TransientError",
|
"TransientError",
|
||||||
"__version__",
|
"__version__",
|
||||||
"gather_bounded",
|
"gather_bounded",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from polygateway.errors import GovernanceBackendError
|
from polygateway.errors import SourceNotConfiguredError
|
||||||
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
|
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -89,7 +89,7 @@ class InMemoryLimiter:
|
|||||||
def _cfg(self, source_key: str) -> SourceConfig:
|
def _cfg(self, source_key: str) -> SourceConfig:
|
||||||
cfg = self._sources.get(source_key)
|
cfg = self._sources.get(source_key)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
raise GovernanceBackendError(f"未知源 {source_key!r}(scope={self._scope})")
|
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
def _window(self) -> int:
|
def _window(self) -> int:
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ class RedisGate:
|
|||||||
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
|
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
|
||||||
)
|
)
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}", scope=self._scope) from exc
|
||||||
return self._decision(source_name, result)
|
return self._decision(source_name, result)
|
||||||
|
|
||||||
async def record_success(
|
async def record_success(
|
||||||
@@ -385,7 +385,7 @@ class RedisGate:
|
|||||||
try:
|
try:
|
||||||
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
|
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}", scope=self._scope) from exc
|
||||||
return self._update(result)
|
return self._update(result)
|
||||||
|
|
||||||
async def record_failure(
|
async def record_failure(
|
||||||
@@ -407,7 +407,7 @@ class RedisGate:
|
|||||||
try:
|
try:
|
||||||
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
|
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}", scope=self._scope) from exc
|
||||||
return self._update(result)
|
return self._update(result)
|
||||||
|
|
||||||
async def release_probe(self, entry: GateDecision) -> GateUpdate:
|
async def release_probe(self, entry: GateDecision) -> GateUpdate:
|
||||||
@@ -419,7 +419,7 @@ class RedisGate:
|
|||||||
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
|
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
|
||||||
)
|
)
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}", scope=self._scope) from exc
|
||||||
return self._update(result)
|
return self._update(result)
|
||||||
|
|
||||||
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
|
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
|
||||||
@@ -429,7 +429,7 @@ class RedisGate:
|
|||||||
try:
|
try:
|
||||||
result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
|
result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope) from exc
|
||||||
return int(result) / 1000.0
|
return int(result) / 1000.0
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from redis.exceptions import RedisError
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
from polygateway.errors import GovernanceBackendError
|
from polygateway.errors import GovernanceBackendError, SourceNotConfiguredError
|
||||||
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
|
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -195,7 +195,7 @@ class RedisLimiter:
|
|||||||
def _cfg(self, source_key: str) -> SourceConfig:
|
def _cfg(self, source_key: str) -> SourceConfig:
|
||||||
cfg = self._sources.get(source_key)
|
cfg = self._sources.get(source_key)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
raise GovernanceBackendError(f"未知源 {source_key!r}(scope={self._scope})")
|
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
def _lease_keys(self, source_key: str) -> tuple[str, str]:
|
def _lease_keys(self, source_key: str) -> tuple[str, str]:
|
||||||
@@ -247,7 +247,7 @@ class RedisLimiter:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}") from exc
|
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}", scope=self._scope) from exc
|
||||||
if ok != 1:
|
if ok != 1:
|
||||||
return None
|
return None
|
||||||
return _RedisPermit(self, source_key, lease_id, est_tokens, window)
|
return _RedisPermit(self, source_key, lease_id, est_tokens, window)
|
||||||
@@ -265,14 +265,14 @@ class RedisLimiter:
|
|||||||
try:
|
try:
|
||||||
await self._release_lua(keys=[gl, sl], args=[lease_id])
|
await self._release_lua(keys=[gl, sl], args=[lease_id])
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"限流后端 release 失败: {exc}") from exc
|
raise GovernanceBackendError(f"限流后端 release 失败: {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
|
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
|
||||||
wk = self._window_keys(source_key, window)
|
wk = self._window_keys(source_key, window)
|
||||||
try:
|
try:
|
||||||
await self._settle_lua(keys=[wk["g_tpm"], wk["s_tpm"]], args=[delta, _WINDOW_TTL_S])
|
await self._settle_lua(keys=[wk["g_tpm"], wk["s_tpm"]], args=[delta, _WINDOW_TTL_S])
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"限流后端 settle 失败: {exc}") from exc
|
raise GovernanceBackendError(f"限流后端 settle 失败: {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def source_stats(self, source_key: str) -> SourceStats:
|
async def source_stats(self, source_key: str) -> SourceStats:
|
||||||
"""当前窗口快照;读侧 clamp ≥0(展示口径,存储保留负值)。"""
|
"""当前窗口快照;读侧 clamp ≥0(展示口径,存储保留负值)。"""
|
||||||
@@ -283,7 +283,7 @@ class RedisLimiter:
|
|||||||
wk = self._window_keys(source_key, window)
|
wk = self._window_keys(source_key, window)
|
||||||
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
|
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}") from exc
|
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}", scope=self._scope) from exc
|
||||||
return SourceStats(
|
return SourceStats(
|
||||||
inflight=int(res[0]),
|
inflight=int(res[0]),
|
||||||
rpm_used=max(0, int(res[1])),
|
rpm_used=max(0, int(res[1])),
|
||||||
@@ -295,14 +295,14 @@ class RedisLimiter:
|
|||||||
try:
|
try:
|
||||||
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
|
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}") from exc
|
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def progress_age_s(self) -> float:
|
async def progress_age_s(self) -> float:
|
||||||
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
|
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
|
||||||
try:
|
try:
|
||||||
res = await self._progress_age_lua(keys=[self._progress_key()])
|
res = await self._progress_age_lua(keys=[self._progress_key()])
|
||||||
except RedisError as exc:
|
except RedisError as exc:
|
||||||
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}") from exc
|
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}", scope=self._scope) from exc
|
||||||
return float("inf") if int(res) == -1 else int(res) / 1000.0
|
return float("inf") if int(res) == -1 else int(res) / 1000.0
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
+46
-12
@@ -25,7 +25,7 @@ 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
|
||||||
from polygateway.pricing import PricingTable
|
from polygateway.pricing import PricingTable
|
||||||
from polygateway.providers import get_provider
|
from polygateway.providers import get_capability, get_provider, resolve_thinking
|
||||||
from polygateway.sources import (
|
from polygateway.sources import (
|
||||||
AdaptivePacer,
|
AdaptivePacer,
|
||||||
HealthAwareSelector,
|
HealthAwareSelector,
|
||||||
@@ -51,7 +51,7 @@ if TYPE_CHECKING:
|
|||||||
TelemetryRecorder,
|
TelemetryRecorder,
|
||||||
Transport,
|
Transport,
|
||||||
)
|
)
|
||||||
from polygateway.providers import ProviderProfile
|
from polygateway.providers import ProviderProfile, ThinkingCapability
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
@@ -61,22 +61,52 @@ if TYPE_CHECKING:
|
|||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_thinking(
|
||||||
|
sources: list[SourceConfig],
|
||||||
|
profiles: list[ProviderProfile],
|
||||||
|
capabilities: Mapping[str, ThinkingCapability] | None,
|
||||||
|
) -> None:
|
||||||
|
"""装配期把不可满足的推理开关炸掉,而不是留到运行时(issue #5)。
|
||||||
|
|
||||||
|
与 transport 内的同一次判定不是重复: 那里兜的是"构造函数全量注入"这条路
|
||||||
|
(CLAUDE.md §4.5 的第二条装配路),而工厂路占 90% 场景,配置错误应当在装配期
|
||||||
|
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
|
||||||
|
"""
|
||||||
|
for source, profile in zip(sources, profiles, strict=True):
|
||||||
|
resolve_thinking(
|
||||||
|
profile,
|
||||||
|
get_capability(source.model, table=capabilities),
|
||||||
|
source.enable_thinking,
|
||||||
|
model=source.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint_mark(source: SourceConfig) -> str:
|
||||||
|
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。
|
||||||
|
|
||||||
|
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
|
||||||
|
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。
|
||||||
|
"""
|
||||||
|
parts: list[Any] = [source.model, dict(source.extra_body)]
|
||||||
|
if source.enable_thinking is not None:
|
||||||
|
parts.append(source.enable_thinking)
|
||||||
|
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
|
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)。全源 `extra_body` 皆空时
|
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理
|
||||||
字面量与历史实现逐字相同,不触发存量缓存冷启动。
|
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时
|
||||||
|
缓存的旧响应。全源两者皆未表态时字面量与历史实现逐字相同,不触发存量
|
||||||
|
缓存冷启动。
|
||||||
"""
|
"""
|
||||||
fingerprint = ",".join(sorted({s.model for s in sources}))
|
fingerprint = ",".join(sorted({s.model for s in sources}))
|
||||||
# 按 (model, extra_body) 而非源名摘要: 语义是"本 scope 会用哪些
|
# 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope
|
||||||
# (模型, 解码参数)组合",改源名不该误触全量冷启动
|
# 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
|
||||||
marks = sorted(
|
marks = sorted(
|
||||||
{
|
{_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None}
|
||||||
json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False)
|
|
||||||
for s in sources
|
|
||||||
if s.extra_body
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
if marks:
|
if marks:
|
||||||
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
|
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
|
||||||
@@ -241,11 +271,13 @@ class GatewayClient:
|
|||||||
cache: CacheBackend | None = None,
|
cache: CacheBackend | None = None,
|
||||||
telemetry: TelemetryRecorder | None = None,
|
telemetry: TelemetryRecorder | None = None,
|
||||||
registry: Mapping[str, ProviderProfile] | None = None,
|
registry: Mapping[str, ProviderProfile] | None = None,
|
||||||
|
capabilities: Mapping[str, ThinkingCapability] | None = None,
|
||||||
rng: Any = random.random,
|
rng: Any = random.random,
|
||||||
) -> GatewayClient:
|
) -> GatewayClient:
|
||||||
"""按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。"""
|
"""按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。"""
|
||||||
sources = list(settings.sources)
|
sources = list(settings.sources)
|
||||||
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)
|
||||||
strategy, escalation = _build_structured(profiles)
|
strategy, escalation = _build_structured(profiles)
|
||||||
return cls(
|
return cls(
|
||||||
scope=settings.scope,
|
scope=settings.scope,
|
||||||
@@ -253,7 +285,7 @@ class GatewayClient:
|
|||||||
selector=_build_selector(settings.selector, rng=rng),
|
selector=_build_selector(settings.selector, rng=rng),
|
||||||
limiter=limiter or _build_limiter(settings, sources),
|
limiter=limiter or _build_limiter(settings, sources),
|
||||||
breaker=breaker or _build_breaker(settings),
|
breaker=breaker or _build_breaker(settings),
|
||||||
transport=OpenAICompatTransport(registry=registry),
|
transport=OpenAICompatTransport(registry=registry, capabilities=capabilities),
|
||||||
retry=settings.retry,
|
retry=settings.retry,
|
||||||
backpressure=settings.backpressure,
|
backpressure=settings.backpressure,
|
||||||
quota_full=settings.quota_full,
|
quota_full=settings.quota_full,
|
||||||
@@ -279,6 +311,7 @@ class GatewayClient:
|
|||||||
cache: CacheBackend | None = None,
|
cache: CacheBackend | None = None,
|
||||||
telemetry: TelemetryRecorder | None = None,
|
telemetry: TelemetryRecorder | None = None,
|
||||||
registry: Mapping[str, ProviderProfile] | None = None,
|
registry: Mapping[str, ProviderProfile] | None = None,
|
||||||
|
capabilities: Mapping[str, ThinkingCapability] | None = None,
|
||||||
env: Mapping[str, str] | None = None,
|
env: Mapping[str, str] | None = None,
|
||||||
) -> GatewayClient:
|
) -> GatewayClient:
|
||||||
"""从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。"""
|
"""从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。"""
|
||||||
@@ -289,6 +322,7 @@ class GatewayClient:
|
|||||||
cache=cache,
|
cache=cache,
|
||||||
telemetry=telemetry,
|
telemetry=telemetry,
|
||||||
registry=registry,
|
registry=registry,
|
||||||
|
capabilities=capabilities,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -149,9 +149,11 @@ class GatewaySettings:
|
|||||||
def _normalize(self) -> None:
|
def _normalize(self) -> None:
|
||||||
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
||||||
|
|
||||||
`scope` 最要紧: 它直接进 Redis key(`pgw:limit:{scope}:…`/`pgw:gate:{scope}:…`)。
|
`scope` 的 strip 才是要紧的那一半: 它进 Redis key(`pgw:limit:{scope}:…`
|
||||||
一个进程走 `from_env("LLM")` 拿到 "llm"、另一个直接构造传 "LLM",同一逻辑
|
/`pgw:gate:{scope}:…`),而两个 Redis 后端在构造函数里只 `.lower()` **不 strip**
|
||||||
scope 的限流与熔断状态会分裂到两套命名空间,各记各的,治理静默失效且不报错。
|
——`"llm "` 会产出 `pgw:limit:llm :…`,与 `from_env` 路的进程分裂成两套命名空间。
|
||||||
|
大小写则不会: 后端自 v1.0.0 起各自 lower,`from_settings` 传 "LLM" 也落在同一
|
||||||
|
套 key 上(此处 lower 只为让 `GatewaySettings.scope` 属性两路取值一致)。
|
||||||
|
|
||||||
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
|
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
|
||||||
抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
|
抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ class EmbeddingClient:
|
|||||||
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||||
self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
|
self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
|
||||||
self._selector = selector
|
self._selector = selector
|
||||||
self._quota = QuotaGate(limiter)
|
self._quota = QuotaGate(limiter, scope=self._scope)
|
||||||
self._breaker = BreakerGate(breaker)
|
self._breaker = BreakerGate(breaker, scope=self._scope)
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._retry = retry
|
self._retry = retry
|
||||||
self._bp = backpressure
|
self._bp = backpressure
|
||||||
|
|||||||
@@ -5,8 +5,22 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SCOPE_REASONS = frozenset(
|
SCOPE_REASONS = frozenset(
|
||||||
{"circuit_open", "retry_exhausted", "stalled", "quota_exhausted", "no_sources"}
|
{
|
||||||
|
"circuit_open",
|
||||||
|
"retry_exhausted",
|
||||||
|
"stalled",
|
||||||
|
"quota_exhausted",
|
||||||
|
"no_sources",
|
||||||
|
"governance_backend_down", # issue #7: 限流/熔断后端故障(fail-closed → 整个 scope 发不出请求)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0
|
||||||
|
"""治理后端故障的建议重投间隔(秒)。
|
||||||
|
|
||||||
|
**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),
|
||||||
|
故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让积压任务零延迟
|
||||||
|
同时冲击已挂掉的后端,把一次故障放大成一场风暴(issue #7 §3.2)。
|
||||||
|
"""
|
||||||
SOURCE_REASONS = frozenset(
|
SOURCE_REASONS = frozenset(
|
||||||
{
|
{
|
||||||
"network_error",
|
"network_error",
|
||||||
@@ -127,5 +141,38 @@ class AllSourcesExhausted(GatewayUnavailableError): # noqa: N818 — ARCH §6.1
|
|||||||
"""重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。"""
|
"""重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。"""
|
||||||
|
|
||||||
|
|
||||||
class GovernanceBackendError(PolyGatewayError):
|
class SourceNotConfiguredError(PolyGatewayError):
|
||||||
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。"""
|
"""源名不在限流后端的配置字典中: 装配缺陷,正常不可达。
|
||||||
|
|
||||||
|
**有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是"配置写
|
||||||
|
错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置错误的任务永远
|
||||||
|
重投、永不告警——正是 issue #7 要修的那个 bug 的镜像(§3.4)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceBackendError(GatewayUnavailableError):
|
||||||
|
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。
|
||||||
|
|
||||||
|
继承 `GatewayUnavailableError`(issue #7): fail-closed 意味着整个 scope 一个
|
||||||
|
请求都发不出去,语义上即 scope 级不可用。此前它是 `PolyGatewayError` 的直接
|
||||||
|
子类,只写 `except GatewayUnavailableError` 的调用方接不住,后果是"Redis 抖
|
||||||
|
一下 → 积压任务消耗业务失败预算 → 进死信",而那是运维重启即可恢复的故障。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
retry_after_s: float = GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
||||||
|
source_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
scope=scope,
|
||||||
|
reason="governance_backend_down",
|
||||||
|
retry_after_s=retry_after_s,
|
||||||
|
source_name=source_name,
|
||||||
|
)
|
||||||
|
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
|
||||||
|
# 携带的诊断串(如"限流后端 try_acquire 失败: ...")是排障主线索,必须保住
|
||||||
|
self.args = (message,)
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ if TYPE_CHECKING:
|
|||||||
class BreakerGate:
|
class BreakerGate:
|
||||||
"""RetryMW 面向熔断后端的唯一入口;包装一切后端异常。"""
|
"""RetryMW 面向熔断后端的唯一入口;包装一切后端异常。"""
|
||||||
|
|
||||||
def __init__(self, gate: ProviderGate) -> None:
|
def __init__(self, gate: ProviderGate, *, scope: str) -> None:
|
||||||
self._gate = gate
|
self._gate = gate
|
||||||
|
# 后端故障即 scope 级不可用,异常须携 scope 供调用方定位(issue #7 §3.3)
|
||||||
|
self._scope = scope
|
||||||
|
|
||||||
async def try_enter(self, source: SourceConfig, owner: str) -> GateDecision:
|
async def try_enter(self, source: SourceConfig, owner: str) -> GateDecision:
|
||||||
try:
|
try:
|
||||||
@@ -23,7 +25,7 @@ class BreakerGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端故障(try_enter): {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端故障(try_enter): {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def record_success(
|
async def record_success(
|
||||||
self, entry: GateDecision, *, count_attempt: bool = True
|
self, entry: GateDecision, *, count_attempt: bool = True
|
||||||
@@ -33,7 +35,7 @@ class BreakerGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端故障(record_success): {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端故障(record_success): {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def record_failure(
|
async def record_failure(
|
||||||
self, entry: GateDecision, reason: str, force_open: bool
|
self, entry: GateDecision, reason: str, force_open: bool
|
||||||
@@ -43,7 +45,7 @@ class BreakerGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端故障(record_failure): {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端故障(record_failure): {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def release_probe(self, entry: GateDecision) -> GateUpdate:
|
async def release_probe(self, entry: GateDecision) -> GateUpdate:
|
||||||
try:
|
try:
|
||||||
@@ -51,7 +53,7 @@ class BreakerGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端故障(release_probe): {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端故障(release_probe): {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
|
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
|
||||||
try:
|
try:
|
||||||
@@ -59,4 +61,4 @@ class BreakerGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"熔断后端故障(retry_after_s): {exc}") from exc
|
raise GovernanceBackendError(f"熔断后端故障(retry_after_s): {exc}", scope=self._scope) from exc
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ if TYPE_CHECKING:
|
|||||||
class QuotaGate:
|
class QuotaGate:
|
||||||
"""RetryMW 面向限流后端的唯一入口;包装一切后端异常。"""
|
"""RetryMW 面向限流后端的唯一入口;包装一切后端异常。"""
|
||||||
|
|
||||||
def __init__(self, limiter: RateLimiter) -> None:
|
def __init__(self, limiter: RateLimiter, *, scope: str) -> None:
|
||||||
self._limiter = limiter
|
self._limiter = limiter
|
||||||
|
# 后端故障即 scope 级不可用,异常须携 scope 供调用方定位(issue #7 §3.3)
|
||||||
|
self._scope = scope
|
||||||
|
|
||||||
async def try_acquire(self, source: SourceConfig) -> Permit | None:
|
async def try_acquire(self, source: SourceConfig) -> Permit | None:
|
||||||
try:
|
try:
|
||||||
@@ -27,7 +29,7 @@ class QuotaGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"限流后端故障(try_acquire): {exc}") from exc
|
raise GovernanceBackendError(f"限流后端故障(try_acquire): {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def stats(self, source: SourceConfig) -> SourceStats:
|
async def stats(self, source: SourceConfig) -> SourceStats:
|
||||||
try:
|
try:
|
||||||
@@ -35,7 +37,7 @@ class QuotaGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"限流后端故障(source_stats): {exc}") from exc
|
raise GovernanceBackendError(f"限流后端故障(source_stats): {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def mark_progress(self) -> None:
|
async def mark_progress(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -43,7 +45,7 @@ class QuotaGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"限流后端故障(mark_progress): {exc}") from exc
|
raise GovernanceBackendError(f"限流后端故障(mark_progress): {exc}", scope=self._scope) from exc
|
||||||
|
|
||||||
async def progress_age_s(self) -> float:
|
async def progress_age_s(self) -> float:
|
||||||
try:
|
try:
|
||||||
@@ -51,4 +53,4 @@ class QuotaGate:
|
|||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GovernanceBackendError(f"限流后端故障(progress_age_s): {exc}") from exc
|
raise GovernanceBackendError(f"限流后端故障(progress_age_s): {exc}", scope=self._scope) from exc
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ class RetryMW:
|
|||||||
self._scope = scope
|
self._scope = scope
|
||||||
self._sources = list(sources)
|
self._sources = list(sources)
|
||||||
self._selector = selector
|
self._selector = selector
|
||||||
self._quota = QuotaGate(limiter)
|
self._quota = QuotaGate(limiter, scope=self._scope)
|
||||||
self._breaker = BreakerGate(gate)
|
self._breaker = BreakerGate(gate, scope=self._scope)
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._retry = retry
|
self._retry = retry
|
||||||
self._bp = backpressure
|
self._bp = backpressure
|
||||||
@@ -438,6 +438,7 @@ class RetryMW:
|
|||||||
usage_source=result.usage_source,
|
usage_source=result.usage_source,
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -28,6 +29,44 @@ if TYPE_CHECKING:
|
|||||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _AttemptUsage:
|
||||||
|
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
|
||||||
|
|
||||||
|
存在的理由是把 `emit_attempt` 里逐字段重复的 `X if response else Y` 收敛为
|
||||||
|
一处判定——十处三元把该方法推到圈复杂度 C,而它们表达的是同一件事。
|
||||||
|
"""
|
||||||
|
|
||||||
|
response_text: str = ""
|
||||||
|
thinking: str = ""
|
||||||
|
prompt_tokens: int = 0
|
||||||
|
completion_tokens: int = 0
|
||||||
|
usage_source: str = "unavailable"
|
||||||
|
ttft_ms: float | None = None
|
||||||
|
max_inter_token_ms: float | None = None
|
||||||
|
cached_prompt_tokens: int | None = None
|
||||||
|
model_reported: str | None = None
|
||||||
|
reasoning_tokens: int | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
||||||
|
"""从响应取用量;`None`(失败尝试)返回全默认视图。"""
|
||||||
|
if response is None:
|
||||||
|
return cls()
|
||||||
|
return cls(
|
||||||
|
response_text=response.content,
|
||||||
|
thinking=response.thinking,
|
||||||
|
prompt_tokens=response.prompt_tokens,
|
||||||
|
completion_tokens=response.completion_tokens,
|
||||||
|
usage_source=response.usage_source,
|
||||||
|
ttft_ms=response.ttft_ms,
|
||||||
|
max_inter_token_ms=response.max_inter_token_ms,
|
||||||
|
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||||
|
model_reported=response.model_reported,
|
||||||
|
reasoning_tokens=response.reasoning_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TelemetryEmitter:
|
class TelemetryEmitter:
|
||||||
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
|
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
|
||||||
|
|
||||||
@@ -46,24 +85,26 @@ class TelemetryEmitter:
|
|||||||
error: str | None,
|
error: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。"""
|
"""逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。"""
|
||||||
|
usage = _AttemptUsage.of(response)
|
||||||
await self._record(
|
await self._record(
|
||||||
request=request,
|
request=request,
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
model=source.model,
|
model=source.model,
|
||||||
provider=source.provider,
|
provider=source.provider,
|
||||||
source_name=source.name,
|
source_name=source.name,
|
||||||
response_text=response.content if response else "",
|
response_text=usage.response_text,
|
||||||
thinking=response.thinking if response else "",
|
thinking=usage.thinking,
|
||||||
prompt_tokens=response.prompt_tokens if response else 0,
|
prompt_tokens=usage.prompt_tokens,
|
||||||
completion_tokens=response.completion_tokens if response else 0,
|
completion_tokens=usage.completion_tokens,
|
||||||
usage_source=response.usage_source if response else "unavailable",
|
usage_source=usage.usage_source,
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
ttft_ms=response.ttft_ms if response else None,
|
ttft_ms=usage.ttft_ms,
|
||||||
max_inter_token_ms=response.max_inter_token_ms if response else None,
|
max_inter_token_ms=usage.max_inter_token_ms,
|
||||||
cache_hit=False,
|
cache_hit=False,
|
||||||
error=error,
|
error=error,
|
||||||
cached_prompt_tokens=response.cached_prompt_tokens if response else None,
|
cached_prompt_tokens=usage.cached_prompt_tokens,
|
||||||
model_reported=response.model_reported if response else None,
|
model_reported=usage.model_reported,
|
||||||
|
reasoning_tokens=usage.reasoning_tokens,
|
||||||
# 唯一有"生效源"的入口,故是唯一能并上 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)),
|
||||||
)
|
)
|
||||||
@@ -90,6 +131,7 @@ class TelemetryEmitter:
|
|||||||
# 统计供应商缓存命中率必须带 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,
|
||||||
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
||||||
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
||||||
sampling=canonical_sampling_json(request.sampling),
|
sampling=canonical_sampling_json(request.sampling),
|
||||||
@@ -117,6 +159,7 @@ class TelemetryEmitter:
|
|||||||
error=error,
|
error=error,
|
||||||
cached_prompt_tokens=None,
|
cached_prompt_tokens=None,
|
||||||
model_reported=None,
|
model_reported=None,
|
||||||
|
reasoning_tokens=None,
|
||||||
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
||||||
sampling=canonical_sampling_json(request.sampling),
|
sampling=canonical_sampling_json(request.sampling),
|
||||||
)
|
)
|
||||||
@@ -142,6 +185,7 @@ class TelemetryEmitter:
|
|||||||
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,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||||
@@ -182,6 +226,7 @@ class TelemetryEmitter:
|
|||||||
cached_prompt_tokens=cached_prompt_tokens,
|
cached_prompt_tokens=cached_prompt_tokens,
|
||||||
model_reported=model_reported,
|
model_reported=model_reported,
|
||||||
sampling=sampling,
|
sampling=sampling,
|
||||||
|
reasoning_tokens=reasoning_tokens,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ class OcrClient:
|
|||||||
self._sources = strip_unsupported_extra_body(list(sources), path="OCR")
|
self._sources = strip_unsupported_extra_body(list(sources), path="OCR")
|
||||||
self._selector = selector
|
self._selector = selector
|
||||||
self._feed_health = isinstance(selector, OutcomeAwareSelector)
|
self._feed_health = isinstance(selector, OutcomeAwareSelector)
|
||||||
self._quota = QuotaGate(limiter)
|
self._quota = QuotaGate(limiter, scope=self._scope)
|
||||||
self._breaker = BreakerGate(breaker)
|
self._breaker = BreakerGate(breaker, scope=self._scope)
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._retry = retry
|
self._retry = retry
|
||||||
self._bp = backpressure
|
self._bp = backpressure
|
||||||
|
|||||||
@@ -275,4 +275,5 @@ class TelemetryRecorder(Protocol):
|
|||||||
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,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|||||||
+174
-16
@@ -10,24 +10,37 @@ 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)
|
@dataclass(frozen=True)
|
||||||
class ProviderProfile:
|
class ProviderProfile:
|
||||||
"""单个 provider 的能力与差异声明。
|
"""单个 provider 的能力与差异声明。
|
||||||
|
|
||||||
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为
|
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为
|
||||||
True/False 时并入请求体的参数片段(None 时二者都不注入,用模型默认);
|
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。
|
||||||
|
|
||||||
注: 某个 provider 的两档若皆为空字典(如 openai/minimax),说明该 provider
|
两档各有三种取值,**语义互不重叠**(issue #5):
|
||||||
无已知的推理开关参数——此时 `enable_thinking` 对它**不产生任何效果**,
|
|
||||||
而非静默生效。需要下发自定义参数时用 `SourceConfig.extra_body`。
|
========== ==========================================================
|
||||||
|
``{...}`` 已知的注入片段
|
||||||
|
``{}`` 已知**无需注入**任何参数即处于该档
|
||||||
|
``None`` **未知**: 本库不知道该 provider 如何表达这一档
|
||||||
|
========== ==========================================================
|
||||||
|
|
||||||
|
`None` 与 `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False`
|
||||||
|
对 minimax/openai 源静默失效——调用方以为关掉了推理,实际什么都没发生。
|
||||||
|
现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`。
|
||||||
|
|
||||||
|
注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型能否
|
||||||
|
关闭推理属**能力**(按 model 变),见 `ThinkingCapability`。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
thinking_on: dict[str, Any]
|
thinking_on: Mapping[str, Any] | None
|
||||||
thinking_off: dict[str, Any]
|
thinking_off: Mapping[str, Any] | None
|
||||||
strip_think_tags: bool
|
strip_think_tags: bool
|
||||||
supports_native_schema: bool = False
|
supports_native_schema: bool = False
|
||||||
|
|
||||||
@@ -47,26 +60,171 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
|
|||||||
thinking_off={"thinking": {"type": "disabled"}},
|
thinking_off={"thinking": {"type": "disabled"}},
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
),
|
),
|
||||||
# 两档皆空 ⇒ `enable_thinking` 对本 provider **不产生任何效果**(调用方
|
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把
|
||||||
# 以为关掉了实际没关)。真需要控制推理时经 `SourceConfig.extra_body` 下发
|
# kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给
|
||||||
|
# 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在
|
||||||
|
# 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider
|
||||||
"openai": ProviderProfile(
|
"openai": ProviderProfile(
|
||||||
name="openai",
|
name="openai",
|
||||||
thinking_on={},
|
thinking_on=None,
|
||||||
thinking_off={},
|
thinking_off=None,
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
),
|
),
|
||||||
# OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理。
|
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2),
|
||||||
# 同上: 两档皆空 ⇒ `enable_thinking` 对 MiniMax 源不产生任何效果
|
# **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被
|
||||||
|
# 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。
|
||||||
|
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的
|
||||||
|
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近
|
||||||
|
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。
|
||||||
|
# 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段)
|
||||||
"minimax": ProviderProfile(
|
"minimax": ProviderProfile(
|
||||||
name="minimax",
|
name="minimax",
|
||||||
thinking_on={},
|
thinking_on={"reasoning_effort": "medium"},
|
||||||
thinking_off={},
|
thinking_off={"reasoning_effort": "none"},
|
||||||
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:
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
cached_prompt_tokens INTEGER,
|
cached_prompt_tokens INTEGER,
|
||||||
model_reported TEXT,
|
model_reported TEXT,
|
||||||
sampling TEXT
|
sampling TEXT,
|
||||||
|
reasoning_tokens INTEGER
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ _BACKFILL = (
|
|||||||
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
|
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
|
||||||
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
|
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
|
||||||
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
|
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
|
||||||
|
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
|
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
|
||||||
@@ -81,6 +83,7 @@ _COLUMNS = (
|
|||||||
"cached_prompt_tokens",
|
"cached_prompt_tokens",
|
||||||
"model_reported",
|
"model_reported",
|
||||||
"sampling",
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
)
|
)
|
||||||
|
|
||||||
_INSERT = (
|
_INSERT = (
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
cached_prompt_tokens INTEGER,
|
cached_prompt_tokens INTEGER,
|
||||||
model_reported TEXT,
|
model_reported TEXT,
|
||||||
sampling TEXT
|
sampling TEXT,
|
||||||
|
reasoning_tokens INTEGER
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ _BACKFILL_COLUMNS = (
|
|||||||
("cached_prompt_tokens", "INTEGER"),
|
("cached_prompt_tokens", "INTEGER"),
|
||||||
("model_reported", "TEXT"),
|
("model_reported", "TEXT"),
|
||||||
("sampling", "TEXT"),
|
("sampling", "TEXT"),
|
||||||
|
("reasoning_tokens", "INTEGER"),
|
||||||
)
|
)
|
||||||
|
|
||||||
_COLUMNS = (
|
_COLUMNS = (
|
||||||
@@ -71,6 +73,7 @@ _COLUMNS = (
|
|||||||
"cached_prompt_tokens",
|
"cached_prompt_tokens",
|
||||||
"model_reported",
|
"model_reported",
|
||||||
"sampling",
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
)
|
)
|
||||||
|
|
||||||
_INSERT = (
|
_INSERT = (
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ from polygateway.errors import (
|
|||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
from polygateway.providers import ProviderProfile, get_provider
|
from polygateway.providers import (
|
||||||
|
ProviderProfile,
|
||||||
|
ThinkingCapability,
|
||||||
|
ThinkingUnsupportedError,
|
||||||
|
get_capability,
|
||||||
|
get_provider,
|
||||||
|
resolve_thinking,
|
||||||
|
)
|
||||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||||
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
||||||
|
|
||||||
@@ -177,6 +184,25 @@ def _coerce_cached_tokens(usage: Any) -> int | None:
|
|||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_reasoning_tokens(usage: Any) -> int | None:
|
||||||
|
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。
|
||||||
|
|
||||||
|
与 `_coerce_cached_tokens` 逐条同构(两者是 OpenAI 兼容 usage 里对称的一对):
|
||||||
|
`0` 如实保留、负数与非整数归 None、`bool` 显式排除。差别只在语义——本字段
|
||||||
|
的 None 是"**本次调用**未上报"而非"该源不上报": 中转在上游不返回 usage 时
|
||||||
|
会本地补算并整体替换 usage 对象,把 details 一并吃掉(findings §4c)。
|
||||||
|
"""
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return None
|
||||||
|
details = usage.get("completion_tokens_details")
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return None
|
||||||
|
reasoning = details.get("reasoning_tokens")
|
||||||
|
if isinstance(reasoning, bool) or not isinstance(reasoning, int) or reasoning < 0:
|
||||||
|
return None
|
||||||
|
return reasoning
|
||||||
|
|
||||||
|
|
||||||
def _coerce_model_reported(value: Any) -> str | None:
|
def _coerce_model_reported(value: Any) -> str | None:
|
||||||
"""取响应体的 model 字段(issue #3);非 str 或空白串一律 None,收口时去空白。
|
"""取响应体的 model 字段(issue #3);非 str 或空白串一律 None,收口时去空白。
|
||||||
|
|
||||||
@@ -265,9 +291,14 @@ class OpenAICompatTransport:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
registry: Mapping[str, ProviderProfile] | None = None,
|
registry: Mapping[str, ProviderProfile] | None = None,
|
||||||
|
capabilities: Mapping[str, ThinkingCapability] | None = None,
|
||||||
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
|
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
|
self._capabilities = capabilities
|
||||||
|
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
|
||||||
|
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
|
||||||
|
self._warned_models: set[str] = 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] = {}
|
||||||
|
|
||||||
@@ -290,10 +321,20 @@ class OpenAICompatTransport:
|
|||||||
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 帧(三项目同款)
|
||||||
if source.enable_thinking is True:
|
# 形态(provider 级)与能力(model 级)在此相遇;不可满足时 ValueError,
|
||||||
payload.update(profile.thinking_on)
|
# 由 complete() 翻译为四分类之一(issue #5)
|
||||||
elif source.enable_thinking is False:
|
capability = get_capability(source.model, table=self._capabilities)
|
||||||
payload.update(profile.thinking_off)
|
first_time = source.model not in self._warned_models
|
||||||
|
self._warned_models.add(source.model)
|
||||||
|
payload.update(
|
||||||
|
resolve_thinking(
|
||||||
|
profile,
|
||||||
|
capability,
|
||||||
|
source.enable_thinking,
|
||||||
|
model=source.model,
|
||||||
|
warn_unregistered=first_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
|
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
|
||||||
# overlay(含结构化注入)在后覆盖之。两行不可调换
|
# overlay(含结构化注入)在后覆盖之。两行不可调换
|
||||||
payload.update(source.extra_body)
|
payload.update(source.extra_body)
|
||||||
@@ -311,9 +352,18 @@ class OpenAICompatTransport:
|
|||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。"""
|
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。"""
|
||||||
profile = get_provider(source.provider, registry=self._registry)
|
profile = get_provider(source.provider, registry=self._registry)
|
||||||
payload = self._build_payload(
|
try:
|
||||||
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
|
payload = self._build_payload(
|
||||||
)
|
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
|
||||||
|
)
|
||||||
|
except ThinkingUnsupportedError as exc:
|
||||||
|
# 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个
|
||||||
|
# 专用类型而非宽 catch ValueError —— 后者会把序列化等无关错误误贴标签
|
||||||
|
raise RequestRejectedError(
|
||||||
|
f"{source.name} 推理开关无法满足: {exc}",
|
||||||
|
source_name=source.name,
|
||||||
|
operation="chat",
|
||||||
|
) from exc
|
||||||
url = source.base_url.rstrip("/") + "/chat/completions"
|
url = source.base_url.rstrip("/") + "/chat/completions"
|
||||||
client = self._client_for(source)
|
client = self._client_for(source)
|
||||||
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
||||||
@@ -400,6 +450,7 @@ 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")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_done(
|
def _check_done(
|
||||||
@@ -484,6 +535,7 @@ 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")),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ class LLMResponse:
|
|||||||
model_reported: str | None = None
|
model_reported: str | None = None
|
||||||
"""API 响应体里的 model 字段;None = 未上报。与 `model`(配置别名)可能
|
"""API 响应体里的 model 字段;None = 未上报。与 `model`(配置别名)可能
|
||||||
分叉——供应商把别名指向新权重时,实验复现必须认这个串。"""
|
分叉——供应商把别名指向新权重时,实验复现必须认这个串。"""
|
||||||
|
reasoning_tokens: int | None = None
|
||||||
|
"""推理消耗的输出 token 数(含在 `completion_tokens` 内,故不影响成本总额,
|
||||||
|
只补归因;issue #6)。
|
||||||
|
|
||||||
|
`None` = **本次调用**未上报,**不是**"该源不上报"——中转网关在上游不返回
|
||||||
|
usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把
|
||||||
|
`completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈
|
||||||
|
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`,
|
||||||
|
故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -151,9 +160,10 @@ class TransportResult:
|
|||||||
ttft_ms: float | None
|
ttft_ms: float | None
|
||||||
max_inter_token_ms: float | None
|
max_inter_token_ms: float | None
|
||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
# —— 可观测字段(issue #3;带默认值,非 OpenAI 兼容的 transport 可不填)——
|
# —— 可观测字段(issue #3/#6;带默认值,非 OpenAI 兼容的 transport 可不填)——
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。
|
||||||
|
|
||||||
|
本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径,
|
||||||
|
验证不了"这个参数在这个模型上到底关没关掉推理"。
|
||||||
|
|
||||||
|
两条判据纪律(来自 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 dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
from polygateway import GatewayClient, GatewaySettings
|
||||||
|
from polygateway.errors import (
|
||||||
|
AllSourcesExhausted,
|
||||||
|
RequestRejectedError,
|
||||||
|
SourceDeadError,
|
||||||
|
TransientError,
|
||||||
|
)
|
||||||
|
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}
|
||||||
|
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
||||||
|
|
||||||
|
# slow: 本组 137 次真实调用、约 7 分钟,且判据是统计性的——网络抖动会让它偶发
|
||||||
|
# 失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` 会把测试
|
||||||
|
# 变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` 显式真跑并
|
||||||
|
# 存档报告。"不自动门控"不等于"可跳过"。
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.slow,
|
||||||
|
pytest.mark.skipif(
|
||||||
|
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
_OUT_DIR = Path("tests/outputs/e2e")
|
||||||
|
_ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
|
||||||
|
|
||||||
|
# 需要一点推理才能答对,但答案极短: 关掉推理时 completion 稳定在个位数,
|
||||||
|
# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里
|
||||||
|
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
|
||||||
|
|
||||||
|
_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 = {
|
||||||
|
"MiniMax-M3": "minimax",
|
||||||
|
"MiniMax-M2.7": "minimax",
|
||||||
|
"MiniMax-M2.5": "minimax",
|
||||||
|
"qwen3.7-plus": "qwen",
|
||||||
|
"deepseek-v4-pro": "deepseek",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_settings() -> GatewaySettings:
|
||||||
|
# 强制关缓存: 多轮测量要求每一轮都真的打到供应商,命中缓存会把后续轮次
|
||||||
|
# 变成对第一轮的回放,整组判据随之失效
|
||||||
|
return GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"})
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**source_overrides) -> GatewaySettings:
|
||||||
|
base = _base_settings()
|
||||||
|
source = dataclasses.replace(base.sources[0], **source_overrides)
|
||||||
|
return dataclasses.replace(base, sources=(source,))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -> list[dict]:
|
||||||
|
"""跑 N 轮真实调用,返回逐轮观测;任一轮抛错即向上冒泡由用例决定处置。"""
|
||||||
|
client = GatewayClient.from_settings(_settings(**source_overrides))
|
||||||
|
observations = []
|
||||||
|
try:
|
||||||
|
for i in range(rounds):
|
||||||
|
resp = await client.chat(
|
||||||
|
[{"role": "user", "content": _PROMPT}],
|
||||||
|
stream=stream,
|
||||||
|
# 每轮独立 salt: 即便某层缓存意外开着也不会回放
|
||||||
|
cache_salt=f"thinking-live-{i}",
|
||||||
|
)
|
||||||
|
observations.append(
|
||||||
|
{
|
||||||
|
"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:
|
||||||
|
_ROWS.append(
|
||||||
|
{
|
||||||
|
"matrix": matrix_id,
|
||||||
|
"desc": desc,
|
||||||
|
"status": status,
|
||||||
|
"detail": detail,
|
||||||
|
"observations": observations or [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_off(obs: dict) -> bool:
|
||||||
|
"""关闭方向: 只看 reasoning_tokens。
|
||||||
|
|
||||||
|
**刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做
|
||||||
|
"只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会
|
||||||
|
把这种正常波动误判成"没关掉"。
|
||||||
|
"""
|
||||||
|
return obs["reasoning_tokens"] in (None, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_on(obs: dict) -> bool:
|
||||||
|
"""开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据。
|
||||||
|
|
||||||
|
不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大
|
||||||
|
(实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理。
|
||||||
|
仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据。
|
||||||
|
"""
|
||||||
|
reasoning = obs["reasoning_tokens"]
|
||||||
|
if reasoning is not None:
|
||||||
|
return reasoning > 0
|
||||||
|
return obs["completion_tokens"] > _ON_MIN_COMPLETION
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str):
|
||||||
|
"""源不可用(渠道下线/模型未开通)→ 跳过并记为未覆盖,不伪装成通过。"""
|
||||||
|
_record(matrix_id, desc, "SKIP(源不可用)", str(exc)[:200])
|
||||||
|
pytest.skip(f"{matrix_id} 源不可用,已记为未覆盖: {str(exc)[:120]}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def _write_report():
|
||||||
|
yield
|
||||||
|
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
path = _OUT_DIR / f"test_thinking_live_{ts}.md"
|
||||||
|
lines = [
|
||||||
|
"# 推理开关与 reasoning_tokens 真实 API 验证",
|
||||||
|
"",
|
||||||
|
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:
|
||||||
|
"""M3 是唯一实测可关闭推理的 MiniMax 模型,修复的地基压在它身上。"""
|
||||||
|
|
||||||
|
async def test_l1_disable_actually_disables(self):
|
||||||
|
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=False)
|
||||||
|
offs = [o for o in obs if _reasoning_off(o)]
|
||||||
|
_record(
|
||||||
|
"L1",
|
||||||
|
"enable_thinking=False(流式)",
|
||||||
|
"PASS" if len(offs) == len(obs) else "FAIL",
|
||||||
|
f"{len(offs)}/{len(obs)} 轮确认未推理",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}"
|
||||||
|
|
||||||
|
async def test_l2_enable_actually_enables(self):
|
||||||
|
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=True)
|
||||||
|
ons = [o for o in obs if _reasoning_on(o)]
|
||||||
|
_record(
|
||||||
|
"L2",
|
||||||
|
"enable_thinking=True(流式,注入 medium)",
|
||||||
|
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
|
||||||
|
f"{len(ons)}/{len(obs)} 轮观察到推理",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(ons) * 2 > len(obs), f"开启方向要求多数轮满足: {obs}"
|
||||||
|
|
||||||
|
async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self):
|
||||||
|
"""确定性锚点: 开启档的 prompt_tokens 严格大于关闭档。
|
||||||
|
|
||||||
|
供应商在开启推理时会向模板注入推理指令,输入侧 token 数随之变大。这是
|
||||||
|
本组唯一不依赖输出侧噪声的证据,且是相对比较——不硬编码任何具体数值,
|
||||||
|
供应商改模板也不会让它假红。
|
||||||
|
"""
|
||||||
|
rounds = max(3, _ROUNDS // 3)
|
||||||
|
off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False)
|
||||||
|
on = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=True)
|
||||||
|
off_max = max(o["prompt_tokens"] for o in off)
|
||||||
|
on_min = min(o["prompt_tokens"] for o in on)
|
||||||
|
_record(
|
||||||
|
"L2b",
|
||||||
|
"关闭/开启的 prompt_tokens 可分",
|
||||||
|
"PASS" if off_max < on_min else "FAIL",
|
||||||
|
f"关闭档最大 {off_max} < 开启档最小 {on_min}",
|
||||||
|
off + on,
|
||||||
|
)
|
||||||
|
assert off_max < on_min, (
|
||||||
|
f"两档的 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): 注入可能没到达模型"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_l3_no_opinion_is_the_model_default(self):
|
||||||
|
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=None)
|
||||||
|
# M3 的默认档实测就是不推理(findings §2.1),所以不干预时也应观测不到推理。
|
||||||
|
# 注意这**不能**反过来证明关闭方向生效 —— L1 与本行同分布,区分二者的是
|
||||||
|
# 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}"
|
||||||
|
|
||||||
|
async def test_l3b_none_is_recognised_not_silently_dropped(self):
|
||||||
|
"""反证: 关闭方向的观测必须排除"参数被静默丢弃"这一伪解释。
|
||||||
|
|
||||||
|
L1(关闭)与 L3(不干预)在 M3 上**同分布**——因为 M3 默认档本就不推理。
|
||||||
|
所以 L1 单独看不能区分"`none` 真的被消费"与"`none` 被中转吞了",而后者
|
||||||
|
正是 issue #5 的原始故障形态(`enable_thinking` 就是这么被吞的)。
|
||||||
|
|
||||||
|
判别方法: 发一个**非法值**。若未知值会被静默丢弃,它的表现应与"不注入"
|
||||||
|
一致(不推理);实测它反而开启了推理,说明网关认这个键、只是不认这个值。
|
||||||
|
既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值。
|
||||||
|
"""
|
||||||
|
rounds = max(3, _ROUNDS // 3)
|
||||||
|
bogus = await _run_rounds(
|
||||||
|
rounds,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
enable_thinking=None,
|
||||||
|
extra_body={"reasoning_effort": "definitely-not-a-real-level"},
|
||||||
|
)
|
||||||
|
off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False)
|
||||||
|
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,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
enable_thinking=False,
|
||||||
|
extra_body={"reasoning_effort": "high"},
|
||||||
|
)
|
||||||
|
ons = [o for o in obs if _reasoning_on(o)]
|
||||||
|
_record(
|
||||||
|
"L4",
|
||||||
|
"extra_body 覆盖 profile 注入",
|
||||||
|
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
|
||||||
|
f"{len(ons)}/{len(obs)} 轮观察到推理(证明 high 生效而非 none)",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}"
|
||||||
|
|
||||||
|
async def test_l5_non_stream_path_matches_stream(self):
|
||||||
|
"""非流式快路径独立于流式实现,采集与注入都要各自验一遍。"""
|
||||||
|
rounds = max(3, _ROUNDS // 2)
|
||||||
|
off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False)
|
||||||
|
on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True)
|
||||||
|
offs = [o for o in off if _reasoning_off(o)]
|
||||||
|
ons = [o for o in on if _reasoning_on(o)]
|
||||||
|
ok = len(offs) == len(off) and len(ons) * 2 > len(on)
|
||||||
|
_record(
|
||||||
|
"L5",
|
||||||
|
"非流式路径重跑 L1/L2",
|
||||||
|
"PASS" if ok else "FAIL",
|
||||||
|
f"关闭 {len(offs)}/{len(off)} 轮,开启 {len(ons)}/{len(on)} 轮",
|
||||||
|
off + on,
|
||||||
|
)
|
||||||
|
assert len(offs) == len(off), f"非流式关闭方向未满足: {off}"
|
||||||
|
assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherProviders:
|
||||||
|
"""qwen / deepseek 的 profile 是既有实现,本组防的是"改 minimax 时误伤它们"。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("matrix", "provider", "model"),
|
||||||
|
[("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")],
|
||||||
|
)
|
||||||
|
async def test_existing_profiles_still_disable(self, matrix, provider, model):
|
||||||
|
desc = f"{provider} enable_thinking=False"
|
||||||
|
try:
|
||||||
|
obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=False)
|
||||||
|
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
|
||||||
|
# 只吞网关/网络类失败。**不吞 ValueError / RequestRejected** ——
|
||||||
|
# 那两类正是本次改动最可能的误伤方向,吞掉就成了纪律(c)要防的静默
|
||||||
|
_skip_if_unreachable(exc, matrix, desc)
|
||||||
|
offs = [o for o in obs if _reasoning_off(o)]
|
||||||
|
_record(
|
||||||
|
matrix,
|
||||||
|
desc,
|
||||||
|
"PASS" if len(offs) == len(obs) else "FAIL",
|
||||||
|
f"{len(offs)}/{len(obs)} 轮确认未推理",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityDrift:
|
||||||
|
"""L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。"""
|
||||||
|
|
||||||
|
def test_every_capability_has_a_provider_mapping(self):
|
||||||
|
"""能力表新增条目必须同步本测试的映射,否则该行会被静默跳过。"""
|
||||||
|
missing = sorted(set(DEFAULT_CAPABILITIES) - set(_MODEL_PROVIDER))
|
||||||
|
assert not missing, f"这些模型缺 provider 映射,L8 会漏测: {missing}"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES))
|
||||||
|
async def test_declared_capability_matches_reality(self, model):
|
||||||
|
cap = get_capability(model)
|
||||||
|
provider = _MODEL_PROVIDER[model]
|
||||||
|
rounds = max(3, _ROUNDS // 2)
|
||||||
|
desc = f"{model} 声明 can_disable={cap.can_disable}"
|
||||||
|
if not cap.can_disable:
|
||||||
|
# 声明关不掉: 装配期就该炸,炸了即与声明一致(不必真调用)
|
||||||
|
with pytest.raises(ValueError, match=model):
|
||||||
|
GatewayClient.from_settings(
|
||||||
|
_settings(provider=provider, model=model, enable_thinking=False)
|
||||||
|
)
|
||||||
|
_record("L8", desc, "PASS", "装配期按声明拒绝,与实测一致")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
obs = await _run_rounds(rounds, provider=provider, model=model, enable_thinking=False)
|
||||||
|
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
|
||||||
|
_skip_if_unreachable(exc, "L8", desc)
|
||||||
|
offs = [o for o in obs if _reasoning_off(o)]
|
||||||
|
verdict = Counter(_reasoning_off(o) for o in obs)
|
||||||
|
_record(
|
||||||
|
"L8",
|
||||||
|
desc,
|
||||||
|
"PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)",
|
||||||
|
f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(offs) == len(obs), (
|
||||||
|
f"能力表漂移: {model} 声明可关闭推理,实测未关掉 —— 请复测后更新 DEFAULT_CAPABILITIES"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssemblyGuardAgainstRealConfig:
|
||||||
|
"""L9: 纯本地,但用的是 .env 里的真实配置形态,防"守卫只在合成配置上生效"。"""
|
||||||
|
|
||||||
|
def test_l9_m27_rejected_at_assembly(self):
|
||||||
|
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||||
|
GatewayClient.from_settings(
|
||||||
|
_settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
|
||||||
|
)
|
||||||
|
_record("L9", "M2.7 + enable_thinking=False", "PASS", "装配期报错,未发出任何请求")
|
||||||
|
|
||||||
|
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:
|
||||||
|
# 装配用 None 绕过守卫,再把源换成 False 直接喂给 transport
|
||||||
|
bad = dataclasses.replace(settings.sources[0], enable_thinking=False)
|
||||||
|
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
|
||||||
|
await client._terminal._transport.complete(
|
||||||
|
messages=[{"role": "user", "content": _PROMPT}],
|
||||||
|
source=bad,
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="e2e-guard",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
_record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类")
|
||||||
@@ -43,6 +43,7 @@ _EXPECTED_COLUMNS = [
|
|||||||
"cached_prompt_tokens",
|
"cached_prompt_tokens",
|
||||||
"model_reported",
|
"model_reported",
|
||||||
"sampling",
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
]
|
]
|
||||||
|
|
||||||
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
||||||
@@ -107,6 +108,7 @@ async def _record_minimal(
|
|||||||
"cached_prompt_tokens": None,
|
"cached_prompt_tokens": None,
|
||||||
"model_reported": None,
|
"model_reported": None,
|
||||||
"sampling": None,
|
"sampling": None,
|
||||||
|
"reasoning_tokens": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import pytest
|
|||||||
from polygateway.backends.redis.breaker import RedisGate
|
from polygateway.backends.redis.breaker import RedisGate
|
||||||
from polygateway.backends.redis.limiter import RedisLimiter
|
from polygateway.backends.redis.limiter import RedisLimiter
|
||||||
from polygateway.client import GatewayClient
|
from polygateway.client import GatewayClient
|
||||||
from polygateway.errors import AllSourcesExhausted, GovernanceBackendError
|
from polygateway.errors import (
|
||||||
|
AllSourcesExhausted,
|
||||||
|
GatewayUnavailableError,
|
||||||
|
GovernanceBackendError,
|
||||||
|
)
|
||||||
from polygateway.sources import RoundRobinSelector
|
from polygateway.sources import RoundRobinSelector
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
@@ -225,7 +229,11 @@ async def test_cancel_in_flight_releases_lease(clients):
|
|||||||
|
|
||||||
|
|
||||||
async def test_redis_down_admission_fails_closed():
|
async def test_redis_down_admission_fails_closed():
|
||||||
"""Redis 不可达 → 准入侧抛 GovernanceBackendError,绝不放行(库铁律)。"""
|
"""Redis 不可达 → 准入侧报错绝不放行(库铁律),且以 scope 级形态到达调用方。
|
||||||
|
|
||||||
|
issue #7: 调用方只写 `except GatewayUnavailableError` 就该覆盖后端故障——
|
||||||
|
真实 Redis 掉线是这条链路唯一的端到端证据,故断言收紧到 scope 级语义。
|
||||||
|
"""
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
dead = aioredis.from_url(
|
dead = aioredis.from_url(
|
||||||
@@ -240,9 +248,12 @@ async def test_redis_down_admission_fails_closed():
|
|||||||
lease_ttl_s=30.0,
|
lease_ttl_s=30.0,
|
||||||
)
|
)
|
||||||
gate = RedisGate(config=_CFG, redis=dead, scope="t-dead")
|
gate = RedisGate(config=_CFG, redis=dead, scope="t-dead")
|
||||||
with pytest.raises(GovernanceBackendError):
|
for call in (limiter.try_acquire("s1", 0), gate.try_enter("s1", "w")):
|
||||||
await limiter.try_acquire("s1", 0)
|
with pytest.raises(GatewayUnavailableError) as ei:
|
||||||
with pytest.raises(GovernanceBackendError):
|
await call
|
||||||
await gate.try_enter("s1", "w")
|
assert isinstance(ei.value, GovernanceBackendError)
|
||||||
|
assert ei.value.reason == "governance_backend_down"
|
||||||
|
assert ei.value.scope == "t-dead"
|
||||||
|
assert ei.value.retry_after_s > 0
|
||||||
finally:
|
finally:
|
||||||
await dead.aclose()
|
await dead.aclose()
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import pytest
|
|||||||
|
|
||||||
from polygateway.backends.memory.breaker import InMemoryGate
|
from polygateway.backends.memory.breaker import InMemoryGate
|
||||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
from polygateway.errors import AllSourcesExhausted, GovernanceBackendError, TransientError
|
from polygateway.errors import (
|
||||||
|
AllSourcesExhausted,
|
||||||
|
GatewayUnavailableError,
|
||||||
|
GovernanceBackendError,
|
||||||
|
SourceNotConfiguredError,
|
||||||
|
TransientError,
|
||||||
|
)
|
||||||
from polygateway.middleware.retry import RetryMW, backoff_delay
|
from polygateway.middleware.retry import RetryMW, backoff_delay
|
||||||
from polygateway.sources import RoundRobinSelector, SourceCooldownMemo
|
from polygateway.sources import RoundRobinSelector, SourceCooldownMemo
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
@@ -173,17 +179,17 @@ class TestStallQuadrants:
|
|||||||
|
|
||||||
class _GateSuccessBroken(InMemoryGate):
|
class _GateSuccessBroken(InMemoryGate):
|
||||||
async def record_success(self, entry):
|
async def record_success(self, entry):
|
||||||
raise GovernanceBackendError("redis 抖动")
|
raise GovernanceBackendError("redis 抖动", scope="llm")
|
||||||
|
|
||||||
|
|
||||||
class _GateFailureBroken(InMemoryGate):
|
class _GateFailureBroken(InMemoryGate):
|
||||||
async def record_failure(self, entry, reason, force_open):
|
async def record_failure(self, entry, reason, force_open):
|
||||||
raise GovernanceBackendError("redis 抖动")
|
raise GovernanceBackendError("redis 抖动", scope="llm")
|
||||||
|
|
||||||
|
|
||||||
class _LimiterProgressBroken(InMemoryLimiter):
|
class _LimiterProgressBroken(InMemoryLimiter):
|
||||||
async def mark_progress(self):
|
async def mark_progress(self):
|
||||||
raise GovernanceBackendError("redis 抖动")
|
raise GovernanceBackendError("redis 抖动", scope="llm")
|
||||||
|
|
||||||
|
|
||||||
class TestAccountingDegradation:
|
class TestAccountingDegradation:
|
||||||
@@ -252,6 +258,71 @@ class TestQuotaGateProgressAge:
|
|||||||
async def progress_age_s(self):
|
async def progress_age_s(self):
|
||||||
raise OSError("down")
|
raise OSError("down")
|
||||||
|
|
||||||
assert await QuotaGate(_L()).progress_age_s() == 12.5
|
assert await QuotaGate(_L(), scope="llm").progress_age_s() == 12.5
|
||||||
with pytest.raises(GovernanceBackendError):
|
with pytest.raises(GovernanceBackendError):
|
||||||
await QuotaGate(_Broken()).progress_age_s()
|
await QuotaGate(_Broken(), scope="llm").progress_age_s()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnknownSourceIsAssemblyDefect:
|
||||||
|
"""未知源 = 限流后端的源名单与治理循环对不上,是装配缺陷不是后端故障。
|
||||||
|
|
||||||
|
两个后端行为必须一致(Redis 版对应用例在 `test_redis_key_layout.py::
|
||||||
|
TestConversions::test_unknown_source_rejected`);内存版此前无覆盖,
|
||||||
|
该分支从未被测过(issue #7 §3.4)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_memory_limiter_rejects_unknown_source(self):
|
||||||
|
limiter = InMemoryLimiter(
|
||||||
|
scope="llm", sources={"s1": make_source("s1")}, global_limits=_NO_GLOBAL
|
||||||
|
)
|
||||||
|
with pytest.raises(SourceNotConfiguredError) as ei:
|
||||||
|
limiter._cfg("nope")
|
||||||
|
# 关键: 若归入 scope 级家族,配置写错的任务会永远延期重投、永不进死信
|
||||||
|
assert not isinstance(ei.value, GatewayUnavailableError)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGateFailuresReachCallersAsScopeLevel:
|
||||||
|
"""三条闸门泄漏路径必须以 scope 级不可用的形态到达调用方(issue #7)。
|
||||||
|
|
||||||
|
记账路径由 `_record_quietly` 降级为 warning,但闸门路径没有那层包裹,会一路
|
||||||
|
抛给调用方。只写 `except GatewayUnavailableError` 的调用方此前接不住,后果
|
||||||
|
是 Redis 抖一下就让积压任务烧掉业务失败预算进死信——而那是运维重启即可恢复
|
||||||
|
的故障。三条路径逐一钉住,防止将来任何一条被漏掉。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_try_acquire_failure_is_scope_level(self):
|
||||||
|
from polygateway.middleware.ratelimit import QuotaGate
|
||||||
|
|
||||||
|
class _Broken:
|
||||||
|
async def try_acquire(self, name, est):
|
||||||
|
raise OSError("down")
|
||||||
|
|
||||||
|
with pytest.raises(GatewayUnavailableError) as ei:
|
||||||
|
await QuotaGate(_Broken(), scope="LLM").try_acquire(make_source("s1"))
|
||||||
|
assert ei.value.scope == "llm"
|
||||||
|
assert ei.value.reason == "governance_backend_down"
|
||||||
|
assert ei.value.retry_after_s > 0 # 0 会让积压任务零延迟冲击已挂的后端
|
||||||
|
|
||||||
|
async def test_try_enter_failure_is_scope_level(self):
|
||||||
|
from polygateway.middleware.breaker import BreakerGate
|
||||||
|
|
||||||
|
class _Broken:
|
||||||
|
async def try_enter(self, name, owner):
|
||||||
|
raise OSError("down")
|
||||||
|
|
||||||
|
with pytest.raises(GatewayUnavailableError) as ei:
|
||||||
|
await BreakerGate(_Broken(), scope="LLM").try_enter(make_source("s1"), "owner")
|
||||||
|
assert ei.value.scope == "llm"
|
||||||
|
assert ei.value.reason == "governance_backend_down"
|
||||||
|
|
||||||
|
async def test_progress_age_failure_is_scope_level(self):
|
||||||
|
from polygateway.middleware.ratelimit import QuotaGate
|
||||||
|
|
||||||
|
class _Broken:
|
||||||
|
async def progress_age_s(self):
|
||||||
|
raise OSError("down")
|
||||||
|
|
||||||
|
with pytest.raises(GatewayUnavailableError) as ei:
|
||||||
|
await QuotaGate(_Broken(), scope="LLM").progress_age_s()
|
||||||
|
assert ei.value.scope == "llm"
|
||||||
|
assert ei.value.reason == "governance_backend_down"
|
||||||
|
|||||||
@@ -202,6 +202,33 @@ class TestModelFingerprint:
|
|||||||
assert plain != tuned
|
assert plain != tuned
|
||||||
assert tuned.startswith("qwen-max|") # 旧字面量仍是前缀,便于人眼辨认
|
assert tuned.startswith("qwen-max|") # 旧字面量仍是前缀,便于人眼辨认
|
||||||
|
|
||||||
|
def test_enable_thinking_changes_fingerprint(self):
|
||||||
|
"""issue #5 配套: thinking 一旦真正改变请求体,就必须进缓存身份。
|
||||||
|
|
||||||
|
否则"关掉推理后重启"会读到开着推理时缓存的旧响应——issue #4 为
|
||||||
|
temperature 写过逐字相同的理由。
|
||||||
|
"""
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|
||||||
|
plain = build_model_fingerprint([_source()])
|
||||||
|
off = build_model_fingerprint([_source(enable_thinking=False)])
|
||||||
|
on = build_model_fingerprint([_source(enable_thinking=True)])
|
||||||
|
assert len({plain, off, on}) == 3
|
||||||
|
|
||||||
|
def test_extra_body_only_fingerprint_is_byte_identical_to_before(self):
|
||||||
|
"""只配 extra_body、不表态 thinking 的存量源不得触发冷启动。
|
||||||
|
|
||||||
|
字面量在此硬编码: 这条断言的价值全在"逐字相同",改实现时必须先看见它红。
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|
||||||
|
mark = json.dumps(["qwen-max", {"temperature": 0}], sort_keys=True, ensure_ascii=False)
|
||||||
|
expected = "qwen-max|" + hashlib.sha256(mark.encode("utf-8")).hexdigest()
|
||||||
|
assert build_model_fingerprint([_source(extra_body={"temperature": 0})]) == expected
|
||||||
|
|
||||||
def test_source_rename_does_not_change_fingerprint(self):
|
def test_source_rename_does_not_change_fingerprint(self):
|
||||||
"""指纹按 (model, extra_body) 而非源名: 改名不该误触全量冷启动。"""
|
"""指纹按 (model, extra_body) 而非源名: 改名不该误触全量冷启动。"""
|
||||||
from polygateway.client import build_model_fingerprint
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|||||||
@@ -460,6 +460,36 @@ class TestCrossFieldInvariants:
|
|||||||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||||
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
|
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
|
||||||
|
|
||||||
|
# —— 推理开关的装配守卫(issue #5)——
|
||||||
|
|
||||||
|
def _thinking_sources(self, provider, model, enable_thinking):
|
||||||
|
base = self._base()
|
||||||
|
src = dataclasses.replace(
|
||||||
|
base.sources[0], provider=provider, model=model, enable_thinking=enable_thinking
|
||||||
|
)
|
||||||
|
return dataclasses.replace(base, sources=(src,))
|
||||||
|
|
||||||
|
def test_model_that_cannot_disable_thinking_fails_at_assembly(self):
|
||||||
|
"""M2.x 关不掉推理: 配了 false 必须当场炸,而不是装出一个骗人的 client。"""
|
||||||
|
settings = self._thinking_sources("minimax", "MiniMax-M2.7", False)
|
||||||
|
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||||
|
GatewayClient.from_settings(settings)
|
||||||
|
|
||||||
|
def test_unknown_thinking_shape_fails_at_assembly(self):
|
||||||
|
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
|
||||||
|
settings = self._thinking_sources("openai", "kimi-k3", False)
|
||||||
|
with pytest.raises(ValueError, match="register_provider"):
|
||||||
|
GatewayClient.from_settings(settings)
|
||||||
|
|
||||||
|
def test_supported_combination_assembles(self):
|
||||||
|
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
|
||||||
|
assert GatewayClient.from_settings(settings) is not None
|
||||||
|
|
||||||
|
def test_not_taking_a_position_never_trips_the_guard(self):
|
||||||
|
"""enable_thinking=None(不干预)对任何 provider 都不该被守卫拦下。"""
|
||||||
|
settings = self._thinking_sources("openai", "kimi-k3", None)
|
||||||
|
assert GatewayClient.from_settings(settings) is not None
|
||||||
|
|
||||||
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
|
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
|
||||||
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
|
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
|
||||||
base = self._base()
|
base = self._base()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
|
GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
||||||
|
SCOPE_REASONS,
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
CircuitOpenError,
|
CircuitOpenError,
|
||||||
GatewayUnavailableError,
|
GatewayUnavailableError,
|
||||||
@@ -11,6 +13,7 @@ from polygateway.errors import (
|
|||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
ResultInvalidError,
|
ResultInvalidError,
|
||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
|
SourceNotConfiguredError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,6 +89,56 @@ class TestGatewayUnavailable:
|
|||||||
class TestBackendFailure:
|
class TestBackendFailure:
|
||||||
def test_governance_backend_error_is_not_transient(self):
|
def test_governance_backend_error_is_not_transient(self):
|
||||||
"""限流/熔断后端故障必须报错不放行,且不落入可重试分类。"""
|
"""限流/熔断后端故障必须报错不放行,且不落入可重试分类。"""
|
||||||
exc = GovernanceBackendError("redis down")
|
exc = GovernanceBackendError("redis down", scope="llm")
|
||||||
assert isinstance(exc, PolyGatewayError)
|
assert isinstance(exc, PolyGatewayError)
|
||||||
assert not isinstance(exc, TransientError)
|
assert not isinstance(exc, TransientError)
|
||||||
|
|
||||||
|
def test_is_scope_level_unavailability(self):
|
||||||
|
"""fail-closed 时整个 scope 一个请求都发不出去,调用方一条 except 应覆盖(issue #7)。"""
|
||||||
|
exc = GovernanceBackendError("限流后端 try_acquire 失败: boom", scope="LLM")
|
||||||
|
assert isinstance(exc, GatewayUnavailableError)
|
||||||
|
assert exc.reason == "governance_backend_down"
|
||||||
|
assert exc.scope == "llm" # 与既有 scope 级异常同款: 归一化小写
|
||||||
|
assert exc.retry_after_s == GOVERNANCE_BACKEND_RETRY_AFTER_S
|
||||||
|
|
||||||
|
def test_diagnostic_message_survives_reparenting(self):
|
||||||
|
"""父类把 message 覆写为模板串,而各构造点的诊断串是排障主线索(§3.5)。"""
|
||||||
|
exc = GovernanceBackendError("熔断后端 try_enter 失败: boom", scope="llm")
|
||||||
|
assert str(exc) == "熔断后端 try_enter 失败: boom"
|
||||||
|
|
||||||
|
def test_retry_after_overridable(self):
|
||||||
|
exc = GovernanceBackendError("redis down", scope="llm", retry_after_s=30.0)
|
||||||
|
assert exc.retry_after_s == 30.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceNotConfigured:
|
||||||
|
"""装配缺陷有意留在 scope 级家族之外(issue #7 §3.4,Q1 人类拍板)。"""
|
||||||
|
|
||||||
|
def test_is_domain_error_but_not_scope_level(self):
|
||||||
|
exc = SourceNotConfiguredError("未知源 'nope'(scope=llm)")
|
||||||
|
assert isinstance(exc, PolyGatewayError)
|
||||||
|
# 关键断言: 归入可重投家族会让配置写错的任务永远重投、永不进死信
|
||||||
|
assert not isinstance(exc, GatewayUnavailableError)
|
||||||
|
|
||||||
|
def test_exported_at_package_top_level(self):
|
||||||
|
import polygateway
|
||||||
|
|
||||||
|
assert polygateway.SourceNotConfiguredError is SourceNotConfiguredError
|
||||||
|
assert "SourceNotConfiguredError" in polygateway.__all__
|
||||||
|
|
||||||
|
|
||||||
|
class TestGovernanceBackendReason:
|
||||||
|
"""新 scope 级 reason 值域(issue #7 §3.1)。"""
|
||||||
|
|
||||||
|
def test_reason_admitted_to_scope_domain(self):
|
||||||
|
assert "governance_backend_down" in SCOPE_REASONS
|
||||||
|
|
||||||
|
def test_gateway_unavailable_accepts_the_new_reason(self):
|
||||||
|
exc = AllSourcesExhausted(
|
||||||
|
scope="LLM", reason="governance_backend_down", retry_after_s=0.0
|
||||||
|
)
|
||||||
|
assert exc.reason == "governance_backend_down"
|
||||||
|
|
||||||
|
def test_retry_after_default_is_non_zero(self):
|
||||||
|
"""取 0 会让积压任务零延迟冲击已挂掉的后端(§3.2)。"""
|
||||||
|
assert GOVERNANCE_BACKEND_RETRY_AFTER_S > 0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import json
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
@@ -394,6 +395,81 @@ class TestObservabilityFields:
|
|||||||
assert set(result.raw) == {"usage"}
|
assert set(result.raw) == {"usage"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasoningTokens:
|
||||||
|
"""issue #6: 推理消耗的输出 token,与 issue #3 的 cached_tokens 对称。
|
||||||
|
|
||||||
|
实测三家供应商在"未推理"时是整个 completion_tokens_details 缺失,无人上报
|
||||||
|
0;且中转在上游不返回 usage 时会本地补算并吃掉该对象。故 None 的语义是
|
||||||
|
"本次调用未上报",不是"该源不上报"(findings §4c)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _reasoning_usage(self, reasoning):
|
||||||
|
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
|
||||||
|
|
||||||
|
async def test_stream_reads_reasoning_tokens(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(7)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens == 7
|
||||||
|
|
||||||
|
async def test_non_stream_reads_reasoning_tokens(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42"}}],
|
||||||
|
"usage": self._reasoning_usage(7),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.reasoning_tokens == 7
|
||||||
|
|
||||||
|
async def test_zero_reasoning_tokens_is_a_real_zero(self):
|
||||||
|
"""0(上报了且确实没推理)与 None(本次未上报)必须可区分。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens == 0
|
||||||
|
|
||||||
|
async def test_usage_without_details_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}])
|
||||||
|
async def test_malformed_reasoning_tokens_degrade_to_none(self, bad):
|
||||||
|
"""`True` 必须排除: Python 里 isinstance(True, int) 为真。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(bad)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
async def test_details_not_a_dict_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
usage = {**_USAGE, "completion_tokens_details": "oops"}
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=usage))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
async def test_salvage_path_records_none_not_zero(self):
|
||||||
|
"""打捞路径拿不到 usage 帧: 记 None(未知)而非 0(确定没推理)。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), done=False)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(missing_done="salvage"))
|
||||||
|
assert result.reasoning_tokens 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):
|
||||||
@@ -431,6 +507,104 @@ class TestRequestShaping:
|
|||||||
assert "enable_thinking" not in seen
|
assert "enable_thinking" not in seen
|
||||||
assert seen["stream_options"] == {"include_usage": True}
|
assert seen["stream_options"] == {"include_usage": True}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("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 = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
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=enable_thinking
|
||||||
|
)
|
||||||
|
await _complete(_transport_for(handler), source)
|
||||||
|
assert seen["reasoning_effort"] == expected
|
||||||
|
assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发
|
||||||
|
|
||||||
|
async def test_extra_body_overrides_the_profile_slot(self):
|
||||||
|
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。"""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
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):
|
||||||
|
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
|
||||||
|
|
||||||
|
裸异常会逃出 chat() —— 它不属错误四分类、TelemetryMW 也不捕,结果是一行
|
||||||
|
遥测都没有就崩了(设计 §5.1)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handler(request): # pragma: no cover - 不该走到发请求
|
||||||
|
raise AssertionError("请求不该发出")
|
||||||
|
|
||||||
|
source = _source(name="mm", provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
|
||||||
|
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
|
||||||
|
await _complete(_transport_for(handler), source)
|
||||||
|
|
||||||
|
async def test_unregistered_model_warns_only_once_per_source(self):
|
||||||
|
"""未登记模型的告警不能打在请求热路径上: 装配期已喊过,逐次再喊是刷屏。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
source = _source(name="mm", provider="minimax", model="MiniMax-M99", enable_thinking=False)
|
||||||
|
transport = _transport_for(handler)
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
await _complete(transport, source)
|
||||||
|
await _complete(transport, source)
|
||||||
|
await _complete(transport, source)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
hits = [m for m in messages if "MiniMax-M99" in m]
|
||||||
|
assert len(hits) == 1, f"三次调用应只告警一次,实得 {len(hits)} 次"
|
||||||
|
|
||||||
|
async def test_unrelated_value_error_is_not_mislabelled(self, monkeypatch):
|
||||||
|
"""只捕 ThinkingUnsupportedError: 无关的 ValueError 不该被贴成推理开关的错。
|
||||||
|
|
||||||
|
今天 `_build_payload` 里只有 resolve_thinking 会抛 ValueError,所以这条
|
||||||
|
是防御未来 —— 但正因如此才要钉住: 将来谁在那里加一处校验,宽 catch 会
|
||||||
|
把它的错误信息盖掉,而这个用例会先红。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handler(request): # pragma: no cover - 不该走到发请求
|
||||||
|
raise AssertionError("请求不该发出")
|
||||||
|
|
||||||
|
def _boom(*args, **kwargs):
|
||||||
|
raise ValueError("故意的无关错误")
|
||||||
|
|
||||||
|
monkeypatch.setattr("polygateway.transports.openai_compat.resolve_thinking", _boom)
|
||||||
|
with pytest.raises(ValueError, match="故意的无关错误") as exc:
|
||||||
|
await _complete(_transport_for(handler), _source(enable_thinking=False))
|
||||||
|
assert "推理开关" not in str(exc.value)
|
||||||
|
assert not isinstance(exc.value, RequestRejectedError)
|
||||||
|
|
||||||
|
async def test_unknown_shape_is_rejected(self):
|
||||||
|
def handler(request): # pragma: no cover - 不该走到发请求
|
||||||
|
raise AssertionError("请求不该发出")
|
||||||
|
|
||||||
|
source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False)
|
||||||
|
with pytest.raises(RequestRejectedError, match="register_provider"):
|
||||||
|
await _complete(_transport_for(handler), source)
|
||||||
|
|
||||||
async def test_overlay_merged_into_payload(self):
|
async def test_overlay_merged_into_payload(self):
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class _DummyRecorder:
|
|||||||
cached_prompt_tokens,
|
cached_prompt_tokens,
|
||||||
model_reported,
|
model_reported,
|
||||||
sampling,
|
sampling,
|
||||||
|
reasoning_tokens,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
||||||
|
|
||||||
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,
|
||||||
|
get_capability,
|
||||||
get_provider,
|
get_provider,
|
||||||
|
register_capability,
|
||||||
register_provider,
|
register_provider,
|
||||||
|
resolve_thinking,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,14 +30,21 @@ class TestDefaultProfiles:
|
|||||||
assert p.thinking_off == {"thinking": {"type": "disabled"}}
|
assert p.thinking_off == {"thinking": {"type": "disabled"}}
|
||||||
assert p.strip_think_tags is False
|
assert p.strip_think_tags is False
|
||||||
|
|
||||||
def test_openai_baseline_profile(self):
|
def test_openai_slots_are_unknown_not_empty(self):
|
||||||
|
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此),
|
||||||
|
|
||||||
|
故不能下发任何厂商方言参数。None = 形态未知 → 配了 enable_thinking 即报错,
|
||||||
|
而不是空字典那种"注入了个寂寞"的静默失效。
|
||||||
|
"""
|
||||||
p = get_provider("openai")
|
p = get_provider("openai")
|
||||||
assert p.thinking_on == {} and p.thinking_off == {}
|
assert p.thinking_on is None and p.thinking_off is None
|
||||||
assert p.strip_think_tags is False
|
assert p.strip_think_tags is False
|
||||||
|
|
||||||
def test_minimax_baseline_profile(self):
|
def test_minimax_profile_uses_reasoning_effort(self):
|
||||||
|
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。"""
|
||||||
p = get_provider("minimax")
|
p = get_provider("minimax")
|
||||||
assert p.thinking_on == {} and p.thinking_off == {}
|
assert p.thinking_off == {"reasoning_effort": "none"}
|
||||||
|
assert p.thinking_on == {"reasoning_effort": "medium"}
|
||||||
assert p.strip_think_tags is False
|
assert p.strip_think_tags is False
|
||||||
|
|
||||||
def test_unknown_provider_fails_loudly(self):
|
def test_unknown_provider_fails_loudly(self):
|
||||||
@@ -62,3 +75,83 @@ class TestPureFunctionRegistration:
|
|||||||
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")
|
||||||
|
|||||||
@@ -68,10 +68,13 @@ class TestConversions:
|
|||||||
_limiter(lease_ttl_s=0)
|
_limiter(lease_ttl_s=0)
|
||||||
|
|
||||||
def test_unknown_source_rejected(self):
|
def test_unknown_source_rejected(self):
|
||||||
from polygateway.errors import GovernanceBackendError
|
"""未知源是装配缺陷,不是后端故障(issue #7 §3.4)。"""
|
||||||
|
from polygateway.errors import GatewayUnavailableError, SourceNotConfiguredError
|
||||||
|
|
||||||
with pytest.raises(GovernanceBackendError):
|
with pytest.raises(SourceNotConfiguredError) as ei:
|
||||||
_limiter()._cfg("nope")
|
_limiter()._cfg("nope")
|
||||||
|
# 关键: 若归入 scope 级家族,配置写错的任务会永远延期重投、永不进死信
|
||||||
|
assert not isinstance(ei.value, GatewayUnavailableError)
|
||||||
|
|
||||||
|
|
||||||
class TestLuaFidelity:
|
class TestLuaFidelity:
|
||||||
|
|||||||
@@ -209,11 +209,13 @@ class TestObservabilityPassthrough:
|
|||||||
raw={},
|
raw={},
|
||||||
cached_prompt_tokens=64,
|
cached_prompt_tokens=64,
|
||||||
model_reported="MiniMax-Text-01-250321",
|
model_reported="MiniMax-Text-01-250321",
|
||||||
|
reasoning_tokens=7,
|
||||||
)
|
)
|
||||||
mw, *_ = _harness([_src("a")], [result])
|
mw, *_ = _harness([_src("a")], [result])
|
||||||
resp = await mw(_REQ)
|
resp = await mw(_REQ)
|
||||||
assert resp.cached_prompt_tokens == 64
|
assert resp.cached_prompt_tokens == 64
|
||||||
assert resp.model_reported == "MiniMax-Text-01-250321"
|
assert resp.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
assert resp.reasoning_tokens == 7
|
||||||
# model 仍是配置别名: 真实版本是旁证,不顶替溯源主字段
|
# model 仍是配置别名: 真实版本是旁证,不顶替溯源主字段
|
||||||
assert resp.model == "m"
|
assert resp.model == "m"
|
||||||
|
|
||||||
@@ -221,6 +223,7 @@ class TestObservabilityPassthrough:
|
|||||||
mw, *_ = _harness([_src("a")], [_ok()])
|
mw, *_ = _harness([_src("a")], [_ok()])
|
||||||
resp = await mw(_REQ)
|
resp = await mw(_REQ)
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
class TestRetryAndFailover:
|
class TestRetryAndFailover:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""遥测子系统测试: SQLiteRecorder(21 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
|
"""遥测子系统测试: SQLiteRecorder(22 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
@@ -39,6 +39,7 @@ _EXPECTED_COLUMNS = [
|
|||||||
"cached_prompt_tokens",
|
"cached_prompt_tokens",
|
||||||
"model_reported",
|
"model_reported",
|
||||||
"sampling",
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
|||||||
"cached_prompt_tokens": None,
|
"cached_prompt_tokens": None,
|
||||||
"model_reported": None,
|
"model_reported": None,
|
||||||
"sampling": None,
|
"sampling": None,
|
||||||
|
"reasoning_tokens": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
@@ -158,6 +160,22 @@ class TestSQLiteRecorder:
|
|||||||
assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL
|
assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL
|
||||||
assert rows["c-none"] is None
|
assert rows["c-none"] is None
|
||||||
|
|
||||||
|
async def test_reasoning_tokens_column_round_trip(self, tmp_path):
|
||||||
|
"""issue #6: 7 / 0 / None 三种值各自如实落库,0 与 NULL 不得混同。"""
|
||||||
|
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||||
|
await _record_minimal(recorder, call_id="r-some", reasoning_tokens=7)
|
||||||
|
await _record_minimal(recorder, call_id="r-zero", reasoning_tokens=0)
|
||||||
|
await _record_minimal(recorder, call_id="r-none", reasoning_tokens=None)
|
||||||
|
recorder.close()
|
||||||
|
rows = dict(
|
||||||
|
sqlite3.connect(tmp_path / "t.db")
|
||||||
|
.execute("SELECT call_id, reasoning_tokens FROM llm_calls")
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
assert rows["r-some"] == 7
|
||||||
|
assert rows["r-zero"] == 0 # 上报了且确实没推理
|
||||||
|
assert rows["r-none"] is None # 本次调用未上报
|
||||||
|
|
||||||
async def test_sampling_column_round_trips(self, tmp_path):
|
async def test_sampling_column_round_trips(self, tmp_path):
|
||||||
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
|
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||||
@@ -284,6 +302,7 @@ class TestPostgresBackfillDiscipline:
|
|||||||
"cached_prompt_tokens",
|
"cached_prompt_tokens",
|
||||||
"model_reported",
|
"model_reported",
|
||||||
"sampling",
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
]
|
]
|
||||||
|
|
||||||
def _recorder(self, conn):
|
def _recorder(self, conn):
|
||||||
@@ -384,11 +403,12 @@ class TestEmitterObservabilityFields:
|
|||||||
source=_source(),
|
source=_source(),
|
||||||
call_id="cid-1",
|
call_id="cid-1",
|
||||||
latency_ms=42,
|
latency_ms=42,
|
||||||
response=_resp(cached_prompt_tokens=64, model_reported="m-real"),
|
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
|
||||||
error=None,
|
error=None,
|
||||||
)
|
)
|
||||||
assert rec.rows[0]["cached_prompt_tokens"] == 64
|
assert rec.rows[0]["cached_prompt_tokens"] == 64
|
||||||
assert rec.rows[0]["model_reported"] == "m-real"
|
assert rec.rows[0]["model_reported"] == "m-real"
|
||||||
|
assert rec.rows[0]["reasoning_tokens"] == 7
|
||||||
|
|
||||||
async def test_failed_attempt_has_no_provider_facts(self):
|
async def test_failed_attempt_has_no_provider_facts(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
@@ -402,16 +422,19 @@ class TestEmitterObservabilityFields:
|
|||||||
)
|
)
|
||||||
assert rec.rows[0]["cached_prompt_tokens"] is None
|
assert rec.rows[0]["cached_prompt_tokens"] is None
|
||||||
assert rec.rows[0]["model_reported"] is None
|
assert rec.rows[0]["model_reported"] is None
|
||||||
|
assert rec.rows[0]["reasoning_tokens"] is None
|
||||||
|
|
||||||
async def test_cache_hit_replays_the_recorded_values(self):
|
async def test_cache_hit_replays_the_recorded_values(self):
|
||||||
"""决策 B1: 命中行原样回放,故命中率统计必须带 WHERE cache_hit = false。"""
|
"""决策 B1: 命中行原样回放,故命中率统计必须带 WHERE cache_hit = false。"""
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
await TelemetryEmitter(rec).emit_cache_hit(
|
await TelemetryEmitter(rec).emit_cache_hit(
|
||||||
request=_REQ, response=_resp(cached_prompt_tokens=64, model_reported="m-real")
|
request=_REQ,
|
||||||
|
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
|
||||||
)
|
)
|
||||||
row = rec.rows[0]
|
row = rec.rows[0]
|
||||||
assert row["cache_hit"] is True
|
assert row["cache_hit"] is True
|
||||||
assert row["cached_prompt_tokens"] == 64 and row["model_reported"] == "m-real"
|
assert row["cached_prompt_tokens"] == 64 and row["model_reported"] == "m-real"
|
||||||
|
assert row["reasoning_tokens"] == 7 # 与 cached 同口径原样回放
|
||||||
|
|
||||||
async def test_terminal_failure_records_none(self):
|
async def test_terminal_failure_records_none(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
@@ -420,6 +443,7 @@ class TestEmitterObservabilityFields:
|
|||||||
)
|
)
|
||||||
assert rec.rows[0]["cached_prompt_tokens"] is None
|
assert rec.rows[0]["cached_prompt_tokens"] is None
|
||||||
assert rec.rows[0]["model_reported"] is None
|
assert rec.rows[0]["model_reported"] is None
|
||||||
|
assert rec.rows[0]["reasoning_tokens"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestEmitterSamplingColumn:
|
class TestEmitterSamplingColumn:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class TestLLMResponse:
|
|||||||
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")
|
||||||
assert resp.cached_prompt_tokens is None
|
assert resp.cached_prompt_tokens is None
|
||||||
assert resp.model_reported is None
|
assert resp.model_reported is None
|
||||||
|
assert resp.reasoning_tokens is None # issue #6: 本次调用未上报
|
||||||
filled = LLMResponse(
|
filled = LLMResponse(
|
||||||
"c",
|
"c",
|
||||||
"t",
|
"t",
|
||||||
@@ -68,9 +69,11 @@ class TestLLMResponse:
|
|||||||
"cid",
|
"cid",
|
||||||
cached_prompt_tokens=0,
|
cached_prompt_tokens=0,
|
||||||
model_reported="MiniMax-Text-01-250321",
|
model_reported="MiniMax-Text-01-250321",
|
||||||
|
reasoning_tokens=0,
|
||||||
)
|
)
|
||||||
assert filled.cached_prompt_tokens == 0 # 真实零命中,不得与 None 混同
|
assert filled.cached_prompt_tokens == 0 # 真实零命中,不得与 None 混同
|
||||||
assert filled.model_reported == "MiniMax-Text-01-250321"
|
assert filled.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
|
||||||
|
|
||||||
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")
|
||||||
@@ -247,6 +250,7 @@ class TestAuxTypes:
|
|||||||
assert s.raw["id"] == "x"
|
assert s.raw["id"] == "x"
|
||||||
# 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
|
||||||
|
|
||||||
|
|
||||||
class TestOcrTypes:
|
class TestOcrTypes:
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""机械校验 Gitea Wiki 与源码的可比对事实(签名/导出/字段序/列清单/env 键)。
|
||||||
|
|
||||||
|
**只查机械可比对的部分**——机制语义、行为口径这类需要读懂代码才能判断的断言
|
||||||
|
不在此列(那部分靠 `解释-治理行为` 的适用性总表做单一事实源 + 人工审查)。
|
||||||
|
|
||||||
|
设计动机: 2026-08 对 wiki 做了七轮人工审查,88 条发现里有相当一部分属于
|
||||||
|
"机械可校验却写错"——`gather_bounded(coros, limit)`(实为 keyword-only 的
|
||||||
|
`concurrency`)、`LLMResponse` 字段表把 `source_name` 排进前 11 位、遥测列数
|
||||||
|
写成 20/21(实为 22 列)、`__version__` 在自称"全集"的页面缺席。这类偏差不该
|
||||||
|
靠人一轮轮追,故收敛为脚本。
|
||||||
|
|
||||||
|
用法(wiki 是独立仓库,须显式给路径;**不做 skip 静默降级**):
|
||||||
|
python3 tools/check_wiki_alignment.py --wiki /path/to/PolyGateway.wiki
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import dataclasses
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polygateway
|
||||||
|
from polygateway import EmbeddingClient, GatewayClient, LLMResponse
|
||||||
|
from polygateway.config import _SOURCE_FIELDS
|
||||||
|
from polygateway.ocr import OcrClient
|
||||||
|
from polygateway.providers import register_provider
|
||||||
|
from polygateway.telemetry.sqlite import _COLUMNS as TELEMETRY_COLUMNS
|
||||||
|
|
||||||
|
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
|
||||||
|
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
|
||||||
|
|
||||||
|
# (符号, 可调用对象) —— 这些的签名必须在 wiki 里逐参数出现
|
||||||
|
_SIGNATURE_TARGETS = [
|
||||||
|
("GatewayClient.chat", GatewayClient.chat),
|
||||||
|
("GatewayClient.from_env", GatewayClient.from_env),
|
||||||
|
("EmbeddingClient.from_env", EmbeddingClient.from_env),
|
||||||
|
("EmbeddingClient.embed", EmbeddingClient.embed),
|
||||||
|
("OcrClient.from_env", OcrClient.from_env),
|
||||||
|
("OcrClient.recognize_text", OcrClient.recognize_text),
|
||||||
|
("gather_bounded", polygateway.gather_bounded),
|
||||||
|
("register_provider", register_provider),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _wiki_text(wiki: Path) -> dict[str, str]:
|
||||||
|
"""读全部 .md;文件名(不含后缀)→ 正文。"""
|
||||||
|
pages = {p.stem: p.read_text(encoding="utf-8") for p in sorted(wiki.glob("*.md"))}
|
||||||
|
if not pages:
|
||||||
|
raise SystemExit(f"错误: {wiki} 下没有 .md 文件,路径是否指向 wiki 克隆?")
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def check_exports_documented(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""`__all__` 每一项都得在某页出现过(R1 漏 gather_bounded、R6 漏 __version__)。"""
|
||||||
|
blob = "\n".join(pages.values())
|
||||||
|
missing = [name for name in polygateway.__all__ if name not in blob]
|
||||||
|
return [f"__all__ 的 {name!r} 在全部 wiki 页面中零命中(页首自称『顶层导出全集』)"
|
||||||
|
for name in missing]
|
||||||
|
|
||||||
|
|
||||||
|
def check_signatures(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""提到某个公共可调用的那一行,必须列全它的参数名。
|
||||||
|
|
||||||
|
只查「参数名是否出现」,不查顺序与类型——后者用自然语言表述合法。
|
||||||
|
历史命中: gather_bounded 的 concurrency 被写成 limit;EmbeddingClient/
|
||||||
|
OcrClient 的 from_env 用省略号承接 chat 的关键字集合,掩盖了没有 cache=。
|
||||||
|
"""
|
||||||
|
problems = []
|
||||||
|
for label, func in _SIGNATURE_TARGETS:
|
||||||
|
symbol = label.split(".")[-1]
|
||||||
|
params = [
|
||||||
|
p.name
|
||||||
|
for p in inspect.signature(func).parameters.values()
|
||||||
|
if p.name not in ("self", "cls")
|
||||||
|
]
|
||||||
|
# 找出提到该符号的所有行,任一行列全即算通过
|
||||||
|
lines = [
|
||||||
|
line
|
||||||
|
for text in pages.values()
|
||||||
|
for line in text.splitlines()
|
||||||
|
if f"`{symbol}`" in line or f"{symbol}(" in line
|
||||||
|
]
|
||||||
|
if not lines:
|
||||||
|
problems.append(f"{label}: wiki 里找不到任何提及")
|
||||||
|
continue
|
||||||
|
best_missing: list[str] | None = None
|
||||||
|
for line in lines:
|
||||||
|
missing = [
|
||||||
|
p for p in params
|
||||||
|
if p not in line and not (_PARAM_ALIASES.get(p, set()) & set(line.split()))
|
||||||
|
]
|
||||||
|
if not missing:
|
||||||
|
best_missing = []
|
||||||
|
break
|
||||||
|
if best_missing is None or len(missing) < len(best_missing):
|
||||||
|
best_missing = missing
|
||||||
|
if best_missing:
|
||||||
|
problems.append(
|
||||||
|
f"{label}: 没有任何一行列全参数,最接近的一行仍缺 {best_missing}"
|
||||||
|
f"(实际签名 {inspect.signature(func)})"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_llmresponse_field_order(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""字段表出现顺序须与 dataclass 声明顺序一致。
|
||||||
|
|
||||||
|
R2 命中: wiki 把 source_name 与 model/provider 并成一行(位置 5),而它实为
|
||||||
|
第 12 个字段。迁移中的三项目按位置构造 fake,照 wiki 写会静默错位。
|
||||||
|
"""
|
||||||
|
page = pages.get("参考-公共API")
|
||||||
|
if page is None:
|
||||||
|
return ["缺少 参考-公共API.md"]
|
||||||
|
# 只在 LLMResponse 小节内找: 别的类型(EmbeddingResponse 等)也有同名字段,
|
||||||
|
# 全页搜索会命中它们、把顺序判断带偏
|
||||||
|
start = page.find("## LLMResponse")
|
||||||
|
if start < 0:
|
||||||
|
return ["参考-公共API.md 缺少 `## LLMResponse` 小节"]
|
||||||
|
end = page.find("\n## ", start + 1)
|
||||||
|
section = page[start : end if end > 0 else len(page)]
|
||||||
|
declared = [f.name for f in dataclasses.fields(LLMResponse)]
|
||||||
|
positions = []
|
||||||
|
for name in declared:
|
||||||
|
# 取该字段在小节内最早的出现位置(表格首列可能写成 `a / b` 合并形式)
|
||||||
|
cands = [
|
||||||
|
section.find(pat)
|
||||||
|
for pat in (f"| {name} ", f"{name} /", f"/ {name} ", f"| {name}\n")
|
||||||
|
]
|
||||||
|
hits = [i for i in cands if i >= 0]
|
||||||
|
positions.append((name, min(hits) if hits else -1))
|
||||||
|
documented = [n for n, i in positions if i >= 0]
|
||||||
|
missing = [n for n, i in positions if i < 0]
|
||||||
|
problems = [f"LLMResponse 字段 {missing} 未在 参考-公共API 的字段表出现"] if missing else []
|
||||||
|
ordered = sorted((i, n) for n, i in positions if i >= 0)
|
||||||
|
actual = [n for _, n in ordered]
|
||||||
|
expected = [n for n in declared if n in documented]
|
||||||
|
if actual != expected:
|
||||||
|
problems.append(
|
||||||
|
f"LLMResponse 字段表顺序与声明顺序不符\n"
|
||||||
|
f" wiki 顺序: {actual}\n"
|
||||||
|
f" 声明顺序: {expected}"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_telemetry_columns(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""遥测列清单与 sqlite 后端的 _COLUMNS 对齐(created_at 由 DDL 生成,单列)。"""
|
||||||
|
page = pages.get("指南-遥测与成本")
|
||||||
|
if page is None:
|
||||||
|
return ["缺少 指南-遥测与成本.md"]
|
||||||
|
expected = [*TELEMETRY_COLUMNS, "created_at"]
|
||||||
|
missing = [c for c in expected if c not in page]
|
||||||
|
problems = [f"遥测列 {missing} 未在 指南-遥测与成本 出现"] if missing else []
|
||||||
|
# 列数声明: 表 = _COLUMNS + created_at;端口 = _COLUMNS。
|
||||||
|
# 页内**所有** "N 列" 声明都必须等于真实列数——只查"正确值是否出现"会被
|
||||||
|
# 漏改的旧数字骗过(它们同时存在时检查照样通过)
|
||||||
|
table_n, port_n = len(expected), len(TELEMETRY_COLUMNS)
|
||||||
|
declared_counts = {int(m) for m in re.findall(r"(\d+)\s*列", page)}
|
||||||
|
if not declared_counts:
|
||||||
|
problems.append(f"指南-遥测与成本 未声明表列数(应为 {table_n} 列)")
|
||||||
|
elif declared_counts != {table_n}:
|
||||||
|
wrong = sorted(declared_counts - {table_n})
|
||||||
|
problems.append(
|
||||||
|
f"指南-遥测与成本 的列数声明 {wrong} 与实际 {table_n} 列不符"
|
||||||
|
f"(端口是 {port_n} 参数,两者差 created_at)"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_source_env_fields(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""`_SOURCE_FIELDS` 的每个 FIELD 段都得在 参考-配置键 出现(R1 命中 EXTRA_BODY)。"""
|
||||||
|
page = pages.get("参考-配置键")
|
||||||
|
if page is None:
|
||||||
|
return ["缺少 参考-配置键.md"]
|
||||||
|
# 用词边界匹配: `f in page` 会让 EXTRA_BODY 被 EXTRA_BODYY 蒙混过关
|
||||||
|
missing = [f for f in _SOURCE_FIELDS if not re.search(rf"\b{re.escape(f)}\b", page)]
|
||||||
|
return [f"源键 FIELD 段 {missing} 未在 参考-配置键 文档化"] if missing else []
|
||||||
|
|
||||||
|
|
||||||
|
_CHECKS = [
|
||||||
|
("顶层导出覆盖", check_exports_documented),
|
||||||
|
("公共签名参数", check_signatures),
|
||||||
|
("LLMResponse 字段序", check_llmresponse_field_order),
|
||||||
|
("遥测列清单", check_telemetry_columns),
|
||||||
|
("源 env 键覆盖", check_source_env_fields),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--wiki", required=True, type=Path, help="PolyGateway.wiki 克隆目录")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.wiki.is_dir():
|
||||||
|
raise SystemExit(f"错误: {args.wiki} 不是目录")
|
||||||
|
|
||||||
|
pages = _wiki_text(args.wiki)
|
||||||
|
failed = 0
|
||||||
|
for label, check in _CHECKS:
|
||||||
|
problems = check(pages)
|
||||||
|
if problems:
|
||||||
|
failed += len(problems)
|
||||||
|
print(f"✗ {label}")
|
||||||
|
for p in problems:
|
||||||
|
print(f" {p}")
|
||||||
|
else:
|
||||||
|
print(f"✓ {label}")
|
||||||
|
print()
|
||||||
|
if failed:
|
||||||
|
print(f"{failed} 处机械偏差 —— wiki 与源码不一致")
|
||||||
|
return 1
|
||||||
|
print(f"{len(pages)} 页机械校验通过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user