diff --git a/.env.example b/.env.example index 923d3d9..bde9761 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ # 键名清单 = M1 设计文档 §8 定稿;缺关键配置直接报错,不做默认值兜底。 # ══ 多源配置: {SCOPE}__{PROVIDER}__{N}__{FIELD} ══ -# PROVIDER 必须是注册表键(qwen/deepseek/openai,或 register_provider 注册后经 registry 传入)。 +# PROVIDER 必须是注册表键(八段: qwen/deepseek/zhipu/moonshot/minimax/openai/anthropic/google, +# 或 register_provider 注册后经 registry 传入)。 # 必填: BASE_URL / API_KEY / MODEL / TIMEOUT_S(或用平铺 LLM_TIMEOUT 作缺省)。 LLM__QWEN__1__BASE_URL= LLM__QWEN__1__API_KEY= @@ -15,7 +16,19 @@ LLM__QWEN__1__TIMEOUT_S=120 # LLM__QWEN__1__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生 # LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout # LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15 -# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭 +# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不表态 / true=要求开启 / false=要求关闭 +# 本键是 REASONING_EFFORT 的语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态 +# "要求开启"注入什么随 provider 段而定: openai/anthropic/google 三段的开启形态是 +# on_base={}——一个字节都不注入,走模型自己的默认档(该默认档若不推理,本键不会报错 +# 也不会开推理,见 CHANGELOG 1.3.3「已知限制」/ issue #21);要确保开启请配 REASONING_EFFORT +# LLM__QWEN__1__REASONING_EFFORT=low # 本源默认推理档位;缺省=不表态(随模型自己的默认档) +# 八档(封闭词汇): none | auto | minimal | low | medium | high | xhigh | max +# none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度 +# 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low), +# 不做"后者赢"的静默兜底——两个键说同一件事,矛盾就是配置错误 +# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期) +# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档) +# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧 # LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage # LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连) # LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5eade9..f32186b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,89 @@ # Changelog +## 1.3.3(2026-09-05) + +推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。 + +**版号是 patch(2026-09-05 人类指令,不因破坏性变更走 minor),但本版含五处破坏性变更与四条行为变更。** patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故全部置于最前。 + +### 请先读这一条(一):五处破坏性变更 + +| # | 位置 | 变更 | 谁会当场断 | +|---|---|---|---| +| 1 | `ThinkingCapability` | 构造签名 `can_disable: bool` → `supported_efforts: tuple[Effort, ...]` | 自建能力表的调用方(**关键字与位置两种构造都断**) | +| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 | +| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort`(25 → 26 参) | 任何自建 recorder 实现 | +| 4 | `thinking.resolve_thinking()` | 第三参数由 `bool` 换成 `Effort`,**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 直调它的读侧代码一律断 | +| 5 | `providers.ProviderProfile` | 两个字段 `thinking_on` / `thinking_off` → 单字段 `thinking: ThinkingWire` | 自建 profile 的调用方 | + +第 1 条的 `can_disable` **保留为只读派生属性**(`Effort.NONE in supported_efforts`),只读它的代码一行不用改;**构造则两种写法都断**: + +| 1.3.2 的写法 | 升级后 | +|---|---| +| `ThinkingCapability(can_disable=True, evidence="…")`(库自己那张表用的就是它) | `TypeError: ... got an unexpected keyword argument 'can_disable'` | +| `ThinkingCapability(True, "…")` | `TypeError: 'bool' object is not iterable`——断在 `__post_init__` 的去重校验里,错误信息看不出真实原因 | +| 迁移写法 | `ThinkingCapability(supported_efforts=(Effort.NONE, Effort.AUTO), evidence="…")` | + +第 2、3 条按这两个端口的既有纪律**不设默认值**:库外没有第三方实现者,带默认值只会让漏传时静默落一个默认值。第 4 条的新返回值是 `ThinkingResolution(payload, applied_effort)`——原来那个 mapping 现在是 `.payload`,多出来的 `.applied_effort` 是开了 `nearest` 映射后**真正发出去**的那一档。 + +### 请先读这一条(二):不改一行代码也会变的四条行为 + +| # | 变更 | 影响 | +|---|---|---| +| 1 | `glm-5.3` / `glm-5.3-flash` / `gemini-3.1-pro` **首次进入能力表**,且三者都登记为**关不掉推理** | **本版唯一会打断存量配置的一条。** 1.3.2 里这三个型号未登记,给它们配 `ENABLE_THINKING=false` 会按 provider 形态尽力注入并**放行**(只发一条 warning);本版在**装配期**抛 `ThinkingUnsupportedError`。并排实测:`deepseek/glm-5.3 + ENABLE_THINKING=false` 在 1.3.2 返回 `{"thinking": {"type": "disabled"}}`,在本版当场报错 | +| 2 | `openai` 段的**开启**方向由「形态未知即装配期报错」放宽为 `on_base={}` | 把任意兼容厂商挂在 `openai` 段下并配 `ENABLE_THINKING=true` 的下游:1.3.2 在装配期报错,本版放行且**一个字节都不注入**——走模型自己的默认档。若该模型默认不推理,这个配置既不报错也不开推理(见下方「已知限制」) | +| 3 | `openai` 段的**关闭**方向由「形态未知即装配期报错」放宽为 `{"reasoning_effort": "none"}` | 同上但配 `ENABLE_THINKING=false` 的下游:1.3.2 在装配期报错,本版下发这个片段。放宽的依据是 `reasoning_effort` 是 OpenAI **官方**字段而非厂商方言,经网关的兼容端点不会把它打到不认识它的厂商 | +| 4 | 缓存 key 加入 `reasoning_effort` | 只有**新配** `REASONING_EFFORT` 的源冷启动一次;只配 `ENABLE_THINKING` 或什么都没配的源,key 字面量逐字不变(已按 1.3.2 的实现逐字比对) | + +第 1 条是设计上有意为之:调用方要的是「不推理」的语义保证,给不了就必须说,而不是让它继续静默烧推理 token——升级后当场失败,正是这三个型号本来就关不掉推理的证据。报错文案带一条能立刻照做的替代(该模型最省的那一档 + 该配的 env 键名),不把人推回 `extra_body` 那条绕过库的路。 + +**`qwen` / `deepseek` / `minimax` 三段的注入形态逐字未变。** 全量比对(4 个 1.3.2 已有的 provider 段 × 25 个模型 × `ENABLE_THINKING` 三态 = 300 种组合)显示,本版与 1.3.2 的差异**只有上表第 1、2、3 条**。`minimax` 的「开」尤其值得点名:它维持 `{"reasoning_effort": "medium"}` 逐字不变,因为真实网关实测显示 MiniMax-M3 在不带任何推理参数时**不推理**(5/5 轮),把它改成「不注入即为开」会让存量 `ENABLE_THINKING=true` 的调用静默停止推理。 + +### 新增能力 + +| 新增 | 说明 | +|---|---| +| 八档 `Effort`:`none` / `auto` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max` | 封闭词汇,取四家参考实现共同收敛的那一套。`none` = 要求不推理(与「不表态」是两回事),`auto` = 要求推理但不指定强度 | +| `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT` | 源级默认档。`ENABLE_THINKING` 保留,降为它的语法糖(`true` ≡ `auto`、`false` ≡ `none`、缺省 ≡ 不表态);两键语义矛盾(如 `true` + `none`)在**装配期**报错,不做「后者赢」的静默兜底 | +| `{SCOPE}__{PROVIDER}__{N}__EFFORT_FALLBACK` | `error`(缺省,报错)或 `nearest`(映射到最近档并 warning)。默认报错的理由是钱:一次静默的 `medium → max` 在部分模型上是数倍账单 | +| `chat(reasoning_effort=...)` | 请求级覆盖,优先级高于源级;裸字符串会在入口归一 | +| `LLMResponse.applied_effort` | 本次**实际**跑在哪一档(开了 `nearest` 时与请求档分叉)。字段追加在末尾,既有字段只增不改名 | +| 四个新 provider 段 `zhipu` / `moonshot` / `anthropic` / `google` | 连同 1.3.2 已有的 `qwen` / `deepseek` / `minimax` / `openai` 共**八段**。四段都是新增,不改变任何存量配置的行为 | +| 能力表由 **5 条扩到 24 条** | 1.3.2 只登记 5 个型号,其余一律走「按 provider 形态尽力注入 + warning」。本版新登记 19 个:qwen 4 款、deepseek 2 款、GLM 6 款、kimi 2 款、gpt 2 款、claude 2 款、gemini 1 款 | +| `kimi-k3` **首次登记**为可关闭 | 它在 1.3.2 未登记(配 `false` 走尽力注入 + warning,不报错)。本版实测坐实可关:请求 `none` 后短提示词 5/5 轮 + 长上下文 3/3 轮无任何推理信号、completion 恒 9 token,与同模型 max 档(rt 33-146)的锚点可分。两源分歧由此了结——OpenRouter 的 `mandatory:false` 是对的,官方档位表没列 `none` 只是没列 | +| 包根新增导出 `Effort` / `EFFORT_ORDER` / `ThinkingWire` / `ThinkingResolution` | 深路径 import 会被内部重组打断,一律从 `polygateway` 包根取 | + +档位不支持时**报错必带可执行替代**:模型关不掉推理时,错误文案直接给出该模型最省的那一档和该配的 env 键名。只报错不给出路,下游只会退回 `extra_body`——而那正是 issue #20 的成因。 + +### 遥测:第 26 个 INSERT 字段 `reasoning_effort` + +`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27)。列可空,`NULL` 表示调用方**没表态**;它与 `'none'`(明确要求不推理)是两回事,折叠成任一档都等于替上游声称了一件它没说过的事。加这一列是为了让「不同档位是不是真有用」这类压测在数据侧能分组——此前 25 列里没有任何一列能回答「这一行跑在哪档」。 + +**成功行与失败行不是同一把尺子。** 开了 `EFFORT_FALLBACK=nearest` 的源上,成功行记的是**映射后的实发档**(读 `response.applied_effort`);失败尝试没有响应、实发档无从得知,记的是**请求档**。故 `GROUP BY reasoning_effort` 不带 `error IS NULL` 时,两种尺子会混进同一个分组。缓存命中行与终态失败行同样只记请求档——它们手上没有选中源,源级档位与 `nearest` 映射都无从谈起。embedding / OCR 两条路径没有推理语义,该列恒 `NULL`。 + +补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE`,两端 DDL 与 `COLUMNS` 同源。**manual 档的下游会看到一处文案变化**:旧表的缺列告警会多点名 `reasoning_effort` 这个维度,并附上对应的 `ALTER TABLE ADD COLUMN` 语句。 + +### 能力表口径:24 条里 17 条经 new-api 实测、7 条仍是文档推定 + +`DEFAULT_CAPABILITIES` 共 24 条,每条 `evidence` 自报家门(实测日期、轮数 N、判据、锚点,或「文档推定」及其四方出处)。**读能力表请以逐条 evidence 为准,本版不存在「能力表已全部实测」这回事。** 未能实测的 7 条与原因: + +| 模型 | 未覆盖的原因 | +|---|---| +| `claude-opus-5`、`claude-sonnet-5` | 该渠道 claude 全系返回 429「api key 7 天限额已用完」,5/5 轮失败;`none` 档还额外依赖网关把 `reasoning_effort=none` 转成 `thinking` 关闭形态,同样未经验证 | +| `gemini-3.1-pro` | 该渠道本型号上游报错(`bad_response_status_code` / `openai_error`),5/5 轮失败,连默认档基线都没取到。默认档「官方文档说 high、OpenRouter 说 medium」两源打架**仍未决**,本版不选边 | +| `gpt-5.4` | 全账号限流(429 All available accounts are currently rate-limited),5/5 轮失败。同代的 `gpt-5.5` 已实测且与清单逐字相符,可作旁证但不是本型号的证据 | +| `glm-5`、`glm-5.1`、`glm-5.2` | 请求这三个型号时,渠道 5/5 轮把流量路由到 `glm-5.3`(issue #20 记录的 6/6 复现);拿到的行为不属于本型号,整组数据作废 | + +`glm-5.2` 的下游风险要单独说:在这条渠道上给它配 `none`,库会照文档推定放行,而真正服务请求的 `glm-5.3` **关不掉推理**;运行期对账会喊,但那是事后。 + +另有两条与实测相关的收获值得下游知道:同一批实测发现 `zhipu` / `moonshot` 这条渠道**不校验档位值**(未登记的 `medium` 也照单收下并返回 200),故「网关没报错」在这两家上**不构成**「该档受支持」的证据;而 `openai` 那条会校验(清单外的 `max` / `minimal` 被上游 400 拒)。 + +### 已知限制:`auto` 不等于「强制开推理」(issue #21) + +`reasoning_effort=auto`(含它的语法糖 `ENABLE_THINKING=true`)在 `on_base={}` 的三个 provider 段(`openai` / `anthropic` / `google`)上表达的是「**用模型自己的默认档**」,库不注入任何字节。若某模型默认就不推理,这个配置**既不报错也不开推理**。正解是让 `auto` 受能力表约束——模型不支持「由模型自定」时报错并指路显式档位,属公共行为变更,留到下一版(gitea issue #21)。 + +与之相连有一处**刻意的不一致**,请勿误读:`DEFAULT_CAPABILITIES` 里 `MiniMax-M3` 的 `supported_efforts` **不含 `auto`**(实测结论——它的默认档不推理),而 `minimax` 段的 wire 会为 `auto` 注入 `{"reasoning_effort":"medium"}` 并被放行。`resolve_thinking` 的 Phase 5 对 `auto` 无条件放行(`auto` 不是写进 `effort_key` 的取值,而是「不写 `effort_key`」),**能力表拦不住这条路**;当前是由 wire 侧的权宜之计兜住的。别把它读成「能力表能挡住 auto」。 + ## 1.3.2(2026-08-28) **本版不改库代码。** `tools/` 与 `tests/` 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 **除版本号外没有任何差异**(`__version__` 与包元数据是唯一的改动)。升级它不会改变任何库行为——本版的内容是运维脚本 `tools/telemetry_retention.py` 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。 diff --git a/README.md b/README.md index 217ea89..26a83ad 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ | 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) | | 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 | | 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 | -| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | +| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | -| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;请求方向与实测观测矛盾时按 `(模型, 方向)` 各告警一次(能力表过期、开启未生效、注入了却观测不到);裁定结果随遥测落库 | -| 遥测与成本 | 每次调用(含缓存命中与失败)必录 25 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | +| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 | +| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 | +| 遥测与成本 | 每次调用(含缓存命中与失败)必录 26 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | | 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 | | 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** | | 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` | @@ -402,7 +403,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应** | 键形态 | 作用 | |---|---| -| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) | +| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/REASONING_EFFORT/EFFORT_FALLBACK/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) | | `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) | | `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) | | `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) | diff --git a/pyproject.toml b/pyproject.toml index 5b876a4..5f0c1cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polygateway" -version = "1.3.2" +version = "1.3.3" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index 1ac83da..c888a8b 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -372,7 +372,7 @@ flowchart TB | `cache_hit` | bool | 是否缓存命中 | | `call_id` | str | UUID,每次**尝试**独立 | -新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)、`thinking_observation`(2026-08-25,issue #16/#17,见下)。 +新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)、`thinking_observation`(2026-08-25,issue #16/#17,见下)、`applied_effort`(2026-09-05,issue #20,见 §7.5/§7.8 与下文推理档位段:本次**实际**跑在哪一档,`nearest` 映射后与请求档分叉,`None` = 调用方不表态或该路径无推理语义)。 **可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**: @@ -395,7 +395,7 @@ flowchart TB 判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。 -**声明 × 观测对账(同批)**: `reconcile_thinking` 把请求方向(`enable_thinking`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。`False × unknown` 与 `None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, direction)` 集合,与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。 +**声明 × 观测对账(同批;判据 2026-09-05 由布尔改档位,issue #20)**: `reconcile_thinking` 把本次**实际发出去的档位**(`effort: Effort | None`,即 `TransportResult.applied_effort`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。判据必须写成 `effort is Effort.NONE` 的**身份比较**才落「要求关闭」一支,其余任何档(含 `auto`)落「要求开启」一支——`Effort.NONE` 的取值是非空串 `"none"`,任何靠真值性的写法(`if not effort`)恒为假,会把每个强度档送进关闭分支、告警方向整个颠倒。`none × unknown` 与 `None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, 生效档位)` 集合(第三段 2026-09-05 由 `enable_thinking` 改为**实际发出的档**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音;而档位根本不经过 `enable_thinking` 那个字段,不改就是同一模型的所有档共用一个键),与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。 这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),成本是一次枚举比较。但**保障只覆盖可观测路径**: M3 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。 @@ -524,12 +524,14 @@ flowchart TB ### 7.5 响应缓存 -**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`。 +**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`。 - `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。 - `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。 - `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。 - `sampling`(2026-07-31,issue #4): 调用级采样参数,**仅非空时参与**(注意与 `salt` 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无差别),故空 overlay 时旧键逐字不变、存量缓存不冷启动。读 `request.sampling` 而非 `request.overlay`,不依赖"CacheMW 恰在 StructuredMW 外侧"的层序巧合。**不进 key 的后果**: 同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错——受控实验静默作废。源级 `extra_body` 同理并入 `model_fingerprint`(全源皆空时字面量不变,否则追加 `|sha256(...)`,摘要对象是各源 `(model, extra_body)` 的 canonical JSON 排序去重——按模型而非源名,改源名不误触冷启动)。 +- `reasoning_effort`(2026-09-05,issue #20): **请求级**档位,仅 `is not None` 时参与(判据不能用真值性——`Effort.NONE` 是「明确要求不推理」,与 `None`「不表态」拿到的是两种响应,合并即毒化)。它不能靠 `model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,同一个 client 上跑 low 与 max 在它眼里毫无分别,不进 key 就是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。**源级** `reasoning_effort` 则与 `enable_thinking` 同规则并入 `_fingerprint_mark`(仅表态时追加,故全源不表态时字面量逐字不变、存量缓存不冷启动;两者取值域不相交,`"none"`/`"low"`… vs `true`/`false`,追进同一个列表不会摘要成同一身份)。 +- **key 记的是请求档,不是 `nearest` 映射后的生效档**: `CacheMW` 在洋葱里比 transport 更外一层,查缓存时 `resolve_thinking` 尚未执行,生效档根本拿不到。副作用是被映射到同一档的两个请求各占一个缓存槽(存两份相同响应,浪费但不毒化)。**由此的已知边界**: 能力表更新导致映射结果变化时(如某模型新增 `minimal` 档),请求档算出的 key 不变而实际发出的字节变了,会命中按旧映射存下的响应——能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace` 或 `cache_salt`。 - **两条已知副作用**: ① 逐 rollout 变化的 `seed` 进 key 后该路径天然全部 miss(正确语义,但缓存对它不再省钱);② `model_fingerprint` 是**集合级**指纹而非本次选中源的指纹,同 scope 各源 `extra_body` 不同时仍可能返回另一源的响应(既有取舍的延续,与 `model` 同),要求逐源可复现应让每源独享 scope 或 namespace。 - value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。 @@ -539,7 +541,7 @@ flowchart TB ### 7.7 多源与选源 -`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。 +`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`reasoning_effort` 与 `effort_fallback`(2026-09-05 issue #20: 前者是本源默认推理档位,`None` = 不表态、`Effort.NONE` = 要求不推理,构造期与 `enable_thinking` 语义矛盾即 `ValueError`;后者取 `error`(缺省)或 `nearest`,决定请求档打空时报错还是映射到最近档)/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。 **TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。 @@ -551,7 +553,7 @@ flowchart TB ### 7.8 遥测与成本 -**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**。 +**必录字段**(继承三项目 15 字段规范;当前 26 个 INSERT 字段,物理表列 27 = 26 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**。 **`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。 @@ -559,6 +561,8 @@ flowchart TB **`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。 +**`reasoning_effort` 列(2026-09-05,issue #20,端口 25 → 26)**: 记本次调用**生效的推理档位**,`TEXT` 可空——`NULL`(不表态,或档位取值不在本版词汇内而降级)与 `'none'`(明确要求不推理)是两回事,折叠成任一档等于替上游声称一件它没说过的事。加这一列的理由是分组能力: 此前 25 列里没有任何一列能回答「这一行跑在哪档」,「不同档位是不是真有用」的压测在数据侧无从下手。**三个 emit 入口的口径必须各自定死**(与 `sampling` 列同一先例): `emit_attempt` 成功行读 `response.applied_effort`(即 `nearest` 映射后**真正发出去**的那一档)且**绝不重算**——重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出过的分组下,而这两个值在没开映射的源上恒等,该错误在本地跑不出来;失败尝试没有响应,退回请求档(`effective_effort` 三层优先级,不是裸读字段——`enable_thinking` 也是一次表态)。故**开了 `nearest` 的源上,成功行与失败行不是同一把尺子**,`GROUP BY reasoning_effort` 须带 `error IS NULL`。`emit_cache_hit` / `emit_terminal_failure` 手上没有选中源,只记请求档。embedding / OCR 路径由 `reasoning_applies=False` 显式声明「本路径无推理语义」,该列恒 NULL——这个布尔**不设默认值也不由 emitter 推断**: 三条路径共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源会让回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的档。 + **`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。 **recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter` 的 `_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value`。`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 backfill 同步。 diff --git a/research-wiki/designs/2026-09-04-reasoning-effort-design.md b/research-wiki/designs/2026-09-04-reasoning-effort-design.md new file mode 100644 index 0000000..7e3fce9 --- /dev/null +++ b/research-wiki/designs/2026-09-04-reasoning-effort-design.md @@ -0,0 +1,355 @@ +# 推理档位一等化设计(issue #20 及其一般形式) + +- **日期**: 2026-09-04 +- **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门) +- **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过 +- **影响面**: `SourceConfig`/`ChatRequest` 公共类型、`ProviderProfile`/`ThinkingCapability` 公共类型、`resolve_thinking`/`reconcile_thinking` 公共函数、缓存 key 公式(ARCH §7.5)、遥测端口(25 → 26 字段)、`.env` 键 +- **人类拍板(2026-09-04)**: 作用域取「源级默认 + 请求级覆盖」;档位不支持时「默认报错、可显式开映射」;不可关闭时「报错并给可执行替代」 +- **人类复核(2026-09-04,针对 Codex 异议)**: `effort_fallback` 的最近档映射**要实现**,不因当前无已知消费者而推迟;方案选择标准 = 架构可维护性/清晰度 > 代码简洁 > 鲁棒性 + +--- + +## 1. 问题不是 issue #20 说的那个 + +issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解决它自己描述的失败,因为二态 bool 在新一代模型上无档可填。 + +2026-09-04 调研,四份独立注册表(cherry-studio 客户端注册表、OpenRouter `/models` 的 `reasoning` 字段、LiteLLM 模型元数据、我们自己的网关 new-api `relaykit/relayconvert/reasoning/`)与六家官方文档,三条结论直接推翻 issue #20 的建议: + +| # | 结论 | 证据 | +|---|---|---| +| 1 | **GLM-5.3 官方强制推理**,`thinking.type` 只接受 `enabled`;官方档位 `low/high/max`,`none` **不是**它的档位 | 智谱官方文档;cherry `toggle:false`;OpenRouter `mandatory:true` 三源一致 | +| 2 | **`medium` 只在 GPT-5.x / Claude 5 / Gemini 3 三家存在** | 见 §8 档位表 | +| 3 | 不可关闭不是孤例: GLM-5.3 系、Gemini 3 Pro / 3.1 Pro 为 mandatory;MiniMax M2.x **接受 `disabled` 但不生效** | 官方文档;与本库 2026-08-02 实测一致 | + +第 1 条意味着 issue #20 建议的 `can_disable=True` 不能登记:我们发出去的 `reasoning_effort:"none"` 是个**未定义值**,智谱按自己的方式处理(多半当最低档)。这正好解释 issue #20 自己观测到的「短提示词 rt≈1.2,5552 token 长上下文跳到 0/54/167」——低档本来就要想,只是短提示词下想得少。 + +第 2 条意味着现有 `minimax` profile 那条「`thinking_on` 统一取 medium」的约定,推广到 GLM/kimi/deepseek 上全部是空档。 + +**真实缺口**: `enable_thinking: bool | None` 这个类型表达不了现实。补数据不能修复类型。 + +## 2. 现状审计(旧行为逐条处置) + +替换 `thinking.py` 的请求侧决策,响应侧与对账基本保留。逐条声明: + +| # | 现有行为 | 处置 | +|---|---|---| +| 1 | `enable_thinking` 三态: None 不注入 / True 注入 on / False 注入 off | **保留**语义,降为 `reasoning_effort` 的语法糖(§4.2) | +| 2 | `ProviderProfile.thinking_on/off` 两个固定片段,`None`=形态未知 | **替换**为 `ThinkingWire`(§3.3);`None`=未知的语义**保留**。**判据须按请求档位取相关字段**(旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此)——初稿 §4.1 Phase 2 写成「只看 `on_base`」是错的: 那会让「关闭形态已知、开启形态未知」的自定义 provider 在请求 `none` 时被误拒,且指路指向它已经做过的 `register_provider`,比不指更糟(2026-09-05 独立验证查出) | +| 3 | `ThinkingCapability.can_disable: bool` | **替换**为 `supported_efforts`;`can_disable` 成为 `'none' in supported_efforts` 的派生(§3.2) | +| 4 | `evidence: str` 强制附实测出处 | **保留**,且强化: 初始表全部标注「文档推定,待实测」 | +| 5 | `resolve_thinking` 四道关卡(不表态/形态未知/能力未登记/不可关闭) | **保留四关的顺序与语义**,判据从 bool 换成档位(§4.1) | +| 6 | 能力未登记 → warning 后尽力注入 | **保留**(新模型不该被库挡住,ARCH §5 R4) | +| 7 | `observe_thinking` 多信号裁定三态 | **保留**,不改一行 | +| 8 | `reconcile_thinking` 声明 × 观测对账,矛盾返回文案、不抛错 | **保留**,判据扩展到档位(§4.3) | +| 9 | transport 按 `(source, model, direction)` 节流告警 | **替换**: 节流键的 `direction` 换成生效档位——同一模型 low 与 max 是两个独立的矛盾 | +| 10 | `ThinkingUnsupportedError(ValueError)`,由 transport 翻译为 `RequestRejectedError` | **保留**,新增的档位错误走同一条路 | +| 11 | `_build_payload` 中 `resolve_thinking` 结果先于 `extra_body`/`overlay` | **保留**(顺序即优先级,issue #4 决策 A) | +| 12 | 缓存 key 不含任何推理参数 | **修复**(§5,现存缺口) | +| 13 | 遥测无档位列 | **新增**一列(§6) | + +**有意放弃**: 无。第 3 条的 `can_disable` 是唯一的破坏性变更,迁移见 §12。 + +## 3. 数据模型 + +### 3.1 档位词汇 + +八档封闭枚举,取四家参考实现共同收敛的词汇(cherry / OpenRouter / LiteLLM / new-api 用的是同一套): + +```python +class Effort(StrEnum): + NONE = "none"; AUTO = "auto" # 不推理 / 推理但档位由模型自定 + MINIMAL = "minimal"; LOW = "low"; MEDIUM = "medium" + HIGH = "high"; XHIGH = "xhigh"; MAX = "max" +``` + +`none` 即「不推理」,与强度档同处一个词汇表——这是关键的表达力来源: 「能不能关」不再是独立的布尔,而是 `none` 在不在该模型的支持列表里。 + +`auto` 不可省(自审补): newapi 上 26 个模型里有 9 个是**纯开关型**(qwen 五个、MiniMax-M3、glm-5/5.1/4.6v),它们能开推理但没有档位名可填。没有 `auto` 就只能拿某个强度档冒充「开」,而那正是现有 `thinking_on` 硬编码 `medium` 的病根。`auto` 的 wire = `on_base` 不附 `effort_key`,恰好等于旧的 `thinking_on` 行为。四家参考实现都有这一档(cherry 的 canonical selection `'default'|'none'|'auto'|Effort`;new-api 的 `ModeAdaptive`)。 + +`Effort` 归 `types.py`(最内层纯值类型),与 `ThinkingObservation` 同处一处,理由相同: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import。 + +### 3.2 `ThinkingCapability`: 能力(按 model) + +```python +@dataclass(frozen=True) +class ThinkingCapability: + supported_efforts: tuple[Effort, ...] # 顺序 = 由弱到强 + evidence: str +``` + +**不设 `default_effort` 字段**(Codex 审查采纳): 初稿有此字段,唯一消费者是「`enable_thinking=True` 等价于哪档」;自审把该语法糖改成 `Effort.AUTO` 后它就没有消费方了——§4 不用它决策,§6 遥测在不表态时记 `NULL`(库并不观测模型内部默认档,记推定值等于把「没看见」说成「发生了」,违既有纪律)。厂商默认档是**文档知识**,写进 `evidence` 文本即可,不必升格为必须逐模型维护的 API 字段(P1 YAGNI)。 + +三个派生量,不单独存字段(存了就会漂移): + +| 派生 | 定义 | 用途 | +|---|---|---| +| `can_disable` | `Effort.NONE in supported_efforts` | 兼容旧语义 | +| `cheapest_effort` | 除 `none` 外的第一档 | 不可关闭时的可执行替代(§4.1 Phase 5) | +| 是否档位型 | 除 `none`/`auto` 外仍有 ≥1 档 | 决定告警文案(纯开关型不该说「可选档位」) | + +OpenRouter 与 LiteLLM 两家**独立收敛到了同一形状**(`supported_efforts`+`default_effort` / `reasoning_effort_levels`+`default_reasoning_effort`),这是「档位清单即能力」这一形状可靠的旁证。我们只取其前半——两家都是**面向展示**的目录(要在 UI 上显示默认档),本库是**执行**路径,默认档不参与任何判定,故不设该字段。 + +### 3.3 `ProviderProfile`: 形态(按 provider) + +```python +@dataclass(frozen=True) +class ThinkingWire: + off: Mapping[str, Any] | None # 关闭档的片段;None = 该 provider 无关闭形态 + on_base: Mapping[str, Any] | None # 开启档的固定部分;None = 形态未知 + effort_key: str | None # 档位写进哪个键;None = 该 provider 无档位概念 +``` + +`ProviderProfile.thinking_on/thinking_off` 由 `thinking: ThinkingWire` 取代。`None` 表示「未知」这一语义原样保留(issue #5 的核心成果,不可退回)。 + +四个形态样例(经 new-api 中转的口径): + +| provider | off | on_base | effort_key | +|---|---|---|---| +| zhipu | `{"thinking":{"type":"disabled"}}` | `{"thinking":{"type":"enabled"}}` | `reasoning_effort` | +| qwen | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None`(无档位,只有 toggle) | +| openai / anthropic / google | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` | +| minimax | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` | + +### 3.4 为什么不需要 cherry 的 endpoint contract 与 wireDialect + +cherry 有两层我们**明确不做**: + +1. **endpoint-keyed 的 per-model wire 覆盖**。它需要这层,是因为同一模型在 `openai-chat` / `openai-responses` / `anthropic-messages` / `google-generate-content` 四种协议下形态不同。**本库只有一个 chat transport(`openai_compat.py`)**,所有请求都是 OpenAI 兼容形态,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent,见 `relaykit/relayconvert/reasoning/intent.go`)。一个协议 = 一层形态。 +2. **`wireDialect` 代际方言**(Claude 4.6+ `adaptive` vs ≤4.5 `budget_tokens`;Gemini 3 `thinkingLevel` vs 2.x `thinkingBudget`)。这是**原生协议**才有的问题;我们发 OpenAI 形态的 `reasoning_effort`,代际差异由网关吸收。 + +同理,`glm-5.2`(有 `none` 档)与 `glm-5.3`(无 `none` 档)**共用同一份 wire**——差别落在 capability 的 `supported_efforts` 上。本库既有的「形态按 provider、能力按 model」分层,恰好容纳档位而无需新增一层。 + +## 4. 解析 + +### 4.1 `resolve_thinking`: 五道关卡 + +判定顺序即语义。前三关是既有的,判据从 bool 换成档位;**Phase 4「可执行替代」是新增的**,Phase 5 是既有第 4 关的档位化推广。 + +| Phase | 条件 | 结果 | +|---|---|---| +| 1 | 生效档位为 `None`(调用方不表态) | 返回 `{}`,不注入 | +| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`;`none` 方向须 `off` 与 `on_base` **皆为 `None`** 才算「整体形态未知」——单 `off is None` 是「该 provider 关不掉」,归 `_inject` 说清缺的是哪半边,2026-09-05 实现时补正) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` | +| 3 | 能力未登记 | warning 后按 wire 尽力注入,**不校验档位** | +| 4 | 请求 `none` 而该模型无 `none` 档 | `ThinkingUnsupportedError`,**给出 `cheapest_effort` 作为替代** | +| 5 | 其余档位不在 `supported_efforts` 且未开映射 | `ThinkingUnsupportedError`,列出该模型可选档 | + +**4 必须先于 5**(自审补): `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就退化成「不支持 none,可选 low/high/max」——丢掉了「这个模型根本关不掉」这个关键信息与可执行替代。 + +Phase 4 的文案是本设计的一个交付物,而非装饰: + +> 模型 'glm-5.3' 无法关闭推理(官方 `thinking.type` 只接受 enabled);最省的档是 'low',请配 `LLM__ZHIPU__1__REASONING_EFFORT=low` 或调用时传 `reasoning_effort=Effort.LOW`。evidence: ... + +理由: 该分支若只报错不给出路,下游会去找 `extra_body` 那条绕过的路——**那正是 issue #20 的成因**。报错必须带可执行替代,否则等于把用户推回起点。 + +映射(Phase 5 的逃生口)默认关闭,由 `SourceConfig.effort_fallback="nearest"` 显式开启,按 `supported_efforts` 的顺序取最近档并 warning。 + +> **Codex 审查异议与人类复核**: Codex 指出本项当前无可复验的消费者,引入它要带来配置项、映射算法、warning 口径与测试面。人类 2026-09-04 复核后**确认实现**——理由是这条逃生口的价值不取决于今天有没有人用它:换模型是常态,而「换完就跑不起来」与「换完静默涨价」之间需要一个下游可以显式选择的中间档。故本项**随本期一并实现**,含映射方向、warning 口径与测试。默认关闭的理由是钱: 一次静默的 `medium→max` 在 GLM-5.3 上是数倍账单,「严禁默认值掩盖错误」(P5)在此有真金白银的含义。 + +### 4.2 生效档位的优先级 + +``` +request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语法糖) > None +``` + +`enable_thinking` **保留不删**(它已被三项目消费,迁移兼容约束见 ARCH §5.1),降级为语法糖: + +| 旧写法 | 等价于 | +|---|---| +| `enable_thinking=False` | `reasoning_effort=Effort.NONE` | +| `enable_thinking=True` | `reasoning_effort=Effort.AUTO`(注入 `on_base`,不附档位),不依赖能力表 | + +**`True` 的等价性分两种**(2026-09-04 实现时发现,更正初稿「与旧行为逐字节等价」的说法): + +| provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 | +|---|---|---|---| +| `on_base` 完整表达「开」(qwen/deepseek/zhipu/moonshot) | `{"enable_thinking": True}` 等 | 同左 | **逐字节等价** | +| 靠档位表达「开」(openai/anthropic/google) | `{"reasoning_effort": "medium"}` | `{}`(不注入) | **行为变更** | +| 同上但**默认不推理**(minimax) | `{"reasoning_effort": "medium"}` | 同左(2026-09-05 回退) | **逐字节等价** | + +第一行是**有意的**: 旧版那个 `medium` 是库替下游做的档位判断(profile 注释自己承认「取 medium 是因为它是五档里语义最接近厂商正常强度的一档」),而 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本设计要消灭的东西。语义仍是「开」(这三家的模型经 OpenRouter 登记默认即推理),只是不再强制一个档;要指定强度请显式配 `REASONING_EFFORT`。须进 CHANGELOG 的行为变更条目。 + +**第三行是 2026-09-05 的回退(issue #21,人类拍板的最小修复)**: 上述「语义仍是开」依赖「模型默认就推理」这个前提,T10 真实网关实测证明 MiniMax-M3 不满足它——不发任何推理参数时 5/5 轮不推理。故 `minimax` 段的 `on_base` 改回 `{"reasoning_effort": "medium"}`,存量 `ENABLE_THINKING=true` 的行为逐字恢复。这是权宜之计: 正解是让 `auto` 受能力表约束(模型不支持「由模型自定」时报错并指路显式档位),属公共行为变更,下一版处理。 + +**同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾**(如 `True` + `none`)→ **构造期 `ValueError`**。不做「后者赢」的静默兜底: 两个字段表达同一件事时,矛盾是配置错误,不是优先级问题。 + +### 4.3 `reconcile_thinking`: 对账扩展 + +现有对账只判「要求关闭却观测到推理」与「要求开启却未推理」。档位化后新增一类可判定的矛盾: + +- 请求 `none`、模型登记 `can_disable=True`、却观测到 `OBSERVED` → 既有文案,**保留**(这正是 issue #20 第 3 条要恢复的机制)。 +- 请求非 `none` 档、观测到 `ABSENT` → 既有文案,保留。 +- **不做**「档位高低与 `reasoning_tokens` 多少的对账」: 档位与 token 数没有可判定的函数关系(issue #20 自己的数据里 glm-5.3-flash 的 medium 档 rt 在 8~56 之间跳),拿它报警必然是噪声。这条留给 §11 的压测,不进库。 + +### 4.4 归一化不变式(实现期补,2026-09-05) + +库内一切档位判定都是 `is Effort.X` 的身份比较,故**每条能让档位进入库内的入口都必须先归一** +(`types.coerce_effort`)。裸字符串不归一的后果不是报错而是**静默判否**——`("none" is Effort.NONE)` +恒假,于是「已关闭」被当成「没表态」。 + +已知三条入口,缺一即漏: + +| # | 入口 | 归一点 | +|---|---|---| +| 1 | `.env` / `from_env()` / `from_settings()` | `config._cast` 委托 `coerce_effort` | +| 2 | 构造函数全量注入 `SourceConfig(...)` 与 `chat(reasoning_effort=...)` | `SourceConfig.__post_init__` / `chat()` 入口 | +| 3 | **缓存命中回放** `LLMResponse.applied_effort` | `CacheMW._coerce_applied_effort` | +| 4 | **公共函数 `resolve_thinking()` 直调** | 函数入口自行 `coerce_effort`(2026-09-05 独立验证查出) | + +第 3 条是 T8 加 `applied_effort` 字段时才浮现的: 响应进 Redis 走 JSON,`StrEnum` 存成裸串, +命中回放时类型已丢。与 `thinking_observation` 当年的坑**逐字相同**(见 issue #16/#17),故按同一 +先例处置: 域外取值降级为 `None` 且**不作废整条缓存**——多项目共用 Redis 时互相打缓存是老问题, +为一个可观测字段丢掉整条响应不划算。 + +第 4 条是本次换代**自己造出来的**: 该函数在 `__all__` 里,第三参数由 `bool` 换成 `Effort` 后,下游最自然的写法就是从 JSON/配置读出来的裸串 `"low"`。不归一则 `_inject` 撞 `.value` 抛 `AttributeError`——一个未文档化、不属四分类的异常。 + +新增第五条入口时(新工厂、新 transport 参数、新的反序列化路径)必须同样过 `coerce_effort`。 + +## 5. 缓存 key + +**更正一个误判(Codex 审查指出)**: 源级 `extra_body` 与 `enable_thinking` **早已进 key**——经 `build_model_fingerprint` 的 `_fingerprint_mark`(`client.py`),由 issue #4/#5 落地,ARCH §7.5 有明文。本设计**不存在**先前稿本断言的「现存毒化缺口」,那是把 `CacheMW` 只读 `request.sampling` 误当成了全部 key 来源。 + +真正需要处置的是两处,均因请求级档位而新增: + +| 层 | 处置 | 理由 | +|---|---|---| +| 源级 `reasoning_effort` | 并入 `_fingerprint_mark`,与 `enable_thinking` 同规则(**仅表态时**追加) | 与既有一致;全源不表态时指纹字面量不变,存量缓存不冷启动 | +| 请求级 `reasoning_effort` | 进 `build_cache_key`,仅非 `None` 时参与 | `model_fingerprint` 是**装配期**算的集合级指纹,覆盖不到逐调用变化的值。不进 key 则同 messages 跑 low 与 max 会互相命中——issue #4「5 个 seed 全命中同一响应」的逐字翻版 | + +**已知取舍原样延续**: ARCH §7.5 已记载 `model_fingerprint` 是**集合级**而非本次选中源的指纹,同 scope 各源配置不同时仍可能返回另一源的响应;要求逐源可复现应让每源独享 scope 或 namespace。加入 `reasoning_effort` 后该取舍不变,本设计不扩大战线去改它。 + +**冷启动代价**: 只有新配 `REASONING_EFFORT` 的源冷启动一次;存量只配 `ENABLE_THINKING` 的源字面量逐字不变。 + +**key 用请求档,不用 `nearest` 映射后的生效档**(T6 实现时定,理由在此补正): 决定性的原因是 +`CacheMW` 位于洋葱中比 transport 更外的一层,查缓存时 `resolve_thinking` 尚未执行,生效档 +**根本拿不到**。副作用是被映射到同一档的两个请求(`minimal` 与 `low` 都映射到 `low`)各占一个 +缓存槽,存两份相同响应——浪费但不毒化,可接受。 + +**由此带来一条已知边界**(与 ARCH §7.5 既有两条并列,不在本设计处理): 能力表更新导致映射结果 +变化时(如某模型新增 `minimal` 档),请求档 `minimal` 算出的 key 不变而实际发出的字节变了, +会命中按旧映射存下的响应。能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表 +与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace` 或 `cache_salt`。 + +## 6. 遥测 + +`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27;两套口径的区分见 `telemetry/schema.py` 模块 docstring)。 + +记的是**本次调用生效的档位**,不是配置值——`None`(不表态)与 `'low'` 必须能区分,故可空。 + +不加此列则你要做的压测「不同档位是不是真有用」在数据侧无法分组: 现在 25 列里没有任何一列能回答「这一行用的是哪档」。补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE` 机制,两端 DDL 与 `COLUMNS` 同源(schema.py 是单一事实源)。 + +## 7. 备选方案对比 + +| | 方案 | 改动面 | 权衡 | +|---|---|---|---| +| **A** | **最小补丁**: 只补 `zhipu` profile,`thinking_on` 填一个档,维持 bool | `providers.py` 一条 + `thinking.py` 两条 | issue #20 字面满足。但 §1 三条结论全部无解: GLM-5.3 填什么档都是错(`medium` 是空档、`none` 是未定义值);`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」之间二选一。**治标** | +| **B** | **能力表档位化 + 源级/请求级双入口**(本设计) | `types.py` 加 `Effort`、两个公共类型重构、`resolve_thinking` 加两关、缓存 key、遥测加列、`.env` 加键 | 表达力对齐现实;下游不必再走 `extra_body`;压测可按档位分组。代价是公共类型破坏性变更 + 一次缓存冷启动 | +| **C** | **照抄 cherry 的完整 wire DSL**: closed operation 集合、`effortMap`、`budgetWire`、endpoint-keyed contract | B 的全部 + 一套 wire 解释器 + per-model wire 覆盖表 | 能表达 budget 型(qwen `thinking_budget`)与原生协议代际差异。但本库只有一个 OpenAI 兼容 transport(§3.4),这层复杂度当前无消费者——**违 P1 YAGNI** | + +**推荐 B**。A 治不了 issue #20 描述的病;C 的两项额外能力(多协议 wire、token 预算)在本库当前没有消费者,等真出现 budget 型需求时,`ThinkingWire` 增一个 `budget_key` 字段即可增量抵达,不必现在就上解释器。 + +## 8. 初始能力表(全部标注「文档推定,待实测」) + +来源: 官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉。**这是待验证的假设,不是结论**——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下是三档、在 `perplexity/` 下是六档,中转会改档位有第三方证据。人类已定:能力表数据以后统一经 new-api 实测。 + +**落库规则**(Codex 审查补): 本表是**调研素材**,不是可直接转代码的表。只有 `supported_efforts` 能写成合法 `Effort` 元组的条目才进 `DEFAULT_CAPABILITIES`。分三档处置: + +| 情形 | 处置 | +|---|---| +| 档位清单与「能否关闭」皆无冲突 | 直接登记 | +| **档位清单三源一致,仅「能否关闭」存疑**(如 kimi-k3: 官方档位无 `none`,OpenRouter 却标 `mandatory:false`) | 按**保守方向**登记(不含 `none`),evidence 注明存疑点。理由: 不登记会退回 Phase 3 的「尽力注入」,下游配 `none` 时静默失效——**那正是 issue #20 的病**;保守登记则报错并给出最低档,明确且有出路 | +| 档位清单本身无该型号直接证据(`未查到`,或仅由**同系**推定如 `推定同上`) | 不登记,走 Phase 3 | + +第二档与第三档的分界是**有没有该型号自己的档位证据**,不是「关不关得掉存不存疑」: `kimi-k3` 进第二档,因为月之暗面官方文档直接写明它的三档是 `low/high/max`,只有「能否关」两源分歧;而 `gemini-3-flash`、`claude-haiku-5` 的档位清单是从同系型号(3.1-pro / opus-5)推来的,**没有该型号自己的文档**,故进第三档。Phase 3 并非静默——它会 warning 指路「实测后用 `register_capability` 登记」,且未登记模型的运行期对账文案也专门写了这一句;登记一个纯推定值反而会让下游以为库确认过。T10 实测时这三个型号优先补。`default` 列只是调研记录,按 §3.2 并入 `evidence` 文本,不进字段。 + +| 模型 | supported_efforts(推定) | 厂商默认(入 evidence) | 关? | +|---|---|---|---| +| glm-5.3, glm-5.3-flash | low, high, max | max | ✗ | +| glm-5.2 | none, high, max | max | ✓ | +| kimi-k3 | low, high, max | max | ?(OR 标可关,但官方档位无 `none`——**待实测**) | +| kimi-for-coding | 未查到 | — | ? | +| deepseek-v4-pro / -flash / -flash-vision-exp | none, high, max | high | ✓ | +| gpt-5.4, gpt-5.5 | none, low, medium, high, xhigh | medium | ✓ | +| claude-opus-5, claude-sonnet-5 | low, medium, high, xhigh, max(+`none` 经网关转 `thinking` 关闭) | high | ✓ | +| claude-haiku-5 | 推定同上 | — | ? | +| gemini-3.1-pro | low, medium, high | 官说 high / OR 说 medium(**打架**) | ✗ | +| gemini-3-flash | low, medium, high | — | ? | +| MiniMax-M3 | none, auto | auto | ✓ | +| MiniMax-M2.5, M2.7 | auto(**仅此一档**) | auto | ✗ | +| glm-5, glm-5.1, glm-4.6v | none, auto | auto | ✓ | +| qwen-plus-latest, qwen3.5-flash, qwen3.6-plus, qwen3.7-max, qwen3.7-plus | none, auto | auto | ✓ | + +三个 embedding 模型(text-embedding-v2/v4、qwen3-vl-embedding)无推理语义,不入表。 + +## 9. 非功能维度 + +| 维度 | 回答 | +|---|---| +| **并发** | 两张表仍是 `MappingProxyType` + 纯函数查找,无共享可变状态。transport 的 `_warned_models`/`_warned_mismatches` 是实例级 `set`,读写之间无 `await`,单事件循环内原子。节流键加入生效档位后基数上升(源×模型×档位),仍为有界小集合 | +| **取消** | 档位解析全部是同步纯函数,不含 `await`,不改变 `CancelledError` 的穿透路径。既有保证不受影响 | +| **降级方向** | 推理档位属**请求正确性**而非资源闸,故一律**报错不放行**(Phase 2/4/5(下同)),与「限流/熔断后端不可用须报错」同向。能力**未登记**是唯一例外——warning 后尽力注入,理由是新模型上线不该被库挡住(既有决策,保留) | +| **幂等** | 纯函数,无副作用,同输入恒同输出。重复调用安全 | +| **持久化** | 两处一次性影响: ① 缓存 key 变化 → 已配推理参数的 namespace 冷启动一次;② 遥测补列 → 走既有 `PGW_TELEMETRY_SCHEMA_MODE`,补列语句与 DDL 同源。均无部分写入风险(补列是 DDL 原子操作,缓存 miss 不损坏数据) | + +## 10. 错误处理与测试策略 + +**错误分类**: 全部落 `RequestRejectedError`(不重试、不换源、不计熔断)。理由: 档位不支持是确定性的配置/参数问题,重试与换源都不会让它变对。路径与既有一致——`thinking.py` 抛 `ThinkingUnsupportedError(ValueError)`,transport 在请求期翻译。 + +装配期 vs 运行期: 源级配置(`SourceConfig.reasoning_effort`)在**构造期**校验并报错;请求级(`ChatRequest.reasoning_effort`)只能在**运行期**校验,落 `RequestRejectedError` 上抛。 + +**测试策略**(先失败后通过,每条对应一个行为): + +| 层 | 用例 | +|---|---| +| unit | 五道关卡各自的触发与不触发;`enable_thinking` 语法糖的三种等价;矛盾配置构造期报错;`nearest` 映射的取档方向;派生量(`can_disable`/`cheapest_effort`)与 `supported_efforts` 一致 | +| unit | Phase 4 文案**含** `cheapest_effort` 与 env 键名(这是交付物,要断言内容而非只断言抛错) | +| unit | 缓存 key: 同 messages 不同档位 → key 不同;不表态时 key 与存量形状一致(回归) | +| integration | 遥测 `reasoning_effort` 列在两端(sqlite/pg)落值正确,不表态时为 NULL | +| e2e(`slow`) | 经 new-api 对 §8 表逐模型实测,校正 `supported_efforts`;标 `slow`(成败取决于外部服务当下状态) | + +## 11. 明确不做 + +1. **档位与 `reasoning_tokens` 的运行期对账**(§4.3): 无可判定的函数关系,拿它报警是噪声。 +2. **token 预算型控制**(`thinking_budget`/`budget_tokens`): qwen 系支持,但当前无下游需求;`ThinkingWire` 可增量加 `budget_key` 抵达。 +3. **原生协议 wire 与代际方言**(§3.4): 本库只有一个 OpenAI 兼容 transport。 +4. **档位对采样参数的联动**: DeepSeek 思考模式不支持 `temperature`/`top_p`,Moonshot kimi-k2.5+ 固定采样参数,传别的值 400。**本设计不代下游做参数裁剪**——这是模型的约束,应由 evidence 记录并让 400 如实抛出,库替下游删参数是「默认值掩盖错误」。记入能力表 evidence,不写进代码逻辑。 +5. **压测本身**: 「不同档位是不是真有用」是 `harness-eval` 范畴,依赖本设计的遥测列,不属于本设计。 + +## 12. 迁移与兼容 + +**破坏性变更五处**(初稿只列了第 1 条,其余四条为 2026-09-05 独立验证实测补全——照初稿写 CHANGELOG 会让下游撞上没有预告的 `TypeError`): + +| # | 位置 | 变更 | 谁会断 | +|---|---|---|---| +| 1 | `ThinkingCapability` | 构造签名 `can_disable` → `supported_efforts` | 自建能力表的调用方 | +| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 | +| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort` | 任何自建 recorder 实现 | +| 4 | `thinking.resolve_thinking()` | 第三参数换语义(`bool` → `Effort`),**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 读侧代码一律断 | +| 5 | `providers.ProviderProfile` | `thinking_on`/`thinking_off` → `thinking: ThinkingWire` | 自建 profile 的调用方 | + +五者都在包根导出面或端口面上。 + +**先更正**(Codex 审查指出): 初稿称「已核实 `reference/` 三项目无调用点,实际影响面为零」——**该结论不成立**。`reference/` 下当前**没有** GovDoc-SaaS / Video-Tree-TRM5 / CHSAnalyzer 三个目录(工作区实际只有本次调研克隆的四个开源项目),此前的 `grep` 因目录不存在而输出空,被误读成「无匹配」。 + +真实的库内调用点(可复验): + +| 位置 | 用法 | 处置 | +|---|---|---| +| `thinking.py:169` | 读 `capability.can_disable` | 改读派生属性,行为不变 | +| `tests/unit/test_thinking.py:128` | `ThinkingCapability(True, "实测")` **位置参数构造** | 随实现同步改——这是不可兼容的部分 | +| `tests/e2e/test_thinking_live.py:455` | 读 `can_disable` | 派生属性覆盖 | +| `__init__.py` | 包根导出 `ThinkingCapability`/`register_capability` | 符号名不变,构造形态变 | + +**兼容策略**: 保留 `can_disable` 为只读派生属性(`Effort.NONE in supported_efforts`),**读侧代码一律不改**;位置参数构造无法兼容,库内三处随实现同步修改。 + +**下游影响面: 推断而非核实**。三项目尚未迁移接入本库(M4 才做),`ThinkingCapability` 是 2026-08-02 才加入的库内表,下游调用它的可能性低——但工作区读不到三项目源码,这条只能是推断。**须人类在审批时确认**,或在实现计划里加一步「三项目可读时复验调用点」。版本号取 **1.3.3**(2026-09-05 人类指令,不走 minor)。 + +**非破坏**: `SourceConfig.enable_thinking` 保留,行为等价(§4.2);`.env` 的 `ENABLE_THINKING` 键保留;新增键 `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT`。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况**本来就是静默失效**,现在会明确报错并给出替代档。 + +## 13. 验收标准 + +1. 五道关卡各有先失败后通过的测试证据;Phase 5 文案内容被断言。 +2. 同 messages 不同档位不再互相命中缓存。 +3. 遥测能按档位分组(压测的前置条件)。 +4. `.env` 只配 `ENABLE_THINKING` 的存量下游行为不变(回归测试)。 +5. 进 `DEFAULT_CAPABILITIES` 的条目**仅限** §8 中无 `?`/无冲突者,每条 `evidence` 标注「文档推定,待实测」并附出处;其余条目留在设计文档里等实测,不登记。 +6. import-linter 契约不破(`Effort` 落 `types.py`,不产生反向依赖)。 diff --git a/research-wiki/designs/reasoning-effort.md b/research-wiki/designs/reasoning-effort.md new file mode 100644 index 0000000..124250e --- /dev/null +++ b/research-wiki/designs/reasoning-effort.md @@ -0,0 +1,16 @@ +--- +type: design +node_id: design:reasoning-effort +title: "推理档位一等化设计(issue #20 及其一般形式)" +date: 2026-09-05 +--- + +# 推理档位一等化设计(issue #20 及其一般形式) + +正文: `2026-09-04-reasoning-effort-design.md`。状态: **2026-09-04 人类已批准**。 + +- **选定方案**: 方案 B「能力表档位化 + 源级/请求级双入口」。`Effort` 八档封闭枚举(含 `auto`)入 `types.py`;`ThinkingCapability` 由 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`(「能不能关」= `none` 在不在列表里);`ProviderProfile` 的两个固定片段改为 `ThinkingWire(off / on_base / effort_key)`;生效档位 = 请求级 > 源级 > `enable_thinking` 语法糖。 +- **触发与真实缺口**: issue #20 字面要一条 zhipu profile,但补它不能解决它自己描述的失败——GLM-5.3 官方强制推理(智谱文档、cherry、OpenRouter 三源一致),`none` 是我们发出去的**未定义值**;而 `medium`(现 minimax profile 硬编码的档)在 GLM/kimi/deepseek 上根本不存在。缺口是**类型**表达不了现实,不是表里少一行。 +- **人类三项拍板(2026-09-04)**: 作用域「源级默认 + 请求级覆盖」;档位打空时「默认报错 + 可显式开 nearest 映射」;不可关闭时「报错并给出该模型最低档作为可执行替代」。复核 Codex 异议后追加确认: `effort_fallback` 随本期实现,不因当前无消费者而推迟。 +- **被否决备选及理由**: **方案 A 最小补丁**(只补 zhipu profile、维持 bool)——`thinking_on` 填什么档都是错的,`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」间二选一,治标;**方案 C 照抄 cherry 完整 wire DSL**(closed operations、`effortMap`、`budgetWire`、endpoint-keyed contract)——它需要那层是因为要支持四种端点协议,而本库只有一个 OpenAI 兼容 transport,跨协议转换由 new-api 服务端完成,该复杂度当前无消费者(P1 YAGNI);**`default_effort` 字段**——自审把 `enable_thinking=True` 的语法糖改成 `Effort.AUTO` 后失去唯一消费方,厂商默认档降为 `evidence` 文本;**档位与 `reasoning_tokens` 的运行期对账**——无可判定的函数关系(实测同档 rt 在 8~56 间跳),报警必成噪声;**代下游裁剪采样参数**(DeepSeek 思考模式不支持 `temperature`)——那是「默认值掩盖错误」,记入 evidence 而不写进逻辑。 +- **审查留痕**: Claude 自审揪出两处实质缺陷(词汇缺 `auto`,导致 9 个纯开关型模型无档可填、等于把要修的 bug 重新实现一遍;五关顺序错置,使「关不掉」的特殊文案被通用分支吞掉)。Codex 独立审推翻两条**错误断言**: ① 源级 `extra_body`/`enable_thinking` **早已**经 `build_model_fingerprint` 进缓存 key(ARCH §7.5 有明文),不存在先前稿本断言的「现存毒化缺口」;② 「三项目无调用点」是对**不存在的目录**做 grep 得到的空结果,`reference/` 下当前并无三项目,迁移安全性只能是推断。另采纳其三条: 删 `default_effort`、补能力表落库规则、列出库内真实会断的调用点(`tests/unit/test_thinking.py:128` 的位置参数构造)。 diff --git a/research-wiki/docs-convention.md b/research-wiki/docs-convention.md index cc7bd65..2b7b7ef 100644 --- a/research-wiki/docs-convention.md +++ b/research-wiki/docs-convention.md @@ -2,7 +2,12 @@ > **定位**: 用户文档站 = Gitea Wiki(`https://gitea.iomgaa.online/iomgaa/PolyGateway/wiki`);本文规定它的结构、更新时机与写作纪律。研发知识(设计/决策/验收)仍归 `research-wiki/`,两者职责不重叠。 -## 1. 结构:Diátaxis 四区(2026-07-23 建站,17 页) +> [!CRITICAL] +> **现状(2026-08-02 起):文档站已全量下线,当前只剩 `Home` 一页占位。** 八轮审查累计确认 93 处与源码不一致,近半落在参考区(手工镜像源码里已有的事实,必然漂移),且修正本身在引入次生偏差,逐轮修补不收敛——过期文档比没有文档更危险,它看起来权威。 +> Home 页现在做的唯一一件事是**把下游指向真实事实源**:签名/字段/参数语义 → 源码 docstring;全量环境变量键 → `.env.example`;版本变更与下游注意事项 → `CHANGELOG.md`;架构决策与行为论证 → `research-wiki/ARCHITECTURE.md`;快速上手 → `README.md`。 +> 历史内容未丢失,全在 wiki 仓库的 git 历史里(`git checkout e78bfb9 -- .`)。**本文以下各节描述的是重建时的目标结构与纪律,不是当前站点的现状**;在文档站重建之前,下面凡指向具体 wiki 页面的条目一律**不可执行**。 + +## 1. 结构:Diátaxis 四区(重建目标;2026-07-23 建站 17 页,2026-08-02 全量下线) | 区 | 页面 | 职责(读者此刻要干什么) | 禁止 | |---|---|---|---| @@ -25,6 +30,8 @@ **门**: 版本 bump 的提交不允许单独存在——同一次交付里必须包含对应的 wiki/CHANGELOG 同步(发布检查清单第一项)。 +**站点下线期间(2026-08-02 至文档站重建)本表如何执行**: 上表左列的判据照旧,右列中指向具体 wiki 页面的项**全部落空,不必也无法执行**;仍然必须做的是 `CHANGELOG.md`、`README.md`、`.env.example` 与 `research-wiki/ARCHITECTURE.md` 四处。这道门因此**没有放松**——只是承接方从 wiki 换成了这四个文件,漏改它们与从前漏改 wiki 是同一性质的失败。 + ## 3. 写作纪律 - 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 1ef34ca..82e2095 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -200,6 +200,16 @@ "id": "plan:plan-issue15-telemetry-pool-lifecycle", "label": "实现计划: 遥测连接池的资源语义与生命周期(issue #15)", "type": "plan" + }, + { + "id": "design:reasoning-effort", + "label": "推理档位一等化设计(issue #20 及其一般形式)", + "type": "design" + }, + { + "id": "plan:reasoning-effort", + "label": "实现计划: 推理档位一等化", + "type": "plan" } ], "links": [ @@ -412,6 +422,13 @@ "relation": "implements", "evidence": "9 个任务逐条实现设计 §4-§11", "added": "2026-08-26T11:28:33.469681+00:00" + }, + { + "source": "plan:reasoning-effort", + "target": "design:reasoning-effort", + "relation": "implements", + "evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定", + "added": "2026-09-05T04:07:17.723586+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 75bd795..ebf56d3 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,8 +1,8 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-26 11:28 UTC +> 自动生成,更新时间:2026-09-05 04:07 UTC -## design (39) +## design (41) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` @@ -21,6 +21,7 @@ - [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design` - [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design` - [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design` +- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` @@ -39,6 +40,7 @@ - [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe` - [推理可观测性一等化(issue #16 + #17)](designs/2026-08-25-thinking-observability-design.md) `design:2026-08-25-thinking-observability-design` - [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design` +- [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort` - [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error` - [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` @@ -59,7 +61,7 @@ - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` -## plan (34) +## plan (36) - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` @@ -75,6 +77,7 @@ - [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention` - [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode` - [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle` +- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation` - [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan` @@ -88,6 +91,7 @@ - [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan` - [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention` - [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode` +- [实现计划: 推理档位一等化](plans/reasoning-effort.md) `plan:reasoning-effort` - [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error` - [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle` - [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan` @@ -99,7 +103,7 @@ - [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review` ## schema (1) -- [表结构: llm_calls(遥测 25 字段)](schemas/llm-calls.md) `schema:llm-calls` +- [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls` ## metric (2) - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` diff --git a/research-wiki/log.md b/research-wiki/log.md index c2c3bf5..67ba932 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -146,3 +146,7 @@ - [2026-08-26 04:49 UTC] 重建索引: 88 篇页面 - [2026-08-26 11:28 UTC] 新增边: plan:2026-08-26-issue18-pg-test-isolation --implements--> design:2026-08-26-issue18-pg-test-isolation - [2026-08-26 11:28 UTC] 重建索引: 91 篇页面 +- [2026-09-05 04:06 UTC] 新增 design: 推理档位一等化设计(issue #20 及其一般形式) (design:reasoning-effort) +- [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort) +- [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort +- [2026-09-05 04:07 UTC] 重建索引: 95 篇页面 diff --git a/research-wiki/plans/2026-09-04-reasoning-effort.md b/research-wiki/plans/2026-09-04-reasoning-effort.md new file mode 100644 index 0000000..9b427ce --- /dev/null +++ b/research-wiki/plans/2026-09-04-reasoning-effort.md @@ -0,0 +1,418 @@ +# 实现计划: 推理档位一等化 + +- **设计**: `research-wiki/designs/2026-09-04-reasoning-effort-design.md`(2026-09-04 人类已批准) +- **目标**: 把 `enable_thinking: bool | None` 升级为可表达厂商档位的 `Effort` 词汇,让「关不掉的模型」「打空的档位」从静默失效变成带出路的报错。 +- **方案概述**: 新增八档封闭枚举 `Effort`(含 `auto`);能力表从 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`;provider 的两个固定片段改为 `ThinkingWire`(off / on_base / effort_key);档位入口取「源级默认 + 请求级覆盖」,进缓存 key 与遥测各一列。 +- **涉及技术**: Python 3.12 `StrEnum`、frozen dataclass、pydantic-settings env 解析、SQLite/Postgres DDL 补列、pytest。 +- **保真校验**: **不适用**。本计划实现的是库自研的推理决策(`thinking.py` 系 2026-08-25 新建),不属 ARCHITECTURE §1.4 的移植蓝本;且 `reference/` 三项目当前不在工作区(见设计 §12),无可比对源。 + +--- + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `src/polygateway/types.py` | 修改 | 新增 `Effort` 枚举;`SourceConfig`/`ChatRequest` 各加档位字段 | +| `src/polygateway/thinking.py` | 修改 | `ThinkingCapability` 重构、`resolve_thinking` 五关、`reconcile_thinking` 判据、默认能力表重写 | +| `src/polygateway/providers.py` | 修改 | `ThinkingWire` 新类型替换两个片段;`DEFAULT_PROFILES` 扩到 8 段 | +| `src/polygateway/config.py` | 修改 | 两个新 env 键的解析与矛盾校验 | +| `src/polygateway/client.py` | 修改 | `chat()` 签名加档位;`_fingerprint_mark` 纳入源级档位 | +| `src/polygateway/middleware/cache.py` | 修改 | `build_cache_key` 纳入请求级档位 | +| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 与三个 emit 入口传递生效档位 | +| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 加一参(25 → 26 字段) | +| `src/polygateway/telemetry/schema.py` | 修改 | `COLUMNS`、两端 DDL、补列声明 | +| `src/polygateway/telemetry/{sqlite,postgres}.py` | 修改 | 落库新列 | +| `src/polygateway/transports/openai_compat.py` | 修改 | 生效档位解析接线、告警节流键 | +| `src/polygateway/__init__.py` | 修改 | 导出 `Effort`、`ThinkingWire` | +| `.env.example` | 修改 | 两个新键的模板与注释 | +| `tests/unit/test_thinking.py` | 修改 | 位置参数构造迁移 + 五关用例 | +| `tests/unit/test_providers.py` | 修改 | `ThinkingWire` 用例 | +| `tests/unit/test_cache.py` | 修改 | 档位进 key 的用例 | +| `tests/unit/test_openai_compat.py` | 修改 | transport 接线与节流用例 | +| `tests/unit/test_telemetry.py`、`tests/integration/test_redis_cache.py` | 修改 | 列数断言与缓存 key 回归 | +| `tests/e2e/test_thinking_live.py` | 修改 | `can_disable` 读法迁移;新增逐模型档位实测(标 `slow`) | + +--- + +## 关键接口(跨任务消费,此处定稿) + +```python +# types.py +class Effort(StrEnum): + NONE = "none"; AUTO = "auto"; MINIMAL = "minimal"; LOW = "low" + MEDIUM = "medium"; HIGH = "high"; XHIGH = "xhigh"; MAX = "max" + +_ORDER = (Effort.NONE, Effort.MINIMAL, Effort.LOW, Effort.MEDIUM, + Effort.HIGH, Effort.XHIGH, Effort.MAX) # auto 不参与强弱序 +``` + +```python +# providers.py +@dataclass(frozen=True) +class ThinkingWire: + off: Mapping[str, Any] | None + on_base: Mapping[str, Any] | None + effort_key: str | None + +@dataclass(frozen=True) +class ProviderProfile: + name: str + thinking: ThinkingWire + strip_think_tags: bool + supports_native_schema: bool = False +``` + +```python +# thinking.py +@dataclass(frozen=True) +class ThinkingCapability: + supported_efforts: tuple[Effort, ...] + evidence: str + + @property + def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts + @property + def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档 + @property + def is_tiered(self) -> bool: ... # 除 NONE/AUTO 外仍有 ≥1 档 + +@dataclass(frozen=True) +class ThinkingResolution: + """注入片段 + **实际**生效档。 + + 返回 dataclass 而非裸 Mapping(CLAUDE.md 4.3「返回类型用 frozen dataclass」): + `nearest` 映射后请求档与实际档不同,遥测必须记后者,否则 T10 的压测按档位 + 分组时,被映射过的行会挂在一个从未真正发出的档下(Codex 审查指出)。 + """ + payload: Mapping[str, Any] + applied_effort: Effort | None # Phase 1(不表态)为 None + +def resolve_thinking( + profile: ProviderProfile, + capability: ThinkingCapability | None, + effort: Effort | None, + *, + model: str, + fallback: str = "error", # "error" | "nearest" + warn_unregistered: bool = True, +) -> ThinkingResolution: ... + +def reconcile_thinking( + *, + effort: Effort | None, + observation: ThinkingObservation, + capability: ThinkingCapability | None, + model: str, +) -> str | None: ... +``` + +```python +# types.py 字段追加(均追加在末尾,不扰动既有位置构造) +# SourceConfig: reasoning_effort: Effort | None = None +# effort_fallback: str = "error" +# ChatRequest: reasoning_effort: Effort | None = None +``` + +--- + +## Task 1 — `Effort` 词汇与能力表重构 + +**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_thinking.py`(改)、`tests/e2e/test_thinking_live.py`(改) + +**行为**: +1. `types.py` 新增 `Effort` 与 `_ORDER`(见上)。放 `types.py` 而非 `thinking.py`: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import(依赖铁律)。 +2. `ThinkingCapability` 改为 `supported_efforts` + `evidence`,加两个 `@property` 派生量。构造期校验: `supported_efforts` 非空、元素唯一、全部属 `Effort`,违反即 `ValueError`。 +3. `DEFAULT_CAPABILITIES` 按设计 §8 落库规则重写(见下表)。 +4. 迁移三处既有读点: `thinking.py` 内部读 `capability.can_disable` 改为读派生属性(行为不变);`tests/unit/test_thinking.py` 的 `ThinkingCapability(True, "实测")` 位置参数构造改为关键字构造;`tests/e2e/test_thinking_live.py` 读 `can_disable` 处确认派生属性可用。 +5. `__init__.py` 导出 `Effort`(包根导出是既有纪律: 深路径 import 正是模块重组会打断下游的原因,见 ARCH D11)。 + +**初始 `DEFAULT_CAPABILITIES`**(evidence 一律以 `2026-09-04 文档推定(来源),待经 new-api 实测` 开头): + +| model | supported_efforts | +|---|---| +| `glm-5.3`, `glm-5.3-flash` | `(LOW, HIGH, MAX)` | +| `glm-5.2` | `(NONE, HIGH, MAX)` | +| `glm-5`, `glm-5.1`, `glm-4.6v` | `(NONE, AUTO)` | +| `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | `(NONE, HIGH, MAX)` | +| `gpt-5.4`, `gpt-5.5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH)` | +| `claude-opus-5`, `claude-sonnet-5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH, MAX)` | +| `gemini-3.1-pro` | `(LOW, MEDIUM, HIGH)` | +| `kimi-k3` | `(LOW, HIGH, MAX)` —— 保守登记,evidence 注明 OpenRouter 标可关但官方档位无 `none` | +| `MiniMax-M3` | `(NONE, AUTO)` | +| `MiniMax-M2.7`, `MiniMax-M2.5` | `(AUTO,)` | +| `qwen-plus-latest`, `qwen3.5-flash`, `qwen3.6-plus`, `qwen3.7-max`, `qwen3.7-plus` | `(NONE, AUTO)` | + +`claude-haiku-5`、`gemini-3-flash`、`kimi-for-coding` **不登记**(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。 + +**验收**: `can_disable` 对 11 类模型的返回与上表一致;`cheapest_effort` 对 `(LOW, HIGH, MAX)` 返回 `LOW`、对 `(NONE, AUTO)` 返回 `AUTO`、对 `(AUTO,)` 返回 `AUTO`;`is_tiered` 对 `(LOW, HIGH, MAX)` 为真、对 `(NONE, AUTO)` 与 `(AUTO,)` 为假;空元组构造报 `ValueError`。 + +**测试**(先失败后通过): `tests/unit/test_thinking.py::test_capability_derives_can_disable`、`::test_cheapest_effort_skips_none`、`::test_is_tiered_excludes_none_and_auto`、`::test_empty_efforts_rejected`。 + +**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v` → PASS;`conda run -n PolyGateway make lint` → PASS(含 import-linter: `Effort` 落 `types.py` 不得产生反向依赖,设计 §13 第 6 条) + +- [ ] 提交: `refactor: make capability a tier list, since "can it be off" is one entry in it` + +--- + +## Task 2 — `ThinkingWire` 与 8 段 provider 表 + +**文件**: `src/polygateway/providers.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_providers.py`(改) + +**行为**: +1. 新增 `ThinkingWire`(见关键接口)。`None` 的语义严格沿用 issue #5: `on_base is None` = **开启形态未知**(请求开启档时报错),`off is None` = 该 provider 无关闭形态,`effort_key is None` = 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。 +2. `ProviderProfile.thinking_on`/`thinking_off` 两字段替换为 `thinking: ThinkingWire`。 +3. `DEFAULT_PROFILES` 由 4 段扩到 8 段: + +| provider | off | on_base | effort_key | +|---|---|---|---| +| `qwen` | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None` | +| `deepseek` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` | +| `zhipu` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` | +| `moonshot` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` | +| `minimax` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` | +| `openai` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` | +| `anthropic` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` | +| `google` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` | + +`__init__.py` 同步导出 `ThinkingWire`。`openai` 段的两档由 `None`(未知)改为 OpenAI 标准形态,是本任务唯一的语义变更,理由写进注释: gpt-5.x 的 `reasoning_effort` 是 OpenAI 官方字段而非厂商方言,兜底段发它不会打到不认识它的厂商;真正未知形态的 provider 仍应走 `register_provider`。 + +**验收**: `get_provider("zhipu").thinking.effort_key == "reasoning_effort"`;未注册名仍报错且错误文案列出全部 8 段;`register_provider` 仍返回新表不改共享状态。 + +**测试**(先失败后通过): `tests/unit/test_providers.py::test_all_eight_profiles_registered`、`::test_wire_none_semantics_distinct`(三种 `None` 各自的含义不混淆)。 + +**验证**: `conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS + +- [ ] 提交: `feat: give zhipu, moonshot, anthropic and google a wire of their own` + +--- + +## Task 3 — `resolve_thinking` 五道关卡与 nearest 映射 + +**文件**: `src/polygateway/thinking.py`(改)、`tests/unit/test_thinking.py`(改) + +**行为**: 按下表实现,**顺序不可调换**,每关的理由写进 docstring。 + +| Phase | 条件 | 结果 | +|---|---|---| +| 1 | `effort is None` | 返回 `{}` | +| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` | +| 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** | +| 4 | `effort is NONE` 且 `not capability.can_disable` | `ThinkingUnsupportedError`,文案含 `cheapest_effort` 与 env 键名 | +| 5 | `effort not in supported_efforts` 且 `fallback == "error"` | `ThinkingUnsupportedError`;文案按 `capability.is_tiered` 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 `auto`/`none`」(设计 §3.2 第三个派生量的用途) | + +Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。 + +**注入形态**: +- `effort is NONE` → `wire.off`;`wire.off is None` 时报错(该 provider 无关闭形态)。 +- `effort is AUTO` → `wire.on_base`(不附档位)。这与旧 `thinking_on` 逐字节等价。 +- 其余档 → `{**wire.on_base, wire.effort_key: effort.value}`;`effort_key is None` 时报错并说明该 provider 只有开关没有档位。 + +**nearest 映射**(`fallback == "nearest"`,人类 2026-09-04 复核确认实现): 按 `_ORDER` 在 `supported_efforts` 中取距请求档**位序最近**者,等距时**取弱侧**(省钱优先,不替下游涨价);`AUTO` 不参与距离计算,仅当它是唯一候选时才被选中;映射发生时 warning 记明「请求档 → 实际档 → 模型」。`effort is NONE` 且不可关时**不走映射**——那是 Phase 4 的领域,必须报错给出路,否则又变成静默降级。 + +**验收**: 五关各自触发与不触发;`medium` 在 `(LOW, HIGH, MAX)` 上 `nearest` 映射到 `LOW`(等距取弱);`minimal` 映射到 `LOW`;`xhigh` 映射到 **`HIGH`**(与 `MAX` 等距,按「等距取弱」规则走——初稿此处写 `MAX` 是笔误,规则优先于例子)。**候选剔除 `none`**: 否则 `(none, auto)` 模型上请求 `high` 会被映射成 `none`,把「想浅一点」变成「别想了」,方向反转即 issue #20 那类静默失效。**`auto` 不受 Phase 5 清单约束**: 它在请求体里是「不写 `effort_key`」而非某个取值,可满足性只取决于 `on_base` 在不在;否则 `enable_thinking=True → AUTO` 会让存量源当场报错(能力表里档位型模型都不含 `auto`)。 + +**测试**(先失败后通过,**五关各一条**,兑现设计 §13 第 1 条): `::test_phase1_absent_effort_injects_nothing`、`::test_phase2_unknown_wire_points_to_register`、`::test_phase3_unregistered_warns_then_injects`(并断言 `warn_unregistered=False` 时不喊)、`::test_phase4_before_phase5`(请求 `none` 打到 glm-5.3,断言文案**含** `cheapest_effort` 值与 `REASONING_EFFORT` 键名)、`::test_phase5_lists_tiers_for_tiered_model`、`::test_phase5_says_toggle_only_for_switch_model`。 +另: `::test_nearest_ties_go_cheaper`、`::test_none_never_maps`、`::test_auto_injects_on_base_only`、`::test_effort_key_none_rejects_tier`、`::test_resolution_reports_applied_effort_after_mapping`(请求 `medium` → 断言 `applied_effort is Effort.LOW`)。 + +**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py -v` → PASS + +- [ ] 提交: `feat: refuse an impossible tier with the cheapest one that model does have` + +--- + +## Task 4 — 源级配置入口 + +**文件**: `src/polygateway/types.py`(改)、`src/polygateway/config.py`(改)、`.env.example`(改)、`tests/unit/test_config.py`(改) + +**行为**: +1. `SourceConfig` 末尾追加 `reasoning_effort: Effort | None = None` 与 `effort_fallback: str = "error"`。 +2. `config.py` 的 `_SOURCE_FIELDS` 增两行: `"REASONING_EFFORT": ("reasoning_effort", "effort")`、`"EFFORT_FALLBACK": ("effort_fallback", "str")`。新增 `"effort"` 解析类型: 值必须属 `Effort` 取值域,否则报错并列出八档。 +3. `effort_fallback` 值域 `{"error", "nearest"}`,越界即报错(与 `_SELECTORS`/`_QUOTA_FULL` 同款 frozenset 校验)。 +4. **矛盾校验**(构造期): 同源同时给出 `enable_thinking` 与 `reasoning_effort` 且语义冲突时 `ValueError`。冲突定义: `enable_thinking is True` 且 `reasoning_effort is NONE`;或 `enable_thinking is False` 且 `reasoning_effort not in (None, Effort.NONE)`。二者一致(如 `False` + `none`)则放行。 +5. `.env.example` 加两键模板,注释写明八档取值、与 `ENABLE_THINKING` 的等价关系及矛盾会报错。 + +**验收**: `LLM__ZHIPU__1__REASONING_EFFORT=low` 解析为 `Effort.LOW`;写 `lowest` 报错且文案列出八档;`ENABLE_THINKING=true` + `REASONING_EFFORT=none` 构造期报错。 + +**测试**(先失败后通过): `tests/unit/test_config.py::test_effort_key_parsed`、`::test_invalid_effort_lists_vocabulary`、`::test_contradictory_thinking_flags_rejected`、`::test_consistent_flags_allowed`。 + +**验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py -v` → PASS + +- [ ] 提交: `feat: let a source name its reasoning tier, and say so when it contradicts itself` + +--- + +## Task 5 — 请求级入口与优先级 + +**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改,`effective_effort` 定义处)、`src/polygateway/client.py`(改)、`tests/unit/test_client.py`(改) + +**行为**: +1. `ChatRequest` 末尾追加 `reasoning_effort: Effort | None = None`。 +2. `GatewayClient.chat()` 增关键字参数 `reasoning_effort: Effort | None = None`,存入 `ChatRequest`。 +3. 新增纯函数(放 `thinking.py`,与其余推理决策同处): + +```python +def effective_effort( + *, request_effort: Effort | None, source_effort: Effort | None, + enable_thinking: bool | None, +) -> Effort | None: + """生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。""" +``` + +语法糖映射: `True` → `Effort.AUTO`(注入 `on_base`,与旧行为逐字节等价,且不依赖能力表);`False` → `Effort.NONE`;`None` → 不表态。 + +**验收**: 三层优先级各自生效;请求级 `None` 不会覆盖源级已配的档;只配 `enable_thinking=True` 的存量源解析为 `AUTO` 且最终 payload 与升级前逐字节相同。 + +**测试**(先失败后通过): `::test_request_effort_wins_over_source`、`::test_none_request_does_not_clear_source`、`::test_enable_thinking_true_is_auto`、`::test_legacy_on_tier_matches_old_fragment`(回归门: **仅**对 `on_base` 完整表达「开」的 provider——qwen/deepseek/zhipu/moonshot——断言逐字节不变;minimax/openai/anthropic/google 的开档旧版硬编码 `medium`、新版不注入,是设计 §4.2 声明过的有意变更)。 + +**验证**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS + +- [ ] 提交: `feat: let one call ask for a different tier than its source defaults to` + +--- + +## Task 5b — 让 transport 拿得到请求级档位(端口签名扩展) + +**文件**: `src/polygateway/ports.py`(改)、`src/polygateway/middleware/retry.py`(改)、`src/polygateway/transports/openai_compat.py`(改)、`tests/unit/test_retry.py`(改)、`tests/unit/test_backpressure.py`(改)、`tests/integration/test_redis_cross_connection.py`(改)、`tests/unit/test_ports.py`(改) + +**为什么单列一步**(Codex 审查查出的阻断问题): T5 只把 `reasoning_effort` 放进 `ChatRequest`,但 `Transport` 协议收的是**拆开的**参数(`messages/source/stream/overlay/call_id`,`ports.py:39-49`),`RetryMW._attempt` 也只传这五个(`retry.py:282-288`)。不扩展协议,请求级档位根本到不了 `_build_payload`,设计 §4.2 的优先级落不了地。 + +**行为**: +1. `Transport.complete` 协议增关键字参数 `reasoning_effort: Effort | None`。**不设默认值**——与 `TelemetryRecorder` 同一既有约定: 库外无第三方实现者,完整签名成本为零,而给默认值会让漏传变成静默的「不表态」。 +2. `RetryMW._attempt` 调用处传 `request.reasoning_effort`。该中间件此前只读 `request` 的五个字段,新增第六个,不改其他语义。 +3. `OpenAICompatTransport.complete` 接收并透传给 `_build_payload`。 +4. 三个测试 fake 同步扩签名(`tests/unit/test_retry.py:72`、`tests/unit/test_backpressure.py:213`、`tests/integration/test_redis_cross_connection.py:76`)——`@runtime_checkable` 只查方法名不查签名,漏改会在调用时 `TypeError`,且错误现场离根因很远。 + +**不动**: `EmbeddingTransport`、`OcrTransport` 两个协议——它们无推理语义(与 issue #4 给 embedding 加 `extra_body` 被否决同理: 装配期报错比静默无效更能指路)。 + +**验收**: 请求级档位能一路到达 `_build_payload`;三个 fake 与协议签名一致;`tests/unit/test_ports.py` 的 Protocol 断言更新。 + +**测试**(先失败后通过): `tests/unit/test_retry.py::test_request_tier_reaches_transport`(断言 fake 收到的 `reasoning_effort` 与 `ChatRequest` 一致)、`::test_embedding_transport_signature_unchanged`(回归: 未误改另两个协议)。 + +**验证**: `conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_backpressure.py tests/unit/test_ports.py -v` → PASS + +- [ ] 提交: `feat: carry the per-call tier down to the transport that must send it` + +--- + +## Task 6 — 缓存 key + +**文件**: `src/polygateway/client.py`(改)、`src/polygateway/middleware/cache.py`(改)、`tests/unit/test_cache.py`(改)、`tests/integration/test_redis_cache.py`(改,该文件亦断言 key 形状) + +**行为**: +1. `_fingerprint_mark`: 源级 `reasoning_effort` **仅在非 `None` 时**追加,规则与 `enable_thinking` 完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。 +2. `build_cache_key` 增关键字参数 `reasoning_effort: Effort | None = None`,**仅非 `None` 时**写入 `key_obj["reasoning_effort"]`。 +3. `CacheMW.__call__` 传 `request.reasoning_effort`。 + +**为什么两处都要**(写进注释): `model_fingerprint` 是装配期算的**集合级**指纹,覆盖不到逐次调用变化的请求级档位;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。ARCH §7.5 记载的「集合级指纹仍可能返回另一源响应」这一既有取舍原样延续,本任务不扩大。 + +**验收**: 同 messages 不同请求级档位 → key 不同;两者皆不表态 → key 与升级前逐字相同(回归);源级档位变化 → fingerprint 变化。 + +**测试**(先失败后通过): `::test_request_tier_changes_key`、`::test_absent_tier_keeps_legacy_key`(断言具体 key 字符串不变)、`::test_source_tier_enters_fingerprint`。 + +**验证**: `conda run -n PolyGateway pytest tests/unit -k "cache or fingerprint" -v` → PASS + +- [ ] 提交: `fix: keep a low-tier answer out of the cache slot a max-tier one filled` + +--- + +## Task 7 — 遥测新增 `reasoning_effort` 列 + +**文件**: `src/polygateway/telemetry/schema.py`、`src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`(均改)、`tests/unit/test_telemetry.py`(改,含列数断言)、`tests/unit/test_ports.py`(改,Protocol 签名断言)、`tests/integration/test_postgres_telemetry.py`(改——该文件有 `_EXPECTED_COLUMNS` 完整**列序**断言与 pre-tenant 历史 DDL 的列子集推导,共 5 处,漏改则 PG 集成测试必红)、`src/polygateway/middleware/retry.py`(改,`emit_attempt` 调用点传新参) + +**行为**: +1. `schema.py`: `COLUMNS` 末尾加 `"reasoning_effort"`(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加 `reasoning_effort TEXT`(位置与 ALTER 追加一致);补列声明同步。**列数断言按物理列写**——两套口径混用是本模块最易错处(见其 docstring)。 +2. `ports.py`: `record_llm_call` 加 `reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。 +3. 两个 recorder 落库新列。 +4. `middleware/telemetry.py`: `_record` 加参并传给 recorder(**唯一** `record_llm_call` 调用点,不复制参数列表);`emit_attempt` 增 `applied_effort` 关键字参数,由其三个调用方传值——`retry.py:411` 传实际档,`embedding.py:407` 与 `ocr.py:451` 传 `None`(无推理语义)。三个 emit 入口取值口径分列: + +| 入口 | 取值 | 理由 | +|---|---|---| +| `emit_attempt` | 成功时 `response.applied_effort`(T8 送上来的实际档);**失败时**回落到 `effective_effort(...)` 的请求档 | **不是**请求档: `nearest` 映射后二者不同(请求 `medium` → 实际 `LOW`),记请求档会让 T10 的压测把行挂在从未发出的档下。失败尝试没有 response,实际档不可知,记请求档并接受这一含义差别——总好过 issue #19 抱怨的「失败行无归因」 | +| `emit_cache_hit` | `request.reasoning_effort` | 缓存命中没有选中源,源级档位无从谈起 | +| `emit_terminal_failure` | `request.reasoning_effort` | 同上(可能根本没选出源) | + +与 `sampling` 列的现有做法同构(`emit_attempt` 合并源级,另两处只取请求级)。 +5. 值为 `Effort` 时取 `.value` 落库,`None` 落 `NULL`——与 `thinking_observation` 同一先例(`StrEnum` 是 `str` 子类,asyncpg 对子类编码不保证接受,遥测写失败只降级 warning,PG 那一路会静默少列)。 + +**验收**: 两端建表列数断言更新且通过;三个入口各自落值正确;不表态时为 `NULL`;`telemetry_schema_sql` 打印的 SQL 与库实际执行的 DDL 同源。 + +**测试**(先失败后通过): 既有遥测列数断言用例更新;`::test_effort_column_records_effective_tier`、`::test_cache_hit_records_request_tier_only`、`::test_absent_tier_is_null`。 + +**验证**: `conda run -n PolyGateway pytest tests/unit tests/integration -k telemetry -v` → PASS + +- [ ] 提交: `feat: record which tier a call actually ran at` + +--- + +## Task 8 — transport 接线与对账 + +**文件**: `src/polygateway/transports/openai_compat.py`(改)、`src/polygateway/thinking.py`(改)、`tests/unit/test_openai_compat.py`(改) + +**行为**: +1. `_build_payload`: 用 `effective_effort(...)` 求生效档位后调 `resolve_thinking(..., fallback=source.effort_fallback)`。注入结果仍**先于** `source.extra_body` 与 `overlay`(顺序即优先级,issue #4 决策 A,两行不可调换)。 +2. `_warn_on_thinking_mismatch` 的节流键由 `(source.name, source.model, source.enable_thinking)` 改为 `(source.name, source.model, effective_effort)`——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。 +3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。 +4. `ThinkingUnsupportedError` 的捕获与翻译路径不变(→ `RequestRejectedError`,不重试不换源不计熔断)。 +5. **把实际档送出 transport**(否则遥测记不到 `nearest` 映射后的真实档): + - `TransportResult` 末尾追加 `applied_effort: Effort | None = None`——带默认值,非 OpenAI 兼容的 transport(OCR/embedding)可不填,与 `thinking_observation` 同一先例; + - `LLMResponse` 末尾追加 `applied_effort: Effort | None = None`——**字段只增不删不改名**,符合 ARCH §5.1 迁移兼容约束;对下游也有价值(它终于能知道这次实际跑在哪档); + - `RetryMW` 在 `retry.py:375` 的 `TransportResult → LLMResponse` 转换处带上该字段。 + +**验收**: 档位不支持时抛 `RequestRejectedError` 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;`reconcile` 三类文案与既有逐字一致(除方向描述由 bool 改档位);`nearest` 映射后 `LLMResponse.applied_effort` 是**映射后**的档。 + +**测试**(先失败后通过): `::test_unsupported_tier_is_request_rejected`、`::test_no_retry_on_tier_error`、`::test_throttle_key_separates_tiers`、`::test_reconcile_none_vs_observed`、`::test_response_carries_mapped_tier`(请求 `medium`、能力 `(LOW,HIGH,MAX)` → 断言 `response.applied_effort is Effort.LOW`)。 + +**验证**: `conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v` → PASS + +- [ ] 提交: `feat: wire the tier through the transport and keep each tier's warning distinct` + +--- + +## Task 9 — 全套件、文档与 wiki + +**文件**: `CHANGELOG.md`、`.env.example`(复核)、Gitea Wiki(按 `research-wiki/docs-convention.md` §2)、`src/polygateway/__init__.py`(版本号)、`pyproject.toml`(版本号) + +**行为**: +1. `make lint` + `make test` 全绿;`make format`。 +2. CHANGELOG 加「未发布」段: 破坏性变更(`ThinkingCapability` 构造签名)、新增(八档 `Effort`、两个 env 键、遥测新列、四个 provider 段)、行为变更(`openai` 段两档由未知改为 OpenAI 标准形态)。 +3. 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。**CHANGELOG 必须覆盖三条行为变更**,漏第三条是独立验证点名的风险: ① `ThinkingCapability` 构造签名(破坏性);② minimax/openai/anthropic/google 开档不再注 `medium`;③ `openai` 兜底段由「形态未知即报错」放宽为标准形态——把别家模型挂在该段下并配 `ENABLE_THINKING=true` 的下游,旧版装配期报错,新版静默不注入任何字节(对这四段涉及的模型无害,它们默认即推理;但语义变了,须明写)。 +4. 版本号 **`1.3.3`**(2026-09-05 人类指令;不因破坏性变更走 minor),`pyproject.toml` 与 `src/polygateway/__init__.py` 两处一致。**本任务只 bump 不发布**——发布走 CLAUDE.md §4.4.1 全清单。 + +**验收**: `make ci` 通过;CHANGELOG 与 wiki 均含破坏性变更条目。 + +**验证**: `conda run -n PolyGateway make ci` → PASS + +- [ ] 提交: `docs: cut 1.3.3 notes for the tier work` + +--- + +## Task 10 — e2e 实测校正初始能力表(标 `slow`) + +**文件**: `tests/e2e/test_thinking_live.py`(改)、`src/polygateway/thinking.py`(改——`DEFAULT_CAPABILITIES` 与 evidence 就在此处,实测结论要写回它,否则本任务只跑不改,设计 §8/§13 第 5 条落不了地) + +**行为**: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 `supported_efforts`,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 `usage.completion_tokens_details.reasoning_tokens`;对声明不可关的模型额外验证「请求 `none` 是否真被拒或真未关」。测试标 `slow`(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 `evidence` 中的「文档推定」。 + +**为什么必须单列一个任务**: 人类 2026-09-04 定「能力表数据统一自己经 new-api 实测」;Task 1 落的是文档推定值,不实测则整张表都是假设。 + +**验收**: 每个已登记模型有一条实测记录;与文档推定不符者更新 `supported_efforts` 并在 evidence 记明分歧(尤其 `kimi-k3` 的保守登记、`gemini-3.1-pro` 的默认档两源打架)。 + +**验证**: `conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v` → PASS(约 20-40 分钟,取决于网关) + +- [ ] 提交: `test: replace the guessed tier table with what the gateway actually does` + +--- + +## 执行顺序与依赖 + +``` +T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport) +T2(wire) ─────────┘ ↑ +T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤ + │ │ + └─→ T6(缓存 key) ↓ + T7(遥测) ─→ T9(文档) ─→ T10(实测,回写能力表) +``` + +T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);**T5b 依赖 T5**(要有 `ChatRequest.reasoning_effort` 才有得传);T6 依赖 T5;T8 依赖 T3 + T5b(没有 T5b 就拿不到请求级档位);**T7 依赖 T8**(自审纠正: 遥测要记的实际档由 T8 在 transport 内算出并经 `TransportResult`/`LLMResponse` 送上来,先做 T7 只能记到请求档);T9 在功能任务全绿后;T10 最后,且它会**改回 `thinking.py`**——与 T1 同一文件,故必须排在最后而非与其并行。 + +执行方式: 10 个任务耦合度中等(共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型),**直接按计划实现**,不派 `subagent-driven-development`——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。 diff --git a/research-wiki/plans/reasoning-effort.md b/research-wiki/plans/reasoning-effort.md new file mode 100644 index 0000000..15b51c3 --- /dev/null +++ b/research-wiki/plans/reasoning-effort.md @@ -0,0 +1,15 @@ +--- +type: plan +node_id: plan:reasoning-effort +title: "实现计划: 推理档位一等化" +date: 2026-09-05 +--- + +# 实现计划: 推理档位一等化 + +正文: `2026-09-04-reasoning-effort.md`(378 行,10 任务)。实现 `design:reasoning-effort`。 + +- **拆分逻辑**: T1(`Effort` 词汇 + 能力表)与 T2(`ThinkingWire` + 8 段 provider 表)可并行 → T3(五道关卡 + nearest 映射)→ T4(源级 env 入口)→ T5(请求级入口与优先级)→ T6(缓存 key 两处)/T7(遥测第 26 列)→ T8(transport 接线与告警节流)→ T9(CHANGELOG/wiki/1.4.0)→ T10(经 new-api 逐模型实测,标 `slow`)。 +- **T10 单列的理由**: 人类定「能力表数据统一自己经 new-api 实测」。T1 落的是文档推定值(四方交叉: 官方文档/OpenRouter/cherry-studio/LiteLLM),不实测则整张表都是假设——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下三档、`perplexity/` 下六档,中转改档位有第三方证据。 +- **执行方式**: 直接按计划实现,**不派** `subagent-driven-development`——10 个任务共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型,独立上下文的 subagent 容易在签名上分叉。 +- **保真校验**: 不适用(`thinking.py` 系库自研,非 `reference/` 移植蓝本;且三项目当前不在工作区)。 diff --git a/research-wiki/schemas/llm-calls.md b/research-wiki/schemas/llm-calls.md index ad82a3e..a40dd97 100644 --- a/research-wiki/schemas/llm-calls.md +++ b/research-wiki/schemas/llm-calls.md @@ -1,11 +1,11 @@ --- type: schema node_id: schema:llm-calls -title: "表结构: llm_calls(遥测 25 字段)" +title: "表结构: llm_calls(遥测 26 字段)" date: 2026-07-20 --- -# 表结构: llm_calls(遥测 25 字段) +# 表结构: llm_calls(遥测 26 字段) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) @@ -31,6 +31,7 @@ date: 2026-07-20 | tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 | | meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB | | thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 | +| reasoning_effort | TEXT | 本次调用**实际发出**的推理档位(2026-09-04,issue #20);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 | ## usage/成本口径(2026-07-30,est_tokens 解耦) @@ -104,6 +105,34 @@ ORDER BY model, calls DESC; 三条限定各有理由: `cache_hit = false` 与 `cost`/`cached_prompt_tokens` 同源——缓存命中行原样回放历史观测值,计入即重复计数;`error IS NULL` 排除失败尝试与终态失败行,那些行的本列恒为 `unknown`(无响应可裁定,默认值本身不撒谎),混进来会把「观测不到」的占比整体抬高;时间窗是为了让**变化**可见——某模型的 `unknown` 占比从 0 跳到 100%,正是它停报推理信号的那一天。补列之前写入的历史行本列为 NULL,与 `unknown` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。 +## 推理档位口径(2026-09-04,issue #20) + +`reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。 + +三个 emit 入口的取值同样各自定死,与 `sampling` 同构: + +| 入口 | 有生效源? | 记什么 | +|---|---|---| +| `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** | +| `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** | +| `emit_cache_hit` / `emit_terminal_failure` | 无 | 仅 `request.reasoning_effort` | + +成功行必须读实发档而非重算: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。 + +OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。 + +按档位看推理产出,即压测「高档是不是真的多想」的基本查询: + +```sql +SELECT model, reasoning_effort, + count(*) AS calls, + round(avg(reasoning_tokens)) AS avg_reasoning_tokens +FROM llm_calls +WHERE cache_hit = false AND error IS NULL AND reasoning_effort IS NOT NULL +GROUP BY model, reasoning_effort +ORDER BY model, calls DESC; +``` + ## 埋点位置(单一 helper 铁律) - `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点; diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 84fe8be..312e1a3 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -22,16 +22,24 @@ from polygateway.errors import ( ) from polygateway.ocr import OcrClient from polygateway.pricing import ModelPrice, PricingTable -from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider +from polygateway.providers import ( + DEFAULT_PROFILES, + ProviderProfile, + ThinkingWire, + register_provider, +) from polygateway.telemetry.schema import telemetry_schema_sql from polygateway.thinking import ( ThinkingCapability, + ThinkingResolution, ThinkingUnsupportedError, get_capability, register_capability, resolve_thinking, ) from polygateway.types import ( + EFFORT_ORDER, + Effort, EmbeddingResponse, LLMResponse, OcrLayoutElement, @@ -42,10 +50,12 @@ from polygateway.types import ( ThinkingObservation, ) -__version__ = "1.3.2" +__version__ = "1.3.3" __all__ = [ "DEFAULT_PROFILES", + "EFFORT_ORDER", + "Effort", "AllSourcesExhausted", "CircuitOpenError", "EmbeddingClient", @@ -73,7 +83,9 @@ __all__ = [ "TelemetryStatus", "ThinkingCapability", "ThinkingObservation", + "ThinkingResolution", "ThinkingUnsupportedError", + "ThinkingWire", "TransientError", "__version__", "gather_bounded", diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 3a53b5a..5b3e1b7 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -34,12 +34,14 @@ from polygateway.sources import ( RoundRobinSelector, SourceCooldownMemo, ) -from polygateway.thinking import get_capability, resolve_thinking +from polygateway.thinking import effective_effort, get_capability, resolve_thinking from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.types import ( ChatRequest, + Effort, LLMResponse, TelemetryStatus, + coerce_effort, validate_caller_dimensions, validate_request_overlay, ) @@ -83,20 +85,36 @@ def _guard_thinking( resolve_thinking( profile, get_capability(source.model, table=capabilities), - source.enable_thinking, + # 装配期看不见请求级档位(它逐次调用才产生),故只解源级两层;请求级 + # 只能在运行期由 transport 校验(设计 §10 的装配期/运行期分工) + effective_effort( + request_effort=None, + source_effort=source.reasoning_effort, + enable_thinking=source.enable_thinking, + ), model=source.model, + # 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被 + # 判死,而它在运行期本来是能映射到最近档跑起来的 + fallback=source.effort_fallback, ) def _fingerprint_mark(source: SourceConfig) -> str: - """单源的指纹标记;`enable_thinking` 仅在**表态时**追加。 + """单源的指纹标记;`enable_thinking` 与 `reasoning_effort` 仅在**表态时**追加。 只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4 时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。 + + `reasoning_effort`(issue #20)与 `enable_thinking` 同规则、同理由: 它一旦真正 + 改变请求体,"把源级档位从 low 改成 max 后重启"就会读到 low 档时缓存的旧响应。 + 两者取值域不相交(`"none"`/`"low"`… vs `true`/`false`),故追加进同一个列表也 + 不会把两种写法摘要成同一身份。 """ parts: list[Any] = [source.model, dict(source.extra_body)] if source.enable_thinking is not None: parts.append(source.enable_thinking) + if source.reasoning_effort is not None: + parts.append(source.reasoning_effort) return json.dumps(parts, sort_keys=True, ensure_ascii=False) @@ -104,16 +122,25 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str: """缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。 配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1 - 后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理 - (issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时 - 缓存的旧响应。全源两者皆未表态时字面量与历史实现逐字相同,不触发存量 - 缓存冷启动。 + 后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking`(issue #5)与 + 源级 `reasoning_effort`(issue #20)同理: 它们一旦真正改变请求体,"关掉推理后 + 重启"就会读到开着推理时缓存的旧响应。全源三者皆未表态时字面量与历史实现逐字 + 相同,不触发存量缓存冷启动。 + + 注意本指纹是**装配期**算出的**集合级**身份,覆盖不到逐次调用变化的请求级档位 + ——后者由 `build_cache_key` 的 `reasoning_effort` 参数单独承担(ARCH §7.5)。 """ fingerprint = ",".join(sorted({s.model for s in sources})) - # 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope - # 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动 + # 按 (model, extra_body[, enable_thinking][, reasoning_effort]) 而非源名摘要: + # 语义是"本 scope 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动。 + # 过滤条件必须与 `_fingerprint_mark` 追加的字段逐项对齐: 漏掉一项,只配了该项 + # 的源根本进不了 marks,`_fingerprint_mark` 改了也白改 marks = sorted( - {_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None} + { + _fingerprint_mark(s) + for s in sources + if s.extra_body or s.enable_thinking is not None or s.reasoning_effort is not None + } ) if marks: digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest() @@ -289,6 +316,7 @@ class GatewayClient: structured: type[BaseModel] | Literal["json"] | None = None, stream: bool = True, overlay: Mapping[str, Any] | None = None, + reasoning_effort: Effort | str | None = None, tenant_id: str | None = None, meta: Mapping[str, Any] | None = None, ) -> LLMResponse: @@ -298,6 +326,11 @@ class GatewayClient: 高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only 参数不影响既有调用点(issue #4)。 + `reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT` + 与 `ENABLE_THINKING`(设计 §4.2)。`None` 是**不表态**(随源级配置),与 + `Effort.NONE`("要求不推理")严格区分。裸字符串(`"low"`)也收,在此归一成 + `Effort`,非法值当场 `ValueError`——与 `SourceConfig` 那条装配路同口径。 + `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key** (租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇 (可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。 @@ -317,6 +350,15 @@ class GatewayClient: dimension_tenant_id, dimensions = validate_caller_dimensions( tenant_id, meta, origin="chat(tenant_id=..., meta=...)" ) + # 同样必须在洋葱之外归一: 档位一路要被 `is Effort.NONE` 身份比较,裸字符串 + # 进去会在 transport 的错误路径上抛 `AttributeError`——那不属错误四分类, + # 会穿透 `except ThinkingUnsupportedError` 与 RetryMW 的分类捕获(库铁律 + # 「错误分类驱动」)。归一失败是调用方编程错误,抛裸 ValueError 不进洋葱 + effort = ( + None + if reasoning_effort is None + else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)") + ) request = ChatRequest( messages=messages, session_id=session_id, @@ -327,6 +369,7 @@ class GatewayClient: stream=stream, overlay=sampling, sampling=sampling, + reasoning_effort=effort, tenant_id=dimension_tenant_id, meta=dimensions, ) diff --git a/src/polygateway/config.py b/src/polygateway/config.py index 24062be..27dcc5f 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -25,6 +25,7 @@ from polygateway.types import ( GlobalLimits, RetryPolicy, SourceConfig, + coerce_effort, ) if TYPE_CHECKING: @@ -43,6 +44,11 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = { "TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"), "INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"), "ENABLE_THINKING": ("enable_thinking", "bool"), + # 档位两键(issue #20);值域校验分工: 档位在此(解析即校验,报错点得出 env 键名), + # fallback 交给 SourceConfig 构造期(那道同时覆盖构造函数注入与 dataclasses.replace) + "REASONING_EFFORT": ("reasoning_effort", "effort"), + # 归一化(strip+lower)在 SourceConfig 构造期,与值域校验同处一点,故这里是裸 "str" + "EFFORT_FALLBACK": ("effort_fallback", "str"), "MISSING_DONE": ("missing_done", "str"), "TRUST_ENV": ("trust_env", "bool"), "EXTRA_BODY": ("extra_body", "json"), @@ -93,6 +99,10 @@ def _cast(raw: str, kind: str, key: str) -> object: if lowered in ("0", "false", "no", "off"): return False raise ValueError(f"非法布尔值: {raw!r}") + if kind == "effort": + # 归一化只有一份实现(`types.coerce_effort`),env 路与两条装配路同口径; + # origin 传空串是因为 env 键名由下面统一的"配置 X 解析失败"补上 + return coerce_effort(raw, origin="") if kind == "json": # JSONDecodeError 是 ValueError 子类,复用下方的统一包装 parsed = json.loads(raw) diff --git a/src/polygateway/embedding.py b/src/polygateway/embedding.py index 068d547..ac042ca 100644 --- a/src/polygateway/embedding.py +++ b/src/polygateway/embedding.py @@ -411,6 +411,9 @@ class EmbeddingClient: latency_ms=int((self._now() - started) * 1000), response=response, error=None if error is None else str(error), + # embedding payload 硬编码 {model, input},从不带推理参数;源上即便 + # 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事 + reasoning_applies=False, ) def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse: diff --git a/src/polygateway/middleware/cache.py b/src/polygateway/middleware/cache.py index 2e386bf..abdc2e7 100644 --- a/src/polygateway/middleware/cache.py +++ b/src/polygateway/middleware/cache.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any from loguru import logger -from polygateway.types import ChatRequest, LLMResponse, ThinkingObservation +from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation if TYPE_CHECKING: from collections.abc import Mapping @@ -53,6 +53,29 @@ def _coerce_observation(raw: Any) -> ThinkingObservation: return ThinkingObservation.UNKNOWN +def _coerce_applied_effort(raw: Any) -> Effort | None: + """缓存里的档位字符串 → 枚举;域外取值降级为 `None`,**不作废整条缓存**。 + + 与 `_coerce_observation` 同源同向,理由逐条相同: 多项目共用一个 Redis 时, + 先升级的进程可能写入本版没有的档位名,未升级的进程若把这些条目判成未命中, + 两个版本就会互相打对方的缓存。归因字段不该有能力废掉内容完好的响应。 + + 降级到 `None` 而不是别的档: 它的语义是"库不知道这次跑在哪档",对一个读不懂 + 的取值这是唯一诚实的说法——随便挑一档等于替上游声称了一件它没说过的事。 + """ + if raw is None: + return None + try: + return Effort(raw) + except ValueError: + logger.warning( + "缓存条目的 applied_effort 取值 {!r} 不在本版档位词汇内(多半由更新版本的" + "进程写入),已降级为 None;响应内容照常复活——归因字段不作废缓存", + raw, + ) + return None + + def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。 @@ -83,12 +106,21 @@ def build_cache_key( salt: str | None, *, sampling: Mapping[str, Any] | None = None, + reasoning_effort: Effort | None = None, ) -> str: """缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。 `sampling` 仅**非空**时参与(与 salt 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无语义差别)。它必须进 key: 否则同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。 + + `reasoning_effort` 是**请求级**档位(issue #20),仅非 `None` 时参与。它不能靠 + `model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,一次调用改档位不会 + 让它变一个字节;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4 + 「5 个 seed 全命中同一响应」的逐字翻版。 + + 判据用 `is not None` 而非真值: `Effort.NONE`(明确要求不推理)与 `None` + (不表态)语义不同——前者拿到的是没有推理过程的响应,合并即毒化。 """ key_obj: dict[str, Any] = { "model": model_fingerprint, @@ -99,6 +131,8 @@ def build_cache_key( key_obj["salt"] = salt if sampling: key_obj["sampling"] = dict(sampling) + if reasoning_effort is not None: + key_obj["reasoning_effort"] = str(reasoning_effort) payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False) return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest() @@ -139,6 +173,9 @@ class CacheMW: namespace, request.cache_salt, sampling=request.sampling, + # 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹, + # 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20) + reasoning_effort=request.reasoning_effort, ) cached = await self._safe_get(key) if cached is not None: @@ -162,6 +199,8 @@ class CacheMW: # 键缺失即升级前写入的旧条目,交给 dataclass 默认值 if "thinking_observation" in fields: fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"]) + if "applied_effort" in fields: + fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"]) fields.update( cache_hit=True, latency_ms=0, diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 91f6c6d..09a0236 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -285,6 +285,9 @@ class RetryMW: stream=request.stream, overlay=request.overlay, call_id=call_id, + # 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport + # 自己按选中的源解析,两者在 effective_effort 里汇合) + reasoning_effort=request.reasoning_effort, ) if result.usage_source == "unavailable": # 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回, @@ -392,6 +395,9 @@ class RetryMW: reasoning_tokens=result.reasoning_tokens, # 裁定归 transport(它才见得到原始信号),本层只搬运不改判 thinking_observation=result.thinking_observation, + # 同理: 实际档由做注入的那一层裁定(`nearest` 映射后与请求档分叉), + # 本层若"顺手"改读 request.reasoning_effort,记的就是从未发出过的档 + applied_effort=result.applied_effort, ) async def _emit( @@ -415,6 +421,8 @@ class RetryMW: latency_ms=int((self._now() - started) * 1000), response=response, error=None if error is None else str(error), + # chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算 + reasoning_applies=True, ) except asyncio.CancelledError: raise diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index b330199..3b4c0f6 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -23,7 +23,8 @@ from polygateway.errors import ( SourceNotConfiguredError, ) from polygateway.middleware.cache import digest_messages -from polygateway.types import ThinkingObservation, canonical_sampling_json, merge_sampling +from polygateway.thinking import effective_effort +from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -80,6 +81,70 @@ def _normalize_observation(raw: object) -> str: return ThinkingObservation.UNKNOWN.value +def _normalize_effort(raw: object) -> str | None: + """实际档位 → 落库用的裸 str;不表态与域外取值都落 `NULL`。 + + **不写 `raw.value`**,理由与 `_normalize_observation` 逐字相同: `LLMResponse` + 是无运行时校验的 frozen dataclass,测试替身写 `applied_effort="low"` 完全自然, + 而 `.value` 会当场抛 `AttributeError`,被 `_record` 的 `except Exception` 吞成 + 一条泛化 warning —— 丢的不是这一列,是**整行**。 + + 域外取值降级为 `None` 而不抛,方向与 `CacheMW._coerce_applied_effort` 一致 + (设计 §4.4): 多项目共用一套后端时,更新版本的进程可能带来本版没有的档位名, + 归因字段不该有能力废掉一整行遥测。降级到 `None` 也是唯一诚实的说法——库确实 + 不知道这次跑在哪档,随便挑一档等于替上游声称了一件它没说过的事。 + + 注意 `None` 在本列有**两个**来源(不表态 / 读不懂),二者都不可折叠进 `'none'`: + `'none'` 是"明确要求不推理",是一次表态。 + """ + if raw is None: + return None + try: + return Effort(raw).value + except ValueError: + logger.warning( + "推理档位取值 {!r} 不在本版档位词汇内,本行 reasoning_effort 降级记为 NULL" + "(其余列照常落库)", + raw, + ) + return None + + +def _attempt_effort( + *, + request: ChatRequest, + source: SourceConfig, + response: LLMResponse | None, + applies: bool, +) -> str | None: + """一次尝试该记哪一档: 成功读**实发档**,失败退回**请求档**(设计 §6)。 + + 成功行一律读 `response.applied_effort` 而**绝不重算**: 源上开了 + `EFFORT_FALLBACK=nearest` 时,请求 `medium` 而模型只有 low/high/max,实发的是 + `low`;此处重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出 + 过的分组下——而两个值在没开映射的源上恒等,这个错在本地跑不出来。 + + 失败尝试没有响应,实发档无从得知,故退回请求档并**接受这层含义差别**: 开了映射 + 的源上,成功行是映射后的档、失败行是请求档,两种行不是同一把尺子。仍然记而不是 + 留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒, + 这类行记的正是**被拒绝的那一档**——"哪一档配错了"是压测与排障要的信号。 + + 回落走 `effective_effort` 而非裸读两个字段: `enable_thinking` 也是一次表态 + (语法糖),漏掉它就会把一次明确要求推理的调用记成"没表态"。 + """ + if not applies: + return None + if response is not None: + return _normalize_effort(response.applied_effort) + return _normalize_effort( + effective_effort( + request_effort=request.reasoning_effort, + source_effort=source.reasoning_effort, + enable_thinking=source.enable_thinking, + ) + ) + + def _cap_text(text: str, cap: int | None) -> str: """超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。""" if cap is None or len(text) <= cap: @@ -161,7 +226,7 @@ class _AttemptUsage: class TelemetryEmitter: - """从请求与结果组装 25 字段并写入 recorder;一切写失败降级 warning。""" + """从请求与结果组装 26 字段并写入 recorder;一切写失败降级 warning。""" def __init__( self, @@ -192,8 +257,17 @@ class TelemetryEmitter: latency_ms: int, response: LLMResponse | None, error: str | None, + reasoning_applies: bool, ) -> None: - """逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。""" + """逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。 + + `reasoning_applies` 声明**这条调用路径有没有推理语义**: chat 路径为 + `True`,embedding / OCR 路径为 `False`。它不能由 emitter 自己推断——三条路径 + 共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源 + 会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的 + 档。**不设默认值**: 与 `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传 + 当场 TypeError,好过被静默当成"没表态"。 + """ usage = _AttemptUsage.of(response) await self._record( request=request, @@ -219,6 +293,9 @@ class TelemetryEmitter: sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)), tenant_id=request.tenant_id, meta=request.meta, + reasoning_effort=_attempt_effort( + request=request, source=source, response=response, applies=reasoning_applies + ), ) async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None: @@ -254,6 +331,9 @@ class TelemetryEmitter: # 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3) tenant_id=request.tenant_id, meta=request.meta, + # 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射 + # 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的) + reasoning_effort=_normalize_effort(request.reasoning_effort), ) async def emit_terminal_failure( @@ -286,6 +366,8 @@ class TelemetryEmitter: # 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的 tenant_id=request.tenant_id, meta=request.meta, + # 可能根本没选出源,故与 sampling 同样只取请求档 + reasoning_effort=_normalize_effort(request.reasoning_effort), ) async def _record( @@ -317,6 +399,10 @@ class TelemetryEmitter: # issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库) tenant_id: str | None, meta: Mapping[str, Any], + # issue #20: 已由各入口按自己的口径定型成裸 str/None(口径差别见三个入口的 + # 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把 + # "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制 + reasoning_effort: str | None, ) -> None: try: # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); @@ -370,6 +456,7 @@ class TelemetryEmitter: # 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让 # Postgres 那一路悄悄少一列数据 thinking_observation=_normalize_observation(thinking_observation), + reasoning_effort=reasoning_effort, ) except asyncio.CancelledError: raise diff --git a/src/polygateway/ocr.py b/src/polygateway/ocr.py index d2b486b..7dc0d35 100644 --- a/src/polygateway/ocr.py +++ b/src/polygateway/ocr.py @@ -455,6 +455,8 @@ class OcrClient: latency_ms=latency_ms, response=response, error=error_text, + # OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding) + reasoning_applies=False, ) @staticmethod diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index 09a6c3f..ed5b20b 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -12,6 +12,7 @@ from typing import Any, Protocol, runtime_checkable from .types import ( ChatRequest, + Effort, EmbeddingTransportResult, LLMResponse, OcrLayoutResult, @@ -36,7 +37,16 @@ class Middleware(Protocol): @runtime_checkable class Transport(Protocol): - """一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。""" + """一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。 + + `reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。 + 它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的 + 请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。 + + 该参数**不设默认值**,与 `TelemetryRecorder.record_llm_call` 同一既有约定: + 库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的 + "调用方没表态"——一次本该报错的漏配就此变成一次悄悄涨价的调用。 + """ async def complete( self, @@ -46,6 +56,7 @@ class Transport(Protocol): stream: bool, overlay: dict[str, Any], call_id: str, + reasoning_effort: Effort | None, ) -> TransportResult: ... @@ -260,7 +271,7 @@ class TelemetryStatusProvider(Protocol): @runtime_checkable class TelemetryRecorder(Protocol): - """遥测后端;25 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16),唯一调用点是 TelemetryEmitter。 + """遥测后端;26 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20),唯一调用点是 TelemetryEmitter。 新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名 Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。 @@ -270,6 +281,9 @@ class TelemetryRecorder(Protocol): `thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value` 的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受, 遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。 + `reasoning_effort` 同一先例(issue #20): emitter 已把 `Effort` 取成 `.value` + 的裸 `str`,`None` 表示调用方没表态——它与 `'none'`(明确要求不推理)不可折叠。 + recorder 只负责落库,不做任何语义判断,与 `sampling` 列由 `canonical_sampling_json()` 在 emitter 侧定型是同一先例。 """ @@ -302,4 +316,5 @@ class TelemetryRecorder(Protocol): tenant_id: str, meta: str, thinking_observation: str, + reasoning_effort: str | None, ) -> None: ... diff --git a/src/polygateway/providers.py b/src/polygateway/providers.py index 36ab1c1..644f1bb 100644 --- a/src/polygateway/providers.py +++ b/src/polygateway/providers.py @@ -14,75 +14,156 @@ from types import MappingProxyType from typing import Any +@dataclass(frozen=True) +class ThinkingWire: + """一个 provider 表达"开/关/多深"的请求体形态(设计 §3.3)。 + + 三个字段各自的 `None` **语义互不重叠**,混淆任意两个都会退回 issue #5 修掉的 + 那种静默失效: + + ============== ========================================================== + ``on_base=None`` **形态未知**: 本库不知道该 provider 如何表达"开",配了开关 + 即装配期报错并指路 `register_provider`/`extra_body` + ``off=None`` 已知开启形态,但**没有关闭形态**(该 provider 关不掉) + ``effort_key`` ``None`` = 该 provider 只有开关、没有档位(qwen 系靠 + ``=None`` ``thinking_budget`` 调深度,不是档位) + ============== ========================================================== + + `on_base={}` 与 `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于 + 开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加), + 后者是"不知道怎么表达"。 + + **为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat / + openai-responses / anthropic-messages / google-generate-content 四种端点协议, + 故需要 closed operation 集合与 `budgetWire` 代际变体。本库只有一个 OpenAI 兼容 + transport,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent), + 一个协议一层形态即够(P1 YAGNI)。 + """ + + off: Mapping[str, Any] | None + on_base: Mapping[str, Any] | None + effort_key: str | None + + @dataclass(frozen=True) class ProviderProfile: """单个 provider 的能力与差异声明。 - thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为 - True/False 时并入请求体的参数片段(`enable_thinking` 为 None 时二者都不 - 注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ```` - 标签(qwen 系);supports_native_schema 供 D14 阶梯选择原生 response_format。 + `thinking` 声明推理参数的**形态**(按 provider 变,数年不变一次); + `strip_think_tags` 声明响应 content 需剥离 ```` 标签(qwen 系); + `supports_native_schema` 供 D14 阶梯选择原生 response_format。 - 两档各有三种取值,**语义互不重叠**(issue #5): - - ========== ========================================================== - ``{...}`` 已知的注入片段 - ``{}`` 已知**无需注入**任何参数即处于该档 - ``None`` **未知**: 本库不知道该 provider 如何表达这一档 - ========== ========================================================== - - `None` 与 `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False` - 对 minimax/openai 源静默失效——调用方以为关掉了推理,实际什么都没发生。 - 现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`。 - - 注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型能否 - 关闭推理属**能力**(按 model 变),见 `ThinkingCapability`。 + 注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型支持哪些 + 档位属**能力**(按 model 变),见 `thinking.ThinkingCapability`。二者合一在 + provider 级表达不了代际差异——glm-5.2 能关而 glm-5.3 不能,形态却完全相同。 """ name: str - thinking_on: Mapping[str, Any] | None - thinking_off: Mapping[str, Any] | None + thinking: ThinkingWire strip_think_tags: bool supports_native_schema: bool = False +# 经 new-api 中转的口径。四家参考实现(cherry-studio / OpenRouter / LiteLLM / +# new-api 自身)一致的结论: OpenAI 兼容端点上,档位一律走标准的 `reasoning_effort`, +# 跨协议转换(→ Claude 的 thinking、Gemini 的 thinkingConfig)由网关服务端完成。 DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType( { # 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态) "qwen": ProviderProfile( name="qwen", - thinking_on={"enable_thinking": True}, - thinking_off={"enable_thinking": False}, + thinking=ThinkingWire( + off={"enable_thinking": False}, + on_base={"enable_thinking": True}, + # 百炼的深度控制是 `thinking_budget`(token 预算)而非档位; + # 预算型控制本库当前不支持(设计 §11 明确不做) + effort_key=None, + ), strip_think_tags=True, ), + # 官方 thinking_mode 文档: thinking:{type} 是开关,reasoning_effort 是深度, + # V4 一代两者并用(deepseek-v4-* 的档位见能力表) "deepseek": ProviderProfile( name="deepseek", - thinking_on={"thinking": {"type": "enabled"}}, - thinking_off={"thinking": {"type": "disabled"}}, + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, + on_base={"thinking": {"type": "enabled"}}, + effort_key="reasoning_effort", + ), strip_think_tags=False, ), - # OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把 - # kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给 - # 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在 - # 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider - "openai": ProviderProfile( - name="openai", - thinking_on=None, - thinking_off=None, + # issue #20。智谱官方迁移建议原文: 原先用 {"type":"disabled"} 的应改为 + # {"type":"enabled"} + reasoning_effort="low"——GLM-5.3 起 thinking.type + # 不再接受 disabled,故"关"这一档由能力表按型号裁定(5.2 能关,5.3 不能) + "zhipu": ProviderProfile( + name="zhipu", + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, + on_base={"thinking": {"type": "enabled"}}, + effort_key="reasoning_effort", + ), strip_think_tags=False, ), - # 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2), - # 2026-08-25 复测结论不变(findings 2026-08-25 §5);**直连官方端点未验证**。 - # 实测 enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens - # 恒定等于基线 194),reasoning_effort 才是真开关——本片段的选型据此成立。 - # "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的 - # thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近 - # "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。 - # 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段) + # kimi-k3 的档位是 low/high/max;thinking.type 为月之暗面的开关形态 + "moonshot": ProviderProfile( + name="moonshot", + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, + on_base={"thinking": {"type": "enabled"}}, + effort_key="reasoning_effort", + ), + strip_think_tags=False, + ), + # 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。 + # enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线 + # 194),reasoning_effort 才是真开关——本段形态据此成立。 + # `on_base={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段 + # 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。 + # 之所以接受: 本次一度改成 `on_base={}`("开"不需要任何参数),该形态依赖 + # "模型默认就推理"这个前提,而 T10 真实网关实测推翻了它——MiniMax-M3 不发任何 + # 推理参数时 5/5 轮不推理(六个强度值 minimal..max 则全部生效且彼此等价)。 + # 于是存量配 ENABLE_THINKING=true 的下游会从"真开推理"静默变成"不推理"。 + # 取 medium 是为逐字恢复旧版的 thinking_on,与存量行为一致;M3 六档等价, + # 故选哪档对效果无差别。 + # 正解是让 `auto` 受能力表约束(模型不支持"由模型自定"时报错并指路显式档位), + # 属公共行为变更,已记入 gitea issue #21 待下一版处理。 "minimax": ProviderProfile( name="minimax", - thinking_on={"reasoning_effort": "medium"}, - thinking_off={"reasoning_effort": "none"}, + thinking=ThinkingWire( + off={"reasoning_effort": "none"}, + on_base={"reasoning_effort": "medium"}, + effort_key="reasoning_effort", + ), + strip_think_tags=False, + ), + # OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底。两档此前标 + # None(未知),因为当时无法区分"厂商方言"与"标准字段";`reasoning_effort` + # 是 OpenAI **官方**字段而非方言,发给经网关的兼容端点不会打到不认识它的 + # 厂商,故 2026-09-04 起给出标准形态。真正形态未知的 provider 仍走 + # register_provider 注册,而不是挂在本段下 + "openai": ProviderProfile( + name="openai", + thinking=ThinkingWire( + off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort" + ), + strip_think_tags=False, + ), + # Claude 5 系原生是 thinking.type=adaptive + output_config.effort,Gemini 3 系 + # 原生是 thinkingConfig.thinkingLevel;两者的代际方言(Claude ≤4.5 的 + # budget_tokens、Gemini 2.x 的 thinkingBudget)由 new-api 的 canonical intent + # 层吸收,本库只发 OpenAI 形态(设计 §3.4) + "anthropic": ProviderProfile( + name="anthropic", + thinking=ThinkingWire( + off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort" + ), + strip_think_tags=False, + ), + "google": ProviderProfile( + name="google", + thinking=ThinkingWire( + off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort" + ), strip_think_tags=False, ), } diff --git a/src/polygateway/telemetry/schema.py b/src/polygateway/telemetry/schema.py index 93e3363..46ada8d 100644 --- a/src/polygateway/telemetry/schema.py +++ b/src/polygateway/telemetry/schema.py @@ -5,8 +5,8 @@ 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。 **`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 -`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 25 个 INSERT 字段 + -`created_at` = 26;列数断言一律按物理列数写,两套口径混用是最易错处。 +`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 26 个 INSERT 字段 + +`created_at` = 27;列数断言一律按物理列数写,两套口径混用是最易错处。 本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且 互不依赖(import-linter 契约执法)。 @@ -51,7 +51,8 @@ CREATE TABLE IF NOT EXISTS llm_calls ( reasoning_tokens INTEGER, tenant_id TEXT NOT NULL DEFAULT '', meta TEXT NOT NULL DEFAULT '{}', - thinking_observation TEXT + thinking_observation TEXT, + reasoning_effort TEXT ); """ @@ -82,7 +83,8 @@ CREATE TABLE IF NOT EXISTS llm_calls ( reasoning_tokens INTEGER, tenant_id TEXT NOT NULL DEFAULT '', meta JSONB NOT NULL DEFAULT '{}'::jsonb, - thinking_observation TEXT + thinking_observation TEXT, + reasoning_effort TEXT ); """ @@ -100,6 +102,9 @@ SQLITE_BACKFILL = ( # 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事", # 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同 ("thinking_observation", "TEXT"), + # 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none' + # (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事 + ("reasoning_effort", "TEXT"), ) # PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给 @@ -114,6 +119,7 @@ _PG_BACKFILL_DECLS = ( ("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"), # 可空,理由同 SQLITE_BACKFILL 同名项 ("thinking_observation", "TEXT"), + ("reasoning_effort", "TEXT"), ) # 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。 @@ -151,6 +157,7 @@ COLUMNS = ( "tenant_id", "meta", "thinking_observation", + "reasoning_effort", ) _COLUMN_SET = frozenset(COLUMNS) diff --git a/src/polygateway/telemetry/sqlite.py b/src/polygateway/telemetry/sqlite.py index cc46f2c..7781530 100644 --- a/src/polygateway/telemetry/sqlite.py +++ b/src/polygateway/telemetry/sqlite.py @@ -143,7 +143,7 @@ class SQLiteRecorder: logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) async def record_llm_call(self, **fields: object) -> None: - """写一行遥测;字段集合即 25 字段冻结签名(ports.TelemetryRecorder)。 + """写一行遥测;字段集合即 26 字段冻结签名(ports.TelemetryRecorder)。 取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的 占位符同序——两者必须一起改,分开改就是把值写进错位的列。 diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 7806d8c..3f262cd 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -14,8 +14,8 @@ from typing import Any from loguru import logger -from polygateway.providers import ProviderProfile -from polygateway.types import ThinkingObservation +from polygateway.providers import ProviderProfile, ThinkingWire +from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation, coerce_effort def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation: @@ -54,50 +54,314 @@ class ThinkingUnsupportedError(ValueError): @dataclass(frozen=True) class ThinkingCapability: - """某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。 + """某个**具体模型**支持哪些推理档位(设计 §3.2);登记必须附证据与日期。 与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变, 数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者 合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的 M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。 + **档位清单而非布尔**(2026-09-04): 旧版是 `can_disable: bool`,表达不了 + "关不掉但能调到最低档"这第三种情况——而 GLM-5.3 系与 Gemini 3 Pro 都是它。 + 现在"能不能关"就是 `Effort.NONE` 在不在清单里,是派生量而非独立字段;三个 + 派生量一律不存字段,存了必与清单漂移。 + `evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。 + 文档推定与实测必须在 evidence 里说清楚是哪种——前者会被 new-api 中转改写 + (LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下三档、`perplexity/` 下六档)。 """ - can_disable: bool + supported_efforts: tuple[Effort, ...] evidence: str + def __post_init__(self) -> None: + """构造期校验: 空清单与重复档都是登记错误,不能等到请求期才炸。""" + if not self.supported_efforts: + raise ValueError("supported_efforts 至少要有一档: 空清单表达不了任何能力") + if len(set(self.supported_efforts)) != len(self.supported_efforts): + raise ValueError(f"supported_efforts 有重复档: {self.supported_efforts}") + + @property + def can_disable(self) -> bool: + """能否关闭推理 = `none` 在不在清单里(旧 `can_disable` 字段的等价物)。""" + return Effort.NONE in self.supported_efforts + + @property + def cheapest_effort(self) -> Effort | None: + """除 `none` 外最省的一档;关不掉时作为**可执行替代**推荐给调用方。 + + `AUTO` 参与候选(纯开关型模型只有它可推荐),但因不在 `EFFORT_ORDER` 中, + 仅当没有任何强度档时才被选中。全清单只有 `none` 时返回 None——那种模型 + 没有"最省的开启档"可言。 + """ + tiers = [e for e in EFFORT_ORDER if e is not Effort.NONE and e in self.supported_efforts] + if tiers: + return tiers[0] + return Effort.AUTO if Effort.AUTO in self.supported_efforts else None + + @property + def is_tiered(self) -> bool: + """是否档位型(除 `none`/`auto` 外仍有强度档)。 + + 用途是**告警文案**: 对纯开关型模型说"可选档位: ..."是错的,它没有档位。 + """ + return any(e not in (Effort.NONE, Effort.AUTO) for e in self.supported_efforts) + + +# 证据分三类,evidence 里必须自报家门: +# 实测 = 经 new-api 中转打过真实请求(最硬,不得被文档推定覆盖); +# 文档推定 = 官方文档 / OpenRouter / cherry-studio / LiteLLM 四方交叉; +# 实测未覆盖 = T10 试过但拿不到数据(渠道限额/上游报错/被路由到别的模型), +# 此时**必须写明原因**——"没测到"与"测了没问题"是两回事,T9 之类的下游 +# 文档任务不得把前者写成后者。 +_MEASURED = "2026-08-02 经 new-api 中转实测" +_T10 = "2026-09-05 经 new-api 中转实测(T10: 短提示词 N=5,声称可关的再加长上下文 N=3 复核)" +_DOC = "2026-09-04 文档推定(官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉)" + +# T10 的三条判据(报告见 tests/outputs/thinking/,用例见 tests/e2e/test_thinking_live.py): +# ① 关闭方向要求**每轮**未观测到推理,任一轮观测到即证伪; +# ② 短提示词下的"关掉了"必须过长上下文复核——glm-5.3-flash 正是短提示词 5/5 +# 未观测到推理、5000 token 长上下文下 2/3 轮露馅(issue #20 的原始现象); +# ③ 上游整片不回传推理信号(kimi/MiniMax/qwen/gpt 这几路的关闭档都是)时, +# "没看见"不算"没发生",另取一个无魔数锚点: 关闭档的 completion_tokens +# 必须严格小于 max 档。 DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType( { + # —— MiniMax —— "MiniMax-M3": ThinkingCapability( - can_disable=True, + supported_efforts=( + Effort.NONE, + Effort.MINIMAL, + Effort.LOW, + Effort.MEDIUM, + Effort.HIGH, + Effort.XHIGH, + Effort.MAX, + ), evidence=( - "2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变;" - "2026-08-25 复测依然成立(prompt 194 = 基线、completion 3、无推理正文)。" - "两条限制(findings 2026-08-25-thinking-observability-regression §3.1/§5): " - "① 非流式路径观测不到推理信号——推理已计费,但正文与 usage 明细都不回传;" - "② enable_thinking / thinking:{type:enabled} 对本模型无效,仅 reasoning_effort 是真开关" + f"{_T10}: reasoning_effort=none 关闭成立(短 5/5 + 长上下文 3/3 未观测到推理," + "completion 恒 3 token,且与 max 档 completion 57-173 锚点可分);六个强度值各 N=5 " + "全部观测到推理,rt 分布完全重叠(minimal 64-124 / low 55-112 / medium 51-104 / " + "high 62-128 / xhigh 58-118 / max 57-170)——**它们是'开'的六种写法,不是六个深度档**," + "MiniMax 官方只有开/关两态,配哪一个都一样贵。" + "**`auto` 已从清单移除**: 实测当时 minimax 的「开」在 wire 上是 on_base={}" + "(什么参数都不注入),而 M3 的默认档实测不推理,故 auto 在这条路上表达不了「开」" + "(N=5 全部未观测到推理)。`resolve_thinking` 的 Phase 5 无条件放行 auto,能力表" + "堵不住这条,故 2026-09-05 由 wire 侧兜住: on_base 改回 {'reasoning_effort': 'medium'}," + "存量 ENABLE_THINKING=true 恢复真开推理(权宜之计,正解见 issue #21)。" + "本清单仍不含 auto——它记的是实测结论,不随 wire 的权宜之计变动。" + f"历史: {_MEASURED} N=10 同样成立;enable_thinking / thinking:{{type}} 两种写法对本模型" + "无效,reasoning_effort 才是真开关(findings 2026-08-25 §3.1/§5)。" + "另: 2026-08-25 记录的'MiniMax 这一路已停报 completion_tokens_details'本次**不再成立**" + "——开启档 rt 有值,只有关闭档整片缺 details" ), ), "MiniMax-M2.7": ThinkingCapability( - can_disable=False, + supported_efforts=(Effort.AUTO,), evidence=( - "2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} " - "各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段" + f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 100-161、推理正文 274-482 字符)," + "**关不掉**成立;auto 档 5/5 观测到推理。" + f"历史({_MEASURED}): reasoning_effort=none / thinking:{{disabled}} / thinking:{{adaptive}} " + "各 N=3 全部无效;OpenRouter 登记 mandatory:true,models.dev 登记无控制手段" ), ), "MiniMax-M2.5": ThinkingCapability( - can_disable=False, - evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理", + supported_efforts=(Effort.AUTO,), + evidence=( + f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 104-158),**关不掉**成立;" + "auto 档 5/5 观测到推理(rt 121-245)。" + f"历史({_MEASURED}): 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理" + ), ), + # —— qwen(百炼系,开关型) —— "qwen3.7-plus": ThinkingCapability( - can_disable=True, - evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)", + supported_efforts=(Effort.NONE, Effort.AUTO), + evidence=( + f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,且与 auto 档 completion 锚点可分);" + "auto 档 5/5 观测到推理。无强度档: 该 provider 的 wire 没有 effort_key,请求 max 当场被库" + "拒(百炼靠 thinking_budget 调深度,预算型控制本库不支持)。" + f"历史({_MEASURED}): enable_thinking=false 关闭(completion 5 token)" + ), ), + "qwen3.7-max": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.AUTO), + evidence=f"{_T10}: 同 qwen3.7-plus——none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理", + ), + "qwen3.6-plus": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.AUTO), + evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理", + ), + "qwen3.5-flash": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.AUTO), + evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理", + ), + "qwen-plus-latest": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.AUTO), + evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理", + ), + # —— deepseek —— "deepseek-v4-pro": ThinkingCapability( - can_disable=True, - evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)", + supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX), + evidence=( + f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,与 max 档锚点可分);" + "high / max 各 N=5 全部观测到推理(rt 59-73 / 56-69,推理正文 max 档明显更长: " + "135-186 vs 101-124 字符)。默认档按官方 thinking_mode 文档为 high" + ), + ), + "deepseek-v4-flash": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX), + evidence=( + f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理" + "(rt 16-36 / 12-42)。与 v4-pro 同档,印证官方'与 deepseek-v4-pro 一致'的说法" + ), + ), + "deepseek-v4-flash-vision-exp": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX), + evidence=( + f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理" + "(rt 12-17 / 17-34)" + ), + ), + # —— 智谱 —— + "glm-5.3": ThinkingCapability( + supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX), + evidence=( + f"{_T10}: **推理不可关闭已实测坐实**——请求 none(注入 thinking:{{type:disabled}})后 " + "5 轮里 4 轮仍观测到推理(rt 7、推理正文 12 字符),只有 1 轮 rt=0;" + "low / high / max 各 N=5 全部观测到推理(rt 55-77 / 47-63 / 48-60,三档分不出深浅)。" + "这一条了结了 issue #20 的核心争议: 当时短提示词下 rt≈1.2 看着像关掉了,实为采样噪声。" + f"文档侧三源一致({_DOC}): 智谱官方 thinking.type 只接受 enabled、迁移建议改用 " + "enabled + reasoning_effort=low,cherry-studio 标 toggle:false,OpenRouter 标 mandatory:true。" + "默认 max。**注意本渠道不校验档位值**: 未登记的 medium 也会被照单接受(实测 rt 62)," + "故'网关没报错'在这一路上不构成'该档受支持'的证据" + ), + ), + "glm-5.3-flash": ThinkingCapability( + supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX), + evidence=( + f"{_T10}: **推理不可关闭,且是判据②唯一的现役样本**——请求 none 时短提示词 5/5 轮" + "未观测到推理(看着完全像关掉了),换成 5000 token 长上下文后 3 轮里 2 轮露馅" + "(rt=2、有推理正文)。只跑短提示词的实测会在这个模型上得出相反结论。" + "low / high / max 各 N=5 全部观测到推理(rt 8-60 / 27-91 / 27-70)。默认 max" + ), + ), + "glm-5.2": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX), + evidence=( + f"{_DOC}: cherry-studio 登记 none/high/max(官方端点默认 max,百炼上默认 high)。" + "**T10 实测未覆盖——该渠道把本型号路由到了别的模型**: 请求 glm-5.2 时 5/5 轮回报 " + "model=glm-5.3(issue #20 记录的 6/6 复现),拿到的行为不属于本型号,故整组数据作废、" + "本行仍是文档推定。**下游风险**: 在本渠道上给 glm-5.2 配 none,库会照本行放行," + "而真正服务请求的 glm-5.3 关不掉推理——运行期 reconcile 会喊,但那是事后" + ), + ), + "glm-5": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.AUTO), + evidence=( + f"{_DOC}: OpenRouter 登记只支持 reasoning 开关、无 reasoning_effort;cherry-studio 标 toggle:true。" + "**T10 实测未覆盖**: 与 glm-5.2 同因——5/5 轮回报 model=glm-5.3,数据不属于本型号" + ), + ), + "glm-5.1": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.AUTO), + evidence=( + f"{_DOC}: 同 glm-5(OpenRouter reasoning.mandatory=false 且无 supported_efforts)。" + "**T10 实测未覆盖**: 5/5 轮回报 model=glm-5.3,数据不属于本型号" + ), + ), + "glm-4.6v": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.AUTO), + evidence=( + f"{_T10}: none 关闭成立,且是全表**证据最硬**的一条——短 5/5 + 长 3/3 全部裁定 ABSENT" + "(上游明确上报 reasoning_tokens=0,不是'看不见'),无需锚点旁证;" + "auto 档 5/5 观测到推理(rt 57-153)。model_reported 与请求一致,未被路由" + ), + ), + # —— 月之暗面 —— + "kimi-k3": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX), + evidence=( + f"{_T10}: **可关闭——推翻 T1 的保守登记**。请求 none(注入 thinking:{{type:disabled}})后" + "短 5/5 + 长上下文 3/3 轮无任何推理信号,completion 恒 9 token;同一模型 max 档 " + "completion 明显更大且带推理正文(rt 33-146),锚点可分——故'没看见'这次有正面证据支撑。" + "两源分歧由此了结: OpenRouter 的 mandatory:false 是对的,官方档位表没列 none 只是没列。" + "low / high / max 各 N=5 全部观测到推理(rt 21-53 / 38-60 / 33-146)。" + "**model_reported 是 `k3`**(别名,非串台)。官方提示切换档位会使 prefix cache 失效," + "不宜在会话中途改档" + ), + ), + "kimi-for-coding": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX), + evidence=( + f"{_T10}: 本型号在 T1 时因'档位清单无直接证据'走 Phase 3 不登记(设计 §8 第三档)," + "现有它自己的实测证据故补登。none: 短 5/5 + 长 3/3 无推理信号、completion 恒 2 token," + "与开启档锚点可分;low / high / max 各 N=3 全部观测到推理(rt 8-40 / 25-75 / 62-85)。" + "档位词汇沿用月之暗面官方的 low/high/max: 本渠道对 moonshot **不校验档位值**" + "(minimal/medium/xhigh 照样返回 200 并推理),故'没被拒'不构成'受支持'," + "登记一个厂商没声明的档等于替它做承诺" + ), + ), + # —— OpenAI —— + "gpt-5.4": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH), + evidence=( + f"{_DOC}: OpenRouter 登记 none/low/medium/high/xhigh,默认 medium;LiteLLM 登记 minimal 不支持。" + "**T10 实测未覆盖**: 该渠道本型号所有账号限流(429 All available accounts are " + "currently rate-limited),5/5 轮失败。同代的 gpt-5.5 已实测且与本清单逐字相符" + ), + ), + "gpt-5.5": ThinkingCapability( + supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH), + evidence=( + f"{_T10}: 清单**逐条对上**,是全表验证最完整的一行。none 关闭成立(短 5/5 + 长 3/3," + "锚点可分);low/medium/high/xhigh 各 N=5 全部观测到推理,且 rt 随档位单调上升" + "(18-21 / 18-22 / 22-34 / 35-65)——本渠道上少见的、档位真的分得开的模型;" + "清单外的 max 与 minimal 各 N=3 全部被上游 400 拒(" + "Unsupported value),说明这一路**会校验档位值**,与 zhipu/moonshot 的照单全收相反" + ), + ), + # —— Anthropic —— + "claude-opus-5": ThinkingCapability( + supported_efforts=( + Effort.NONE, + Effort.LOW, + Effort.MEDIUM, + Effort.HIGH, + Effort.XHIGH, + Effort.MAX, + ), + evidence=( + f"{_DOC}: Anthropic 官方 adaptive thinking + output_config.effort 五档(low/medium/high/" + "xhigh/max),默认 high;OpenRouter 标 mandatory:false 故可关。" + "**T10 实测未覆盖**: 该渠道 claude 全系返回 429「api key 7天限额已用完」,5/5 轮失败。" + "关闭档仍依赖 new-api 把 reasoning_effort=none 转成 thinking 关闭形态,未经验证" + ), + ), + "claude-sonnet-5": ThinkingCapability( + supported_efforts=( + Effort.NONE, + Effort.LOW, + Effort.MEDIUM, + Effort.HIGH, + Effort.XHIGH, + Effort.MAX, + ), + evidence=( + f"{_DOC}: 同 claude-opus-5(OpenRouter supported_efforts 与默认档一致)。" + "**T10 实测未覆盖**: 同因 429「api key 7天限额已用完」" + ), + ), + # —— Google —— + "gemini-3.1-pro": ThinkingCapability( + supported_efforts=(Effort.LOW, Effort.MEDIUM, Effort.HIGH), + evidence=( + f"{_DOC}: **推理不可关闭**——Google 官方文档明确 Gemini 3 Pro / 3.1 Pro 无法关闭思考," + "OpenRouter 亦标 mandatory:true。thinking_level 三档;默认档两源打架" + "(官方文档说 HIGH,OpenRouter 说 medium)。" + "**T10 实测未覆盖**: 该渠道本型号上游报错(bad_response_status_code / openai_error)," + "5/5 轮失败,连默认档基线都没取到,两源分歧仍悬着" + ), ), } ) @@ -127,67 +391,302 @@ def register_capability( return table +@dataclass(frozen=True) +class ThinkingResolution: + """请求体注入片段 + 本次**实际**生效的档位(设计 §4.1)。 + + 返回 dataclass 而非裸 Mapping,是因为 `nearest` 映射后"请求的档"与"真正发出 + 去的档"会分叉(请求 `medium`、模型只有 low/high/max → 实际发 `low`)。遥测必 + 须记后者: 记请求档会让按档位分组的压测把整行数据挂在一个从未真正发出过的档 + 下,而那种数据错得看不出来。 + + `applied_effort is None` 只出现在 Phase 1(调用方不表态): 库既不注入,也不 + 去推定模型自己的默认档——"没看见"不许说成"发生了"。 + """ + + payload: Mapping[str, Any] + applied_effort: Effort | None + + +def effective_effort( + *, + request_effort: Effort | None, + source_effort: Effort | None, + enable_thinking: bool | None, +) -> Effort | None: + """求本次生效的档位: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。 + + **收口成一个纯函数**是本函数存在的全部理由: 装配守卫(`client._guard_thinking`) + 与请求热路径(`openai_compat._build_payload`)必须给出**同一个**判定,两处各写 + 一份就地转换迟早会分叉,而分叉的形态是"装配期放行、运行期报错"——最难查的那种。 + + **一律用 `is None` 判有没有表态,不靠真值性**: `Effort.NONE`(要求不推理)与 + `enable_thinking=False` 都是**表态**而非缺省,`x or y` 式的回落会把后者当成没配 + 从而跳到下一层——那正是本次要消灭的静默失效。 + + 语法糖排在最末且 `True → AUTO`(开启但不指定强度,不依赖能力表),不是旧版那个 + 硬编码的 `medium`: 那是库替下游做的档位判断,而 `medium` 在 GLM/kimi/deepseek 的 + 档位表里根本不存在(设计 §4.2 声明过的有意变更)。 + + 同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾,已由 + `SourceConfig.__post_init__` 在构造期报错,故这里不再判——两个字段说同一件事时, + 矛盾是配置错误,不是优先级问题。 + """ + if request_effort is not None: + return request_effort + if source_effort is not None: + return source_effort + if enable_thinking is None: + return None + return Effort.AUTO if enable_thinking else Effort.NONE + + def resolve_thinking( profile: ProviderProfile, capability: ThinkingCapability | None, - enable_thinking: bool | None, + effort: Effort | str | None, *, model: str, + fallback: str = "error", warn_unregistered: bool = True, -) -> Mapping[str, Any]: - """三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。 +) -> ThinkingResolution: + """档位 + 两层声明(形态/能力)→ 注入片段;不可满足时 `ThinkingUnsupportedError`。 调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为 - `RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时 - 无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有 - `can_disable` 可读,故 Phase 3 必须先于 Phase 4。 + `RequestRejectedError`(四分类之一)。**判定顺序即语义,不可调换**: + + ========== ================================================================ + Phase 1 不表态 → 不注入。与 `Effort.NONE` 严格区分: 前者是"随模型默认", + 后者是"要求不推理" + Phase 2 **该请求档所需的**形态未知 → 报错(判据见 `_wire_unknown_for`)。 + 无从注入时,模型能力如何都无关紧要,故必须先于 4/5 + Phase 3 能力未登记 → 尽力注入且**不校验档位**。没有清单可比对,拿空清单 + 去拒绝档位就是凭空报错;新模型上线不该被库挡住(设计 §5 R4) + Phase 4 请求 `none` 而模型关不掉 → 报错并给出 `cheapest_effort` + Phase 5 其余档位打空 → 报错(或按 `fallback` 映射) + ========== ================================================================ + + **4 必须先于 5**: `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就 + 退化成"不支持 none,可选 low/high/max"——丢掉"这个模型根本关不掉"这个关键 + 信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是 + issue #20 的成因。 + + **`auto` 不受档位清单约束**: 它表达的是"开启,但不指定强度",在请求体里就是 + "不写 `effort_key`",而不是写进 `effort_key` 的某个取值,故 Phase 5 放行它。 + 反过来判会让存量的 `ENABLE_THINKING=true`(T5 起等价于 `auto`)在 deepseek-v4 + 与 glm-5.3 这类清单里没有 `auto` 的模型上当场报错,而设计 §12 明确承诺存量 + 配置继续可跑——那里唯一允许新报错的是"关闭一个官方不可关的模型"。 `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而 `capability` 为 None(未登记)时无从从别处取得模型名。 - `warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次 - 调用再喊只会刷屏。判定结果不受此参数影响。 + `fallback="nearest"` 是 Phase 5 的逃生口,**默认关闭的理由是钱**: 一次静默的 + `medium → max` 在 GLM-5.3 上是数倍账单(P5"严禁默认值掩盖错误")。 + + `warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次调用 + 再喊只会刷屏。判定结果不受此参数影响。 + + **裸字符串也收**(设计 §4.4 第 4 条入口): 本函数在 `__all__` 里,下游直调时 + 传的天然是从 JSON/配置读出来的 `"low"`,而第三参数本次由 `bool` 换成 `Effort` + 正是这条入口冒出来的时机。签名照实写 `Effort | str`——下面每一关的判据都是 + `is Effort.X` 的身份比较,`"none" is Effort.NONE` 恒假,不归一的后果不是报错 + 而是**静默判否**: Phase 2 按开启方向取字段、Phase 4 整条被绕过,最后在拼错误 + 文案时才以 `AttributeError` 现形(一个未文档化、也不属四分类的异常)。 """ - # 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: + # Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举 + if effort is not None: + effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})") + # Phase 1: 调用方不表态 —— 与 Effort.NONE 严格区分,用模型自己的默认档 + if effort is None: + return ThinkingResolution({}, None) + wire = profile.thinking + # Phase 2: 形态未知 —— 给了档位却不知道怎么发,静默放行就是欺骗调用方 + if _wire_unknown_for(wire, effort): raise ThinkingUnsupportedError( - f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): " - f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态," - f"或改用 SourceConfig.extra_body 直接下发供应商参数" + f"provider {profile.name!r} 的推理形态未知(模型 {model!r},请求档位 " + f"{effort.value!r}): 本库不知道该 provider 如何表达推理。请用 " + f"register_provider 注册形态,或改用 SourceConfig.extra_body 直接下发供应商参数" ) # Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功 if capability is None: + payload = _inject(profile, effort, model=model) if warn_unregistered: - _warn_unregistered(model, profile, slot) - return slot - # Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说 - if enable_thinking is False and not capability.can_disable: + _warn_unregistered(model, profile, effort, payload) + return ThinkingResolution(payload, effort) + # Phase 4: 明确关不掉 —— 调用方要的是"不推理"的语义保证,给不了必须说,且必须 + # 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由) + if effort is Effort.NONE and not capability.can_disable: + raise ThinkingUnsupportedError(_cannot_disable(model, capability)) + # Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring) + applied = _settle_tier(effort, capability, model=model, fallback=fallback) + return ThinkingResolution(_inject(profile, applied, model=model), applied) + + +def _wire_unknown_for(wire: ThinkingWire, effort: Effort) -> bool: + """Phase 2 的判据: **按请求档取相关字段**,不是一律看 `on_base`。 + + 旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此。只看 + `on_base` 会让"关闭形态已知、开启形态未知"的自定义 provider 在请求 `none` 时 + 被误拒,且指向它已经做过的 `register_provider`(设计 §2 处置表第 2 条)。 + + 请求 `none` 时判据是**两者皆 None**,而不是单看 `off`: `ThinkingWire` 的三个 + `None` 语义互不重叠——`off is None` 而 `on_base` 已知是"该 provider 关不掉" + (由 `_inject` 说清是缺了哪半边),只有两者皆 None 才是"整个形态未知",此时 + 指路 `register_provider` 才是对的方向。 + """ + if effort is not Effort.NONE: + return wire.on_base is None + return wire.off is None and wire.on_base is None + + +def _settle_tier( + effort: Effort, capability: ThinkingCapability, *, model: str, fallback: str +) -> Effort: + """Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。 + + `auto` 直接放行: 它不是写进 `effort_key` 的取值,而是"不写 effort_key" + (理由见 `resolve_thinking` 的 docstring)。 + """ + if effort is Effort.AUTO or effort in capability.supported_efforts: + return effort + mapped = _nearest_effort(effort, capability) if fallback == "nearest" else None + if mapped is None: raise ThinkingUnsupportedError( - f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: " - f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——" - f"需要关闭思维链请换用支持关闭的模型" + _tier_unsupported(model, effort, capability, fallback=fallback) ) - return slot - - -def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None: logger.warning( - "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};" + "模型 {} 不支持 reasoning_effort={},按 effort_fallback=nearest 改用最近的 {} 档;" + "本次真正发出去的、以及遥测成功行记的都是后者,但**缓存 key 记的是前者**" + "(CacheMW 在洋葱里比 transport 更外,查缓存时映射尚未发生,拿不到实发档)", + model, + effort.value, + mapped.value, + ) + return mapped + + +def _inject(profile: ProviderProfile, effort: Effort, *, model: str) -> Mapping[str, Any]: + """按 wire 把档位写成请求体片段;wire 表达不了这一档时报错。 + + 自己重读 `wire` 而不由调用方传 `on_base`: Phase 2 的判据按请求档取相关字段 + (`none` 看 `off`,其余档看 `on_base`)之后,"on_base 一定不是 None"这条前提 + 只对非 `none` 档成立,写进签名反而是句假话。 + + 三种 `None` 的语义在此**各自兑现**(ThinkingWire 的 docstring 定义了它们): + `off is None` = 该 provider 关不掉,`effort_key is None` = 它只有开关没有档位。 + 两者都不是"形态未知",故都不指向 `register_provider`——指错了排查方向比不指 + 还糟。 + """ + wire = profile.thinking + if effort is Effort.NONE: + if wire.off is None: + raise ThinkingUnsupportedError( + f"provider {profile.name!r} 没有关闭形态(模型 {model!r}): " + f"本库知道它如何表达开启,但该 provider 没有可用的关闭参数。" + f"需要不推理请换用支持关闭的 provider 或模型" + ) + return wire.off + # 非 none 档的开启形态由 Phase 2 保证已知(内部不变量,不承担生产校验) + assert wire.on_base is not None + if effort is Effort.AUTO: + # auto = 开启但不指定强度: 逐字节等于升级前的 `thinking_on` + return wire.on_base + if wire.effort_key is None: + raise ThinkingUnsupportedError( + f"provider {profile.name!r} 只有推理开关、没有档位键(模型 {model!r})," + f"表达不了 reasoning_effort={effort.value!r}: 请改用 auto/none 两档," + f"或用 register_provider 给该 provider 注册 effort_key" + ) + return {**wire.on_base, wire.effort_key: effort.value} + + +def _cannot_disable(model: str, capability: ThinkingCapability) -> str: + """Phase 4 的文案: 报错必须带一条能立刻照做的替代,否则等于把用户推回起点。 + + 只报"关不掉"而不给出路,下游就会去找 `extra_body` 那条绕过库的路——issue #20 + 的成因正是如此。故文案必须含 `cheapest_effort` 的值与 env 键名两样东西。 + """ + # Phase 4 只在 none 不在清单里时触发,而清单构造期保证非空,故必有一档可推荐 + alternative = capability.cheapest_effort + assert alternative is not None + return ( + f"模型 {model!r} 无法关闭推理,reasoning_effort='none' 无法满足: " + f"{capability.evidence}。最省的开启档是 {alternative.value!r}——请配 " + f"{{SCOPE}}__{{PROVIDER}}__{{N}}__REASONING_EFFORT={alternative.value}," + f"或调用时传 reasoning_effort=Effort.{alternative.name};" + f"真正需要不推理请换用支持关闭的模型" + ) + + +def _tier_unsupported( + model: str, effort: Effort, capability: ThinkingCapability, *, fallback: str +) -> str: + """Phase 5 的文案: 按 `is_tiered` 分叉,纯开关型模型不能被告知"可选档位"。 + + 它没有档位——对它说"可选档位: none, auto"是把开关说成了强度轴,下游照着找 + 档位只会一无所获(设计 §3.2 第三个派生量的用途就是这一句话该怎么说)。 + """ + listed = ", ".join(e.value for e in _ordered(capability.supported_efforts)) + head = f"模型 {model!r} 不支持 reasoning_effort={effort.value!r}: {capability.evidence}。" + body = ( + f"该模型的可选档位: {listed}" + if capability.is_tiered + else f"该模型只有开关、没有强度档位,可用: {listed}" + ) + # 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话 + hint = "" if fallback == "nearest" else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest" + return f"{head}{body}{hint}" + + +def _ordered(efforts: tuple[Effort, ...]) -> list[Effort]: + """按由弱到强列出档位;`auto` 不在强弱轴上,排在末尾。""" + ordered = [e for e in EFFORT_ORDER if e in efforts] + if Effort.AUTO in efforts: + ordered.append(Effort.AUTO) + return ordered + + +def _nearest_effort(requested: Effort, capability: ThinkingCapability) -> Effort | None: + """取距 `requested` 位序最近的**开启档**;等距取弱侧,无开启档时返回 None。 + + 候选**剔除 `none`**: 把"想得浅一点"映射成"别想了"是方向反转而非省钱,正是 + issue #20 那种静默失效的翻版。`none` 的领域归 Phase 4,它在那里已经被处理过, + 走不到这里(能关就不会打空,不能关就已经报错)。 + + `auto` 不在强弱轴上(`EFFORT_ORDER` 不含它),故不参与距离计算,只在一个强度 + 档都没有时兜底——它恰好是纯开关型模型唯一能表达"开"的档。 + + **等距取弱**的理由是钱: 一次静默的 `medium → max` 在 GLM-5.3 上是数倍账单, + 库不替下游涨价。 + """ + candidates = [ + e for e in EFFORT_ORDER if e is not Effort.NONE and e in capability.supported_efforts + ] + if not candidates: + return Effort.AUTO if Effort.AUTO in capability.supported_efforts else None + target = EFFORT_ORDER.index(requested) + # 排序键第二位是位序本身: 距离相同时位序小的(更省的)胜出 + return min( + candidates, key=lambda e: (abs(EFFORT_ORDER.index(e) - target), EFFORT_ORDER.index(e)) + ) + + +def _warn_unregistered( + model: str, profile: ProviderProfile, effort: Effort, payload: Mapping[str, Any] +) -> None: + logger.warning( + "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});" "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", model, profile.name, - dict(slot), + dict(payload), + effort.value, ) def reconcile_thinking( *, - enable_thinking: bool | None, + effort: Effort | None, observation: ThinkingObservation, capability: ThinkingCapability | None, model: str, @@ -197,6 +696,17 @@ def reconcile_thinking( 能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的 表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。 + **判据是档位而非布尔**(2026-09-05,设计 §4.3): `Effort.NONE` 走"要求关闭" + 一支,其余任何档走"要求开启"一支,`None`(不表态)仍沉默。判据必须写成 + `is Effort.NONE` 的**身份比较**——它的取值是非空串 `"none"`,任何靠真值性 + 的写法(`if not effort`)都恒为假,会把每个强度档送进关闭分支,告警方向整个 + 颠倒。传入的应是**实际发出去**的那一档(`nearest` 映射后与请求档分叉), + 否则文案会说一个从未发出过的档。 + + **不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3/§11 第 1 + 条): 二者没有可判定的函数关系(实测同一档 rt 在 8~56 之间跳),拿它报警必然 + 是噪声,而噪声等于没有告警。该问题归 §11 的压测,不进库。 + **只判定、不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容, 而不必去解析日志格式;节流也才能留在握有实例状态的 transport 里。 @@ -205,24 +715,26 @@ def reconcile_thinking( 与遥测落地,处置权归下游。 """ # Phase 1: 调用方不表态 —— 没提要求就无从谈"违背" - if enable_thinking is None: + if effort is None: return None # Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警 # 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警 - if enable_thinking is False: + if effort is Effort.NONE: if observation is not ThinkingObservation.OBSERVED: return None return _off_but_observed(model, capability) - # Phase 3: 要求开启 —— ABSENT 是正面证伪,UNKNOWN 是"看不见",两者文案不可混 + # Phase 3: 要求开启(含 auto 与各强度档)—— ABSENT 是正面证伪,UNKNOWN 是 + # "看不见",两者文案不可混。文案写出**是哪一档**: transport 的节流键正按档 + # 分离,文案不分档的话,两条告警长得一模一样,看的人分不出是哪一档出的问题 if observation is ThinkingObservation.ABSENT: return ( - f"模型 {model!r} 的 enable_thinking=True 未生效: 已注入开启参数," + f"模型 {model!r} 的 reasoning_effort={effort.value!r} 未生效: 已注入开启参数," f"上游却明确上报本次未推理(reasoning_tokens=0)" ) if observation is ThinkingObservation.UNKNOWN: return ( - f"模型 {model!r} 的 enable_thinking=True 无法确认是否生效: 已注入开启参数," - f"但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。" + f"模型 {model!r} 的 reasoning_effort={effort.value!r} 无法确认是否生效: " + f"已注入开启参数,但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。" f"若走的是非流式路径,推理内容可能已计费却不回传" ) return None @@ -236,12 +748,12 @@ def _off_but_observed(model: str, capability: ThinkingCapability | None) -> str: """ if capability is None: return ( - f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生," + f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生," f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。" f"请实测后用 register_capability 登记其真实能力" ) return ( - f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生," + f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生," f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。" f"能力表可能已过期——请复测后用 register_capability 更新登记" ) diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 01c879c..207fcaf 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import re import time +from dataclasses import replace from typing import TYPE_CHECKING, Any import httpx @@ -28,13 +29,19 @@ from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_ti from polygateway.thinking import ( ThinkingCapability, ThinkingUnsupportedError, + effective_effort, get_capability, observe_thinking, reconcile_thinking, resolve_thinking, ) from polygateway.transports._http_errors import compose_message, summarize_body -from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult +from polygateway.types import ( + Effort, + EmbeddingTransportResult, + SourceConfig, + TransportResult, +) if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Mapping @@ -327,7 +334,7 @@ class OpenAICompatTransport: # 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略, # 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制; # 分开维护的理由是语义,不是碰撞) - self._warned_mismatches: set[tuple[str, str, bool | None]] = set() + self._warned_mismatches: set[tuple[str, str, Effort | None]] = set() self._client_factory = client_factory or _default_client_factory self._clients: dict[str, httpx.AsyncClient] = {} @@ -346,7 +353,14 @@ class OpenAICompatTransport: profile: ProviderProfile, stream: bool, overlay: dict[str, Any], - ) -> dict[str, Any]: + reasoning_effort: Effort | None, + ) -> tuple[dict[str, Any], Effort | None]: + """组装请求体,并交回本次**实际**发出去的档位(`None` = 未表态,不注入)。 + + 返回二元组而非只返回 payload: 实际档在 `nearest` 映射后与请求档分叉,而 + 除本函数外没有第二处知道映射结果——不交出去,遥测就只能事后再算一遍, + 算出来必是请求档。 + """ payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream} if stream: payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款) @@ -355,20 +369,28 @@ class OpenAICompatTransport: capability = get_capability(source.model, table=self._capabilities) 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, - ) + # 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。 + # 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错" + resolution = resolve_thinking( + profile, + capability, + effective_effort( + request_effort=reasoning_effort, + source_effort=source.reasoning_effort, + enable_thinking=source.enable_thinking, + ), + model=source.model, + # 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确 + # 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出) + fallback=source.effort_fallback, + warn_unregistered=first_time, ) + payload.update(resolution.payload) # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级 # overlay(含结构化注入)在后覆盖之。两行不可调换 payload.update(source.extra_body) payload.update(overlay) - return payload + return payload, resolution.applied_effort async def complete( self, @@ -378,12 +400,22 @@ class OpenAICompatTransport: stream: bool, overlay: dict[str, Any], call_id: str, + reasoning_effort: Effort | None, ) -> TransportResult: - """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。""" + """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。 + + `reasoning_effort` 是**请求级**档位(`None` = 不表态);它与源级配置的优先级 + 在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。 + """ profile = get_provider(source.provider, registry=self._registry) try: - payload = self._build_payload( - messages=messages, source=source, profile=profile, stream=stream, overlay=overlay + payload, applied_effort = self._build_payload( + messages=messages, + source=source, + profile=profile, + stream=stream, + overlay=overlay, + reasoning_effort=reasoning_effort, ) except ThinkingUnsupportedError as exc: # 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个 @@ -408,27 +440,37 @@ class OpenAICompatTransport: except httpx.TransportError as exc: # VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8) raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc - # 此处是唯一同时握有请求方向与响应结果的地方,对账只能落在这里 + # 实际发出去的档只有 `_build_payload` 知道,而组装 TransportResult 的两条 + # 路径都在更深一层。在此唯一汇合点补齐,好过给两条路径各加一个参数——那正是 + # 遥测那边被明令禁止的"复制参数列表"形态,两条路径迟早只改一条 + result = replace(result, applied_effort=applied_effort) + # 此处是唯一同时握有请求档位与响应结果的地方,对账只能落在这里 self._warn_on_thinking_mismatch(source, result) return result def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None: - """声明与观测矛盾即 warning;按 (source, model, direction) 节流,同组合只喊一次。 + """声明与观测矛盾即 warning;按 (source, model, 实际档位) 节流,同组合只喊一次。 - 三段缺一不可。**方向**: 同一模型的开、关两档是两个独立的矛盾。**源名**: - 多源多账号是本库的核心场景,同一 model 跨 N 个源是常态,而每个源背后是 - 独立的账号/网关,一个源的行为不代表另一个——漏掉源名,5 个源里第一个出 - 问题的喊完一次,其余四个永久静音。逐次调用刷屏会把告警变成噪声,噪声等于 - 没有告警。 + 三段缺一不可。**档位**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个 + 键会让第二个永久静音(旧版拿 `enable_thinking` 当第三段,而档位根本不经过 + 那个字段,于是同一模型的所有档共用一个键)。**源名**: 多源多账号是本库的 + 核心场景,同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,一个 + 源的行为不代表另一个——漏掉源名,5 个源里第一个出问题的喊完一次,其余四个 + 永久静音。逐次调用刷屏会把告警变成噪声,噪声等于没有告警。 + + 档位取 `result.applied_effort`(真正发出去的那一档)而非请求档: `nearest` + 映射后二者分叉,而对账问的是"我发出去的要求有没有被满足"——拿一个从未发出 + 过的档去对账,文案与键都指向了一次不存在的请求。被映射到同一档的两个请求 + 因此共用一个键,这正是它们该有的关系(同一条实际要求,同一个矛盾)。 **先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串, 而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。 """ - key = (source.name, source.model, source.enable_thinking) + key = (source.name, source.model, result.applied_effort) if key in self._warned_mismatches: return message = reconcile_thinking( - enable_thinking=source.enable_thinking, + effort=result.applied_effort, observation=result.thinking_observation, capability=get_capability(source.model, table=self._capabilities), model=source.model, diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 56d6af0..2857d8e 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -18,6 +18,9 @@ from loguru import logger _MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"}) +_EFFORT_FALLBACK_DOMAIN = frozenset({"error", "nearest"}) +"""`SourceConfig.effort_fallback` 的值域: 请求档打空时报错,还是映射到最近的档。""" + _PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType( { "model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算", @@ -167,6 +170,89 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None: return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False) +class Effort(StrEnum): + """推理强度档位的封闭词汇(设计 §3.1)。 + + 取值直接写进请求体(`reasoning_effort` 等键),**改名即改变发出去的字节**, + 且会进缓存 key 与遥测落库,历史数据会断层。 + + 八档而非六档: `none`(不推理)与 `auto`(推理,档位由模型自定)必须同时存在。 + `auto` 不可省——newapi 上 26 个可调用模型里有 9 个是**纯开关型**(qwen 五个、 + MiniMax-M3、glm-5/5.1/4.6v),它们能开推理却没有强度档可填;没有 `auto` 就只 + 能拿某个强度档冒充"开",而那正是本次要修的病根(旧 `thinking_on` 硬编码 + `medium`,可 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在)。 + + 词汇取四家参考实现共同收敛的一套(cherry-studio 的 canonical selection、 + OpenRouter 的 `supported_efforts`、LiteLLM 的 `reasoning_effort_levels`、 + new-api 的 `relayconvert/reasoning`),不自创。 + + **枚举定义在最内层而非决策层**: 它是 `SourceConfig`/`ChatRequest`/ + `LLMResponse` 的字段类型,放进 `thinking.py` 会让 `types.py` 反向 import + 决策模块(P7 依赖铁律),与 `ThinkingObservation` 同一理由。 + """ + + NONE = "none" + AUTO = "auto" + MINIMAL = "minimal" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + XHIGH = "xhigh" + MAX = "max" + + +EFFORT_ORDER: tuple[Effort, ...] = ( + Effort.NONE, + Effort.MINIMAL, + Effort.LOW, + Effort.MEDIUM, + Effort.HIGH, + Effort.XHIGH, + Effort.MAX, +) +"""由弱到强的强度序;`AUTO` **不在其中**——它是"由模型自定",在强弱轴上没有位置。 + +供能力表求"最省的开启档"与 `nearest` 映射取最近档。公开(非 `_` 前缀)是因为 +`thinking.py` 要跨模块消费它,跨模块引用私有名是坏味道。 +""" + + +def coerce_effort(raw: Any, *, origin: str) -> Effort: + """把外部传入的档位**归一**成 `Effort`;非法值报 `ValueError` 并列全八档。 + + 存在的理由是"归一化点必须在入口":库内一律用 `is Effort.NONE` 做身份比较 + (枚举成员唯一,`is` 比 `==` 更能表达"就是这一档"),而 `Effort` 是 `StrEnum` + ——下游从 JSON/配置/命令行读出来的天然是裸字符串,`"none" is Effort.NONE` + 恒为假。不在入口归一,身份比较就会在**错误路径上**误判(把一致的配置判成 + 矛盾),随后拼错误文案时再 `.value` 抛 `AttributeError`,连承诺的 `ValueError` + 都拿不到(2026-09-05 独立验证实测)。 + + 故裸字符串**接受并归一**而非拒收: 拒收会把 `.env` 之外的两条装配路(工厂 / + 构造函数全量注入,CLAUDE.md §4.5)口径劈成两半,而 `.env` 那条早已是"解析即 + 归一"。`strip().lower()` 与 `config._to_effort` 同口径,理由同样是配置里的 + 行尾空格与大写写法是常态,而档位取值本身没有大小写语义。 + + `origin` 指回具体的配置项或调用点: 档位在源级、请求级两处都能配,只说 + "非法档位"要人自己去找是哪一处填错了。传空串表示调用方自己会补上下文 + (`config._cast` 的 `配置 X 解析失败` 已经说了是哪个 env 键)。 + """ + if isinstance(raw, Effort): + return raw + prefix = f"{origin}: " if origin else "" + listed = ", ".join(e.value for e in Effort) + if isinstance(raw, str): + try: + return Effort(raw.strip().lower()) + except ValueError: + # 不 `from exc`: 枚举原生的 "'lowest' is not a valid Effort" 只是同一 + # 件事的英文复述,链上去反而把可操作的那句挤到后面 + raise ValueError(f"{prefix}非法推理档位 {raw!r};允许: {listed}") from None + raise ValueError( + f"{prefix}推理档位必须是 Effort 或其字面量字符串," + f"收到 {type(raw).__name__}: {raw!r};允许: {listed}" + ) + + class ThinkingObservation(StrEnum): """一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。 @@ -240,6 +326,16 @@ class LLMResponse: 实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。 要判"确实没推理"只认 `ABSENT`(上游明确上报 0)。""" + applied_effort: Effort | None = None + """本次调用**真正发出去**的推理档位(issue #20);`None` = 调用方未表态。 + + 与 `ChatRequest.reasoning_effort`(请求档)可能分叉: 源上配了 + `EFFORT_FALLBACK=nearest` 时,请求 `medium` 而模型只有 low/high/max,实际发 + 出的是 `low`。遥测按本字段分组,记请求档会把整行挂在一个从未发出过的档下。 + + `None` 不是"没推理": 库不表态时也不推定模型自己的默认档——"没看见"不许说成 + "发生了"(同 `thinking_observation` 的 `UNKNOWN` 一脉)。""" + @dataclass(frozen=True) class ChatRequest: @@ -275,6 +371,17 @@ class ChatRequest: 再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非 语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。""" + # —— 请求级推理档位(issue #20;追加在末尾,不扰动既有字段的位置构造)—— + reasoning_effort: Effort | None = None + """本次调用要求的推理档位,压过源级默认(设计 §4.2 的最高优先级层)。 + + `None` 是**不表态**(随源级配置),与 `Effort.NONE`("要求不推理")严格区分: + 把前者读成后者会让一次没写档位的调用悄悄关掉源上配好的推理。 + + 独立成字段而非塞进 `overlay`: `overlay` 是采样参数的直通层,库不解释其内容, + 而档位要经能力表校验、要进缓存 key、要落遥测——混进直通层等于放弃这三样, + 正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。""" + @dataclass(frozen=True) class Usage: @@ -341,6 +448,13 @@ class TransportResult: 默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默 时,不该替上游做出"没推理"这个它从未做过的声明。""" + applied_effort: Effort | None = None + """本次调用真正发出去的推理档位(issue #20),由做注入的 transport 填。 + + 只有做了注入的那一层知道它: `nearest` 映射后请求档与实际档分叉(请求 + `medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是 + "未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。""" + @dataclass(frozen=True) class SourceConfig: @@ -348,6 +462,10 @@ class SourceConfig: 限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、 True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。 + + 2026-09-04 起 `enable_thinking` 降级为 `reasoning_effort` 的语法糖 + (`True`→`AUTO`、`False`→`NONE`),保留不删是因为它已被三项目消费 + (迁移兼容约束,ARCH §5.1)。两个字段说的是同一件事,故矛盾即报错。 """ name: str @@ -372,10 +490,29 @@ class SourceConfig: (加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法; 要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`。""" + reasoning_effort: Effort | None = None + """本源默认的推理档位;None = 不表态(与 `Effort.NONE`「要求不推理」不同)。 + + 裸字符串(`"low"`、`" LOW "`)也收,构造期由 `coerce_effort` 归一成 `Effort`, + 非法值当场 `ValueError` 并列出八档;**构造完成后本字段一定是 `Effort`**,库内 + 的 `is Effort.NONE` 身份比较依赖这条不变式。 + + **追加在末尾**是硬要求:三项目的测试按位置构造 fake,插在中间会静默错位 + (本模块头部 docstring 的字段保序约定)。""" + + effort_fallback: str = "error" + """请求档打空时的处置: `error`(默认,报错)或 `nearest`(映射到最近的档)。 + + 默认报错的理由是钱: 一次静默的 `medium → max` 在 GLM-5.3 上是数倍账单 + (P5「严禁默认值掩盖错误」)。值域在此把关而非交给 `resolve_thinking`—— + 后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果 + 就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。""" + def __post_init__(self) -> None: self._validate_identity() self._validate_gates() self._validate_watchdog() + self._validate_thinking() self._freeze_extra_body() def effective_est_tokens(self) -> int: @@ -413,6 +550,52 @@ class SourceConfig: ): raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s") + def _validate_thinking(self) -> None: + """推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。 + + 归一化必须先于下面的矛盾判定: 判据用的是 `is Effort.NONE`,而本类是公共 + 入口,`reasoning_effort="none"` 这种裸字符串写法(从 JSON/配置读出来的 + 常态)会让它误判成矛盾,再拼文案时 `.value` 直接 `AttributeError`。同一 + 理由也适用于下游读侧——归一化后库内一律是 `Effort`,`is` 比较才安全。 + + 矛盾**报错而非「后者赢」**: `enable_thinking` 与 `reasoning_effort` 表达的是 + 同一件事,静默取其一等于替下游猜它到底想要哪个,而猜错的代价是账单—— + 猜成开启就是白花钱,猜成关闭就是拿到一个没推理过的答案。 + + 判据是「二者是否都在说关闭」: `enable_thinking is False` 与 + `reasoning_effort is NONE` 必须同真同假。`True` + 某个开启档(如 `low`) + 不算矛盾,那只是把同一件事说了两遍,且后者更精确。 + """ + if self.reasoning_effort is not None: + # frozen dataclass 改字段走 object.__setattr__(同款先例: _freeze_extra_body) + object.__setattr__( + self, + "reasoning_effort", + coerce_effort( + self.reasoning_effort, origin=f"SourceConfig({self.name}).reasoning_effort" + ), + ) + if isinstance(self.effort_fallback, str): + # 与相邻的 `REASONING_EFFORT` 同口径: `.env` 里的行尾空格与大写写法是 + # 常态,而 `nearest`/`error` 本身没有大小写语义。归一化放在值域校验的 + # 同一处(而不是 env 解析处),三条配置路一并覆盖 + object.__setattr__(self, "effort_fallback", self.effort_fallback.strip().lower()) + if self.effort_fallback not in _EFFORT_FALLBACK_DOMAIN: + raise ValueError( + f"SourceConfig.effort_fallback(EFFORT_FALLBACK)非法值 " + f"{self.effort_fallback!r};允许: {sorted(_EFFORT_FALLBACK_DOMAIN)}" + ) + if self.enable_thinking is None or self.reasoning_effort is None: + return + if (self.enable_thinking is False) != (self.reasoning_effort is Effort.NONE): + raise ValueError( + f"源 {self.name!r} 的 enable_thinking={self.enable_thinking} 与 " + f"reasoning_effort={self.reasoning_effort.value!r} 相互矛盾: " + f"enable_thinking 已是 reasoning_effort 的语法糖" + f"(True={Effort.AUTO.value}、False={Effort.NONE.value})。" + f"请只保留其中一个,或让两者语义一致" + ) + def _freeze_extra_body(self) -> None: """校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。""" validated = validate_request_overlay( diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py index db35e85..a359b60 100644 --- a/tests/e2e/test_thinking_live.py +++ b/tests/e2e/test_thinking_live.py @@ -26,12 +26,15 @@ 源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。 """ +import asyncio import dataclasses import json import os from collections import Counter +from collections.abc import Mapping from datetime import datetime from pathlib import Path +from types import MappingProxyType import pytest from dotenv import dotenv_values @@ -39,11 +42,13 @@ from dotenv import dotenv_values from polygateway import GatewayClient, GatewaySettings, ThinkingObservation from polygateway.errors import ( AllSourcesExhausted, + GatewayUnavailableError, RequestRejectedError, SourceDeadError, TransientError, ) -from polygateway.thinking import DEFAULT_CAPABILITIES, get_capability +from polygateway.thinking import DEFAULT_CAPABILITIES, ThinkingCapability, get_capability +from polygateway.types import EFFORT_ORDER, Effort _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) @@ -77,7 +82,28 @@ _MODEL_PROVIDER = { "MiniMax-M2.7": "minimax", "MiniMax-M2.5": "minimax", "qwen3.7-plus": "qwen", + "qwen3.7-max": "qwen", + "qwen3.6-plus": "qwen", + "qwen3.5-flash": "qwen", + "qwen-plus-latest": "qwen", "deepseek-v4-pro": "deepseek", + "deepseek-v4-flash": "deepseek", + "deepseek-v4-flash-vision-exp": "deepseek", + "glm-5.3": "zhipu", + "glm-5.3-flash": "zhipu", + "glm-5.2": "zhipu", + "glm-5.1": "zhipu", + "glm-5": "zhipu", + "glm-4.6v": "zhipu", + "kimi-k3": "moonshot", + "kimi-for-coding": "moonshot", + "gpt-5.4": "openai", + "gpt-5.5": "openai", + "claude-opus-5": "anthropic", + "claude-sonnet-5": "anthropic", + "claude-haiku-5": "anthropic", + "gemini-3.1-pro": "google", + "gemini-3-flash": "google", } @@ -514,7 +540,534 @@ class TestAssemblyGuardAgainstRealConfig: stream=True, overlay={}, call_id="e2e-guard", + reasoning_effort=None, ) finally: await client.aclose() _record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类") + + +# ══════════════════════════════════════════════════════════════════════════════ +# T10: 逐模型档位实测(方法论沿用 issue #20) +# +# 本节与上面的 L1-L9 分工不同: 上面验的是**库的行为**(注入到没到、观测准不准), +# 这里验的是**能力表的内容**(`DEFAULT_CAPABILITIES` 里那 20 多条声明是不是真的)。 +# 二者判据可以共用,数据源却必须分开——能力表实测要**绕过能力表**才有意义, +# 否则拿待验证的声明去挡请求,等于用结论证明前提。 +# +# 判据(三条,均沿用已有纪律): +# ① 关闭方向: 每轮 `thinking_observation != OBSERVED` 才算真关掉;任一轮 +# OBSERVED 即证伪(推理正文是事实本身,不需要多数票)。 +# ② **短提示词的"关掉了"必须经长上下文复核**: issue #20 实测 GLM 系在短提示词 +# 下 reasoning_tokens≈1.2 像是关了,5552 token 长上下文下跳到 0/54/167 即露馅。 +# 短提示词下推理量本就趋近于 0,分不出"关了"与"没什么可想的"。 +# ③ 开启方向: 多数轮 OBSERVED(单轮抖动不判红,与 L2 同口径)。 +# ④ 关闭结论**不许只靠 `UNKNOWN`**: 上游整片不回传推理信号时(kimi、MiniMax 两路 +# 都是),"没看见"不是"没发生"。此时补一个不含魔数的锚点——关闭档的 +# `completion_tokens` 必须严格小于 `max` 档,否则结论记为「判不出来」。 +# ══════════════════════════════════════════════════════════════════════════════ + +_TIER_OUT_DIR = Path("tests/outputs/thinking") +_TIER_ROUNDS = int(os.environ.get("PGW_E2E_TIER_ROUNDS", "5")) +_TIER_LONG_ROUNDS = int(os.environ.get("PGW_E2E_TIER_LONG_ROUNDS", "3")) +# 共用生产网关,宁慢勿冲(人类 2026-09-05 指令): 默认 3,可下调不建议上调 +_TIER_CONCURRENCY = int(os.environ.get("PGW_E2E_TIER_CONCURRENCY", "3")) + +# 固定短提示词: 答案本身约 4 token,推理 token 的信噪比高(issue #20 同款) +_TIER_PROMPT = "23 乘以 47 等于多少?只回答一个数字,不要解释。" + +# 长上下文对照组(判据②)。填充文本与题目无关且不含任何业务领域词汇(零业务假设 +# 铁律),只为把输入撑到数千 token;题目放在最后,避免被当成"读完就忘"的前缀 +_TIER_LONG_PROMPT = ( + "\n".join( + f"{i:04d}. 这是一段与题目无关的填充文字,仅用于把上下文撑到数千 token," + "以复核短提示词下得到的关闭结论在长上下文下是否依然成立。" + for i in range(120) + ) + + "\n\n" + + _TIER_PROMPT +) + +_ALL_EFFORTS: tuple[Effort, ...] = (*EFFORT_ORDER, Effort.AUTO) + +_PROBE_ROWS: list[dict] = [] + + +def _tier_settings(model: str) -> GatewaySettings: + """探测用配置: 生产口径的超时,但**重试预算压到 1 次**。 + + 压重试是因为探测里"这一轮失败"本身就是数据(逐轮进报告),库替它重试只会 + 把"渠道当下不可用"变成三倍等待——2026-09-05 实测 claude 系 7 天限额用尽时 + 每轮 429,三次重试让单个模型阻塞三分钟以上,26 个模型跑不完。 + + **单次请求的超时不动**(仍是 .env 的生产值 300s): §4.6 那条"测试超时不得紧于 + 生产配置"防的是把慢而正常的模型误判成不可用,那个风险在这里照旧存在。重试次数 + 与背压窗口不属于同一类——它们决定"失败之后还等多久",而不是"多慢算失败"; + 一个真在出字的模型永远碰不到这两者。 + """ + base = GatewaySettings.from_env( + "LLM", + env={ + **_ENV, + "PGW_CACHE_BACKEND": "none", + "LLM_MAX_RETRIES": "1", + # 探测是**单源**的,没有别的源可换。生产值 1200s 的 stall window 在这里 + # 只会把"这个模型当下不可用"拖成 20 分钟一轮: 2026-09-05 实测 claude 系 + # 7 天限额用尽返回 429 且不带 Retry-After,库据此判"无可运行源"并按背压 + # 语义等到窗口耗尽(实测把窗口调到 45s 即在 46.7s 报 stalled)。多源生产 + # 场景下这段等待是有意义的(等别的源恢复),探测场景下等不到任何东西 + "LLM__BACKPRESSURE__STALL_WINDOW_S": "60", + }, + ) + source = dataclasses.replace( + base.sources[0], + provider=_MODEL_PROVIDER[model], + model=model, + enable_thinking=None, + reasoning_effort=None, + ) + return dataclasses.replace(base, sources=(source,)) + + +def _probe_capabilities(model: str) -> dict[str, ThinkingCapability]: + """临时全档能力表: **实测的对象正是能力表本身**,不能拿它当前提去挡请求。 + + 不传 `capabilities={}`(即"未登记")的理由是噪声: 那条路会走 Phase 3,每轮都 + warning 一句"能力未登记",几百轮下来把真正的告警淹没。全档表让五关全部放行, + 请求原样发出去,由上游而不是由库来回答"这一档到底行不行"。 + """ + return {model: ThinkingCapability(_ALL_EFFORTS, evidence="T10 实测临时表(不进 DEFAULT)")} + + +async def _probe_effort( + model: str, effort: Effort, *, rounds: int, prompt: str, prompt_kind: str +) -> list[dict]: + """对一个 (模型, 档位) 打 N 轮真实请求,逐轮记录;失败轮记 `error` 而不冒泡。 + + 失败不冒泡是本函数与 `_run_rounds` 的唯一区别: 这里"上游拒绝这一档"本身就是 + **实测结论**(HTTP 400 = 该档不被接受),把它抛出去会让数据采集半途而废。 + 只吞四分类与 `AllSourcesExhausted`——库自身的 `ValueError` 等仍然冒泡,那是 + bug 不是数据。 + """ + client = GatewayClient.from_settings( + _tier_settings(model), capabilities=_probe_capabilities(model) + ) + semaphore = asyncio.Semaphore(_TIER_CONCURRENCY) + + async def _one(index: int) -> dict: + base = {"round": index + 1, "effort": effort.value, "prompt_kind": prompt_kind} + async with semaphore: + try: + resp = await client.chat( + [{"role": "user", "content": prompt}], + stream=True, + reasoning_effort=effort, + cache_salt=f"tier-probe-{model}-{effort.value}-{prompt_kind}-{index}", + ) + except ( + RequestRejectedError, + GatewayUnavailableError, + SourceDeadError, + TransientError, + ) as exc: + # 捕 `GatewayUnavailableError` 而不是只捕 `AllSourcesExhausted`: + # 某个模型在网关上不通时,连续失败会把熔断门打开,后续轮次抛的是 + # `CircuitOpenError`(同一父类的兄弟)。只捕子类会让"源不可用"这 + # 件事在第 N 轮换个类型冒出去,把数据采集打断成一次红测 + return {**base, "error": f"{type(exc).__name__}: {str(exc)[:160]}"} + return { + **base, + "error": None, + "prompt_tokens": resp.prompt_tokens, + "completion_tokens": resp.completion_tokens, + "reasoning_tokens": resp.reasoning_tokens, + "thinking_chars": len(resp.thinking), + "thinking_observation": resp.thinking_observation, + "applied_effort": resp.applied_effort, + # 核对模型身份: issue #20 记录本渠道对 glm-5.2 的请求 6/6 回报 + # model=glm-5.3。凡结论依赖模型身份的,对不上即数据不可信 + "model_reported": resp.model_reported, + "content": resp.content[:40], + } + + try: + return list(await asyncio.gather(*(_one(i) for i in range(rounds)))) + finally: + await client.aclose() + + +def _probe_ok(obs: dict) -> bool: + return obs["error"] is None + + +def _probe_quiet(obs: dict) -> bool: + """成功且未观测到推理(判据①的满足条件);失败轮不算"安静",它没有观测。""" + return _probe_ok(obs) and obs["thinking_observation"] != ThinkingObservation.OBSERVED + + +def _probe_observed(obs: dict) -> bool: + return _probe_ok(obs) and obs["thinking_observation"] == ThinkingObservation.OBSERVED + + +def _rt_summary(observations: list[dict]) -> str: + """报告里的一行摘要: rt 观测值序列 + 裁定分布 + 身份核对,三样缺一不可复核。""" + ok = [o for o in observations if _probe_ok(o)] + if not ok: + return f"全部 {len(observations)} 轮失败: {observations[0]['error']}" + rts = [o["reasoning_tokens"] for o in ok] + verdicts = Counter(str(o["thinking_observation"]) for o in ok) + reported = sorted({str(o["model_reported"]) for o in ok}) + failed = len(observations) - len(ok) + tail = f";{failed} 轮失败" if failed else "" + return ( + f"rt={rts};裁定 {dict(verdicts)};thinking_chars=" + f"{[o['thinking_chars'] for o in ok]};model_reported={reported}{tail}" + ) + + +# 已知的合法别名: 供应商回报的名字与配置里的别名本就可以不同(月之暗面回 +# `k3`、Google 回 `-preview` 后缀)。**显式登记而不是按前缀猜**——猜的话 +# `glm-5.2 → glm-5.3` 这种真·串台也会被当成"同族别名"放过,而那正是本表要抓的 +_MODEL_REPORTED_ALIASES: Mapping[str, frozenset[str]] = MappingProxyType( + { + "kimi-k3": frozenset({"k3"}), + "kimi-for-coding": frozenset({"k3"}), + "gemini-3-flash": frozenset({"gemini-3-flash-preview"}), + "gemini-3.1-pro": frozenset({"gemini-3.1-pro-preview"}), + } +) + + +def _identity_mismatch(model: str, observations: list[dict]) -> list[str]: + """响应体里的 `model` 与请求的模型对不上 → 本次数据说的不是这个模型。 + + issue #20 就栽在这里: 该渠道对 `glm-5.2` 的请求 6/6 回报 `model=glm-5.3`, + 照单全收的话,能力表里 glm-5.2 那一行记的其实是 glm-5.3 的行为。凡结论依赖 + 模型身份的,对不上就必须当场作废,而不是打个折扣继续用。 + + `None`(上游未上报)不算不符: 那是"没说",不是"说了别的"。 + """ + allowed = {model, *_MODEL_REPORTED_ALIASES.get(model, frozenset())} + return sorted( + { + o["model_reported"] + for o in observations + if _probe_ok(o) + and o["model_reported"] is not None + and o["model_reported"] not in allowed + } + ) + + +async def _anchor_off_against_on( + model: str, off_observations: list[dict] +) -> tuple[list[dict], Effort | None, bool]: + """判据④: 拿"开启档的 completion 明显更大"给关闭结论补一个正面证据。 + + 需要它是因为 `UNKNOWN` 的语义: 它是"本次没有任何信号,判不出来",不是"没推理" + (`observe_thinking` 的 docstring 把这条写死了)。kimi 与 MiniMax 这两路上游都 + 不回传 `completion_tokens_details`,关闭档整片 `UNKNOWN`——此时若直接把"没看见" + 读成"关掉了",库就会登记一个自己从未验证过的 `none`,而下游据此以为省了钱。 + + 锚点取 `completion_tokens` 的相对比较(关闭档最大值 < 开启档最小值),**不含 + 任何魔数**: 推理段计在 completion 里,真开着时两档差一个数量级(实测 kimi-k3 + 关闭档恒 9 token)。取 `max` 档而非 `auto`: 后者在 `on_base={}` 的 provider 上 + 等于"什么都不注入",那是模型默认档而不是"开",拿它当对照组会把 M3 这种默认不推理的 + 模型判成"分不开"(minimax 段已按 issue #21 改回带 medium,openai/anthropic/google + 三段仍是空片段,故该风险仍在)。`max` 打不通时才退到 `auto`。 + """ + off_usable = [o for o in off_observations if _probe_ok(o)] + for tier in (Effort.MAX, Effort.AUTO): + anchor = await _probe_effort( + model, + tier, + rounds=_TIER_LONG_ROUNDS, + prompt=_TIER_PROMPT, + prompt_kind=f"anchor({tier.value})", + ) + on_usable = [o for o in anchor if _probe_ok(o)] + if not on_usable: + continue + off_max = max(o["completion_tokens"] for o in off_usable) + on_min = min(o["completion_tokens"] for o in on_usable) + return anchor, tier, off_max < on_min + return [], None, False + + +def _probe_record(model: str, phase: str, verdict: str, detail: str, observations: list[dict]): + _PROBE_ROWS.append( + { + "model": model, + "provider": _MODEL_PROVIDER[model], + "phase": phase, + "verdict": verdict, + "detail": detail, + "observations": observations, + } + ) + + +@pytest.fixture(scope="module", autouse=True) +def _write_tier_report(): + """T10 报告独立成文件: 它的读者是"能力表该怎么改",与 L1-L9 的"库对不对"不同。""" + yield + if not _PROBE_ROWS: + return + _TIER_OUT_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = _TIER_OUT_DIR / f"tier_probe_{ts}.md" + lines = [ + "# 推理档位能力表实测(T10,经 new-api 中转)", + "", + f"- 时间: {ts}", + f"- 短提示词轮数: {_TIER_ROUNDS};长上下文复核轮数: {_TIER_LONG_ROUNDS};" + f"并发: {_TIER_CONCURRENCY}(共用生产网关,宁慢勿冲)", + f"- 短提示词: `{_TIER_PROMPT}`", + f"- 长上下文: 同题 + {len(_TIER_LONG_PROMPT)} 字符无关填充(判据②)", + "- 判据: 关闭方向要求**每轮**未观测到推理,且短提示词的「关掉了」必须经长上下文复核;" + "开启方向要求多数轮 OBSERVED", + "- 能力表在探测时被临时替换为全档表: 实测的对象正是它,不能拿它挡请求", + "", + "## 逐模型结论", + "", + "| 模型 | provider | 阶段 | 结论 | 观测 |", + "|---|---|---|---|---|", + ] + total = 0 + for row in _PROBE_ROWS: + detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:220] + lines.append( + f"| {row['model']} | {row['provider']} | {row['phase']} | {row['verdict']} | {detail} |" + ) + total += len(row["observations"]) + lines += ["", f"**总真实调用次数: {total}**", "", "## 逐轮原始观测", ""] + for row in _PROBE_ROWS: + if not row["observations"]: + continue + lines += [f"### {row['model']} — {row['phase']}", "", "```json"] + lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2, default=str)) + lines += ["```", ""] + path.write_text("\n".join(lines), encoding="utf-8") + print(f"\n[T10 报告] {path}") + + +class TestTierProbe: + """能力表实测。可只跑单个模型: `-k "test_t10 and glm-5.3"`。""" + + @pytest.mark.parametrize("model", sorted(_MODEL_PROVIDER)) + async def test_t10_none_direction_matches_declaration(self, model): + """「这个模型到底关不关得掉」——能力表里唯一会**报错**的那条声明。 + + 它是本节最要紧的一条: `Effort.NONE` 在不在清单里,决定 Phase 4 是放行还是 + 当场报错。声明错了,两个方向的代价都很实在——多写了 `none` 会让下游以为 + 关掉了(issue #20 的静默失效),漏写了会把一条本来可用的路堵死。 + """ + short = await _probe_effort( + model, Effort.NONE, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="short" + ) + # 上游拒绝这一档(400)是**结论**而非故障: 它等价于"关不掉"; + # 其余失败(渠道下线/超时)才是源不可用,按既有纪律记为未覆盖 + rejected = [o for o in short if o["error"] and o["error"].startswith("RequestRejected")] + usable = [o for o in short if _probe_ok(o)] + # **按可用轮判,而不是一有失败就整条跳过**: 共用网关上偶发 429/503 是常态, + # 一票否决会让整张表因为一次抖动而没有数据。样本低于 3 轮才是真的没结论 + if not rejected and len(usable) < min(3, _TIER_ROUNDS): + broken = [o for o in short if o["error"]] + _probe_record(model, "none 方向", "SKIP(源不可用)", _rt_summary(short), short) + pytest.skip(f"{model} 源不可用,已记为未覆盖: {broken[0]['error'][:120]}") + + strangers = _identity_mismatch(model, short) + if strangers: + _probe_record( + model, + "none 方向", + "SKIP(身份不符,数据不可信)", + f"该渠道把请求回报成 {strangers};{_rt_summary(short)}", + short, + ) + pytest.skip(f"{model} 被该渠道路由到 {strangers},本次观测说的不是这个模型") + + observations = list(short) + measured_can_disable = not rejected and all(_probe_quiet(o) for o in usable) + note = "" + if measured_can_disable: + # 判据②: 短提示词下"看起来关了"必须过长上下文这一关 + long_ctx = await _probe_effort( + model, + Effort.NONE, + rounds=_TIER_LONG_ROUNDS, + prompt=_TIER_LONG_PROMPT, + prompt_kind="long", + ) + observations += long_ctx + usable = [o for o in long_ctx if _probe_ok(o)] + if not usable: + note = ";长上下文复核未跑通,结论只在短提示词下成立" + else: + measured_can_disable = all(_probe_quiet(o) for o in usable) + note = ";长上下文复核" + ("同样未观测到推理" if measured_can_disable else "露馅") + + if measured_can_disable and not any( + o["thinking_observation"] is ThinkingObservation.ABSENT for o in observations + ): + # 判据④: 全程 `UNKNOWN` 时,"关掉了"是一句没有正面证据的话 + anchor, anchor_tier, separable = await _anchor_off_against_on(model, observations) + observations += anchor + if anchor_tier is None: + note += ";锚点未跑通,关闭结论缺正面证据" + elif separable: + note += f";锚点可分(关闭档 completion 严格小于 {anchor_tier.value} 档)" + else: + measured_can_disable = None + note += f";**锚点不可分**(与 {anchor_tier.value} 档的 completion 分不开),判不出来" + + detail = f"实测 can_disable={measured_can_disable}{note}。短: {_rt_summary(short)}" + ( + f" ‖ 后续: {_rt_summary(observations[len(short) :])}" + if len(observations) > len(short) + else "" + ) + if measured_can_disable is None: + _probe_record(model, "none 方向", "INCONCLUSIVE(无正面证据)", detail, observations) + pytest.skip(f"{model} 判不出来,已记为未覆盖: {detail[:160]}") + capability = get_capability(model) + if capability is None: + _probe_record(model, "none 方向", "DATA(未登记)", detail, observations) + pytest.skip(f"{model} 未登记(设计 §8 第三档),本条只采数据: {detail[:120]}") + agrees = measured_can_disable == capability.can_disable + _probe_record( + model, + "none 方向", + "PASS" if agrees else "FAIL(能力表已漂移)", + f"声明 can_disable={capability.can_disable};{detail}", + observations, + ) + assert agrees, ( + f"{model} 的能力表与实测不符: 声明 can_disable={capability.can_disable}," + f"实测 {measured_can_disable}。{detail}" + ) + + @pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES)) + async def test_t10_declared_tiers_actually_reason(self, model): + """已登记的每个**开启档**都必须被上游接受,且真的推理。 + + 证伪力只在"被拒"与"没推理"两件事上——**不断言档位之间的 rt 高低**: + 设计 §4.3 已定,同一档 rt 实测在 8~56 之间跳,拿它比大小必然是噪声。 + 故本条能证伪的是"登记了一个上游根本不认的档",不是"档位排序对不对"。 + """ + capability = get_capability(model) + tiers = [e for e in capability.supported_efforts if e is not Effort.NONE] + if not tiers: + pytest.skip(f"{model} 只登记了 none,没有开启档可验") + failures = [] + for tier in tiers: + observations = await _probe_effort( + model, tier, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="short" + ) + rejected = [ + o for o in observations if o["error"] and o["error"].startswith("RequestRejected") + ] + usable = [o for o in observations if _probe_ok(o)] + observed = [o for o in observations if _probe_observed(o)] + strangers = _identity_mismatch(model, observations) + if strangers: + # 与 none 方向同一条纪律: 回报的不是这个模型,这组数就不是它的 + _probe_record( + model, + f"档位 {tier.value}", + "SKIP(身份不符,数据不可信)", + f"该渠道把请求回报成 {strangers};{_rt_summary(observations)}", + observations, + ) + pytest.skip(f"{model} 被该渠道路由到 {strangers},本次观测说的不是这个模型") + if rejected: + verdict, problem = "FAIL(上游拒绝该档)", f"{tier.value}: 上游拒绝" + elif not usable: + verdict, problem = "SKIP(源不可用)", None + elif len(observed) * 2 > len(usable): + verdict, problem = "PASS", None + else: + verdict, problem = "FAIL(该档未推理)", f"{tier.value}: 多数轮未观测到推理" + if problem: + failures.append(problem) + _probe_record( + model, f"档位 {tier.value}", verdict, _rt_summary(observations), observations + ) + assert not failures, f"{model} 登记的档位与实测不符: {failures}" + + @pytest.mark.parametrize("model", ["gemini-3.1-pro", "gpt-5.5", "glm-5.3"]) + async def test_t10_no_opinion_stays_no_opinion(self, model): + """不表态时库**不推定**模型自己的默认档(Phase 1),顺带采下默认档的 rt 基线。 + + 为什么给这三个模型单列一条: 它们的「厂商默认档」是 evidence 里写着、却最容易 + 写错的一格(Gemini 3.1 Pro 官方文档说 HIGH、OpenRouter 说 medium,两源打架), + 而默认档写错会误导下游估成本。库本身不依赖这个值——**它不表态就什么都不注入**, + 这正是本条断言的东西;默认档的 rt 观测只作报告里的旁证,**不作断言**: 单一模型上 + rt 与档位没有可判定的函数关系(设计 §4.3),拿它反推默认档只能存疑,不能定论。 + + 2026-09-05: gemini 一路当下在本渠道上游报错,claude 一路 7 天限额用尽,故把 + 另两格换成当下可测的 gpt-5.5 与 glm-5.3;gemini 留着,渠道恢复即有数。 + """ + client = GatewayClient.from_settings( + _tier_settings(model), capabilities=_probe_capabilities(model) + ) + observations = [] + try: + for i in range(_TIER_ROUNDS): + try: + resp = await client.chat( + [{"role": "user", "content": _TIER_PROMPT}], + stream=True, + cache_salt=f"tier-default-{model}-{i}", + ) + except ( + RequestRejectedError, + GatewayUnavailableError, + SourceDeadError, + TransientError, + ) as exc: + observations.append( + { + "round": i + 1, + "effort": "(不表态)", + "prompt_kind": "short", + "error": f"{type(exc).__name__}: {str(exc)[:160]}", + } + ) + continue + observations.append( + { + "round": i + 1, + "effort": "(不表态)", + "prompt_kind": "short", + "error": None, + "prompt_tokens": resp.prompt_tokens, + "completion_tokens": resp.completion_tokens, + "reasoning_tokens": resp.reasoning_tokens, + "thinking_chars": len(resp.thinking), + "thinking_observation": resp.thinking_observation, + "applied_effort": resp.applied_effort, + "model_reported": resp.model_reported, + "content": resp.content[:40], + } + ) + finally: + await client.aclose() + usable = [o for o in observations if _probe_ok(o)] + if not usable: + _probe_record( + model, + "默认档基线(不表态)", + "SKIP(源不可用)", + _rt_summary(observations), + observations, + ) + pytest.skip(f"{model} 源不可用,已记为未覆盖: {observations[0]['error'][:120]}") + leaked = [o for o in usable if o["applied_effort"] is not None] + _probe_record( + model, + "默认档基线(不表态)", + "PASS" if not leaked else "FAIL(库替模型推定了默认档)", + _rt_summary(observations), + observations, + ) + assert not leaked, f"{model}: 不表态时 applied_effort 应为 None,实测 {leaked}" diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index 0159873..380e223 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -57,6 +57,7 @@ _EXPECTED_COLUMNS = [ "tenant_id", "meta", "thinking_observation", + "reasoning_effort", ] @@ -127,6 +128,8 @@ async def _record_minimal( "meta": "{}", # 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str "thinking_observation": "unknown", + # 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开) + "reasoning_effort": None, } fields.update(overrides) await recorder.record_llm_call(**fields) @@ -225,11 +228,12 @@ class TestObservabilityColumns: await _record_minimal( recorder, call_id="samp", sampling='{"seed": 42, "temperature": 0}' ) + await _record_minimal(recorder, call_id="tier", reasoning_effort="low") rows = await _fetch( sandbox.dsn, - "SELECT call_id, cached_prompt_tokens, model_reported, sampling FROM llm_calls " - "WHERE call_id = ANY($1::text[])", - ["hit", "zero", "model", "samp"], + "SELECT call_id, cached_prompt_tokens, model_reported, sampling, " + "reasoning_effort FROM llm_calls WHERE call_id = ANY($1::text[])", + ["hit", "zero", "model", "samp", "tier"], ) by_id = {r["call_id"]: r for r in rows} assert by_id["hit"]["cached_prompt_tokens"] == 64 @@ -239,6 +243,10 @@ class TestObservabilityColumns: # issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在) assert json.loads(by_id["samp"]["sampling"]) == {"seed": 42, "temperature": 0} assert by_id["hit"]["sampling"] is None + # issue #20: PG 侧同样要验档位读得回来——emitter 落的是裸 str, + # 若哪天回退成 `Effort` 实例,asyncpg 编码不保证接受,写入会整行降级 + assert by_id["tier"]["reasoning_effort"] == "low" + assert by_id["hit"]["reasoning_effort"] is None # 不表态是 NULL finally: await recorder.aclose() @@ -580,11 +588,13 @@ _PRE_TENANT_INSERT = ( ) -# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的三列 +# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的四列 # 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。 -# 去掉后的顺序与 DDL 逐字一致(这三列在 DDL 里本就排在末尾)。 +# 去掉后的顺序与 DDL 逐字一致(这四列在 DDL 里本就排在末尾)。 _PRE_TENANT_COLUMNS = [ - c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta", "thinking_observation") + c + for c in _EXPECTED_COLUMNS + if c not in ("tenant_id", "meta", "thinking_observation", "reasoning_effort") ] # 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个 @@ -693,7 +703,7 @@ class TestCallerDimensionsAcceptance: "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", schema, ) - # 22 → 25 个 recorder 字段(加 created_at 共 26 个物理列),且新列追加在末尾 + # 22 → 26 个 recorder 字段(加 created_at 共 27 个物理列),且新列追加在末尾 assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS rows = await _fetch( schema_dsn, @@ -869,7 +879,7 @@ class TestManualSchemaModeAcceptance: """22 字段旧表 + manual: 列一个不加,行照常落库,缺的三维度静默不写。 与 `test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照: - 同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 26 之别。 + 同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 27 之别。 """ schema_dsn, schema = pre_tenant_schema recorder = _recorder(schema_dsn, auto_migrate=False) @@ -898,8 +908,11 @@ class TestManualSchemaModeAcceptance: assert [m for m in captured_warnings if "补列失败" in m] == [] notices = [m for m in captured_warnings if "auto_migrate=False" in m] assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏 - # 逐字钉住三个维度: 前缀断言会让将来漏进告警的新列照样绿 - assert "以下维度不会被记录: tenant_id, meta, thinking_observation。" in notices[0] + # 逐字钉住四个维度: 前缀断言会让将来漏进告警的新列照样绿 + assert ( + "以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。" + in notices[0] + ) finally: await recorder.aclose() @@ -925,8 +938,11 @@ class TestManualSchemaModeAcceptance: assert recorder.telemetry_status.degraded is False notices = [m for m in captured_warnings if "auto_migrate=False" in m] assert len(notices) == 1 # 准备期一次,第二行不再重复 - # 逐字钉住三个维度: 前缀断言会让将来漏进告警的新列照样绿 - assert "以下维度不会被记录: tenant_id, meta, thinking_observation。" in notices[0] + # 逐字钉住四个维度: 前缀断言会让将来漏进告警的新列照样绿 + assert ( + "以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。" + in notices[0] + ) # 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名 assert ( "ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0] @@ -984,7 +1000,7 @@ class TestPublishedSchemaScript: await _execute_script(fresh_dsn, script) actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)] - # 物理列 = 25 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份 + # 物理列 = 26 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份 assert set(actual) == set(COLUMNS) | {"created_at"} # 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉 assert actual == _EXPECTED_COLUMNS diff --git a/tests/integration/test_redis_cross_connection.py b/tests/integration/test_redis_cross_connection.py index b3cd507..4b37cb6 100644 --- a/tests/integration/test_redis_cross_connection.py +++ b/tests/integration/test_redis_cross_connection.py @@ -73,7 +73,7 @@ class ScriptedTransport: self.hang = hang self.calls: list[str] = [] - async def complete(self, *, messages, source, stream, overlay, call_id): + async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort): self.calls.append(source.name) if self.hang: await asyncio.Event().wait() diff --git a/tests/unit/test_backpressure.py b/tests/unit/test_backpressure.py index bde7e93..479ea05 100644 --- a/tests/unit/test_backpressure.py +++ b/tests/unit/test_backpressure.py @@ -210,7 +210,7 @@ class ClockAdvancingTransport: self.clock = clock self.calls = [] - async def complete(self, *, messages, source, stream, overlay, call_id): + async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort): self.calls.append((source.name, call_id)) advance, action = self.script.pop(0) self.clock.advance(advance) diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index 290ba64..c83e423 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -11,7 +11,13 @@ from polygateway.backends.memory.cache import InMemoryCache from polygateway.errors import ResultInvalidError, TransientError from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages from polygateway.middleware.telemetry import TelemetryEmitter -from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation +from polygateway.types import ( + ChatRequest, + Effort, + LLMResponse, + SourceConfig, + ThinkingObservation, +) _MSGS = [{"role": "user", "content": "hi"}] @@ -114,6 +120,62 @@ class TestKeyFormula: "m", messages2, "p", None ) + def test_request_tier_changes_key(self): + """同 messages 跑 low 与 max 不得互相命中(issue #20;issue #4 的逐字翻版)。 + + 请求级档位必须**独立于** `model_fingerprint` 进 key: 后者是装配期算出的 + 集合级指纹,一次调用改档位不会让它变一个字节。 + """ + k_low = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.LOW) + k_max = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.MAX) + assert k_low != k_max + + def test_explicit_none_tier_is_not_the_absent_tier(self): + """`None`(不表态)与 `Effort.NONE`(要求不推理)是两个 key。 + + 二者合并即毒化: "没写档位"的调用会读到"明确关掉推理"那次的响应, + 而后者的内容恰恰是缺推理过程的。 + """ + assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key( + "m", _MSGS, "proj", None, reasoning_effort=Effort.NONE + ) + + def test_absent_tier_keeps_legacy_key(self): + """不表态档位时键形逐字不变,存量缓存不被本次升级全量作废。 + + golden 值与 `test_empty_sampling_keeps_legacy_key` 同源,取自加 + `reasoning_effort` 维度之前的实现,不得随实现漂移。 + """ + assert build_cache_key( + "qwen-max", + [{"role": "user", "content": "hi"}], + "proj", + None, + reasoning_effort=None, + ) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b") + + def test_declared_tier_key_is_a_golden(self): + """配了档位那一侧同样要有 golden: 字面量变了就是所有该档缓存冷启动。 + + 存量(不表态)那侧的 golden 由 `test_absent_tier_keeps_legacy_key` 守着, + 而"档位怎么写进 key"此前没有任何字面量断言——变异实测把 `str(...)` 换成 + `repr(...)`,全套件依然全绿(2026-09-05 独立验证查出)。 + """ + assert build_cache_key( + "qwen-max", + [{"role": "user", "content": "hi"}], + "proj", + None, + reasoning_effort=Effort.LOW, + ) == ("pgw:cache:21d7be93729635b27d4ee54e0e7e7310554bb04faf08919f8ce83794ac33f575") + assert build_cache_key( + "qwen-max", + [{"role": "user", "content": "hi"}], + "proj", + "s1", + reasoning_effort=Effort.NONE, + ) == ("pgw:cache:44f4f1ce1ee39e5003a27f4f21a531554cb54d1c66e936d11008d4ff06ddc5e6") + class _Terminal: def __init__(self, response): @@ -163,6 +225,21 @@ class TestCacheFlow: third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal) assert third.cache_hit is True and terminal.calls == 2 + async def test_differing_reasoning_effort_does_not_hit(self): + """接线门: `CacheMW` 必须把 `request.reasoning_effort` 传进 key 公式。 + + 只测 `build_cache_key` 不够——参数加了却没人传是本改动最可能的落地方式, + 那种缺口在公式层的用例里完全看不见。 + """ + backend = InMemoryCache() + mw = _mw(backend) + terminal = _Terminal(_resp()) + await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal) + await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.MAX), terminal) + assert terminal.calls == 2 # 两档各自回源 + third = await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal) + assert third.cache_hit is True and terminal.calls == 2 # 同档才命中 + async def test_structured_injection_does_not_pollute_key(self): """CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。""" backend = InMemoryCache() @@ -324,6 +401,69 @@ class TestThinkingObservationRehydration: assert hit.thinking_observation is ThinkingObservation.UNKNOWN +class TestAppliedEffortRehydration: + """issue #20: 实际档同样必须复活成枚举,理由与 `thinking_observation` 逐条相同。 + + JSON 里存的是 `StrEnum` 的字符串值;不转就复活成裸 str,而库内一路是 + `is Effort.LOW` 的身份比较——命中路径上会静默判否,且下游拿到的类型与字段 + 注解分叉。缓存是档位的**第三条入口**(另两条是 `.env` 解析与 `chat()` 参数), + 归一化不变式必须在这里也闭合。 + """ + + async def test_hit_replays_enum_instance_not_bare_str(self): + backend = InMemoryCache() + mw = _mw(backend) + terminal = _Terminal(_resp(applied_effort=Effort.LOW)) + await mw(ChatRequest(messages=_MSGS), terminal) + hit = await mw(ChatRequest(messages=_MSGS), terminal) + assert hit.cache_hit is True and terminal.calls == 1 + assert isinstance(hit.applied_effort, Effort) + assert hit.applied_effort is Effort.LOW + + async def test_unknown_tier_degrades_to_none_and_still_hits(self): + """域外档位降级为 None(=不知道这次跑在哪档),不作废内容完好的条目。 + + 降级方向与 `thinking_observation` 同源: 共用一个 Redis 的项目里,先升级 + 的那个可能写入本版没有的档位名,未升级的项目若判成未命中,两个版本就会 + 互相打对方的缓存。归因字段不该有能力废掉一条内容完好的响应。 + """ + backend = InMemoryCache() + mw = _mw(backend) + key = build_cache_key("m", _MSGS, "proj", None) + poisoned = dataclasses.asdict(_resp(content="from-a-newer-version")) + poisoned["applied_effort"] = "ultra" + poisoned.pop("structured_data", None) + await backend.set(key, json.dumps(poisoned), 3600) + terminal = _Terminal(_resp()) + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + resp = await mw(ChatRequest(messages=_MSGS), terminal) + finally: + logger.remove(sink_id) + assert terminal.calls == 0 and resp.cache_hit is True + assert resp.content == "from-a-newer-version" + assert resp.applied_effort is None + hits = [m for m in messages if "ultra" in m] + assert len(hits) == 1, f"域外档位必须单独告警一次,实得 {len(hits)} 条: {messages}" + assert "applied_effort" in hits[0] + assert [m for m in messages if "重建失败" in m] == [] + + async def test_legacy_entry_without_key_rehydrates_to_none(self): + """升级前写入的条目没有该键,必须照常复活并落到默认 None。""" + backend = InMemoryCache() + mw = _mw(backend) + key = build_cache_key("m", _MSGS, "proj", None) + legacy = dataclasses.asdict(_resp(content="legacy")) + legacy.pop("applied_effort") + legacy.pop("structured_data", None) + await backend.set(key, json.dumps(legacy), 3600) + terminal = _Terminal(_resp()) + hit = await mw(ChatRequest(messages=_MSGS), terminal) + assert hit.content == "legacy" and terminal.calls == 0 + assert hit.applied_effort is None + + class _BrokenBackend: async def get(self, key): raise ConnectionError("redis down") @@ -444,6 +584,7 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) # 截断确实发生了(否则本用例恒真) logged = json.loads(rec.rows[0]["messages"]) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index aa55ae2..1a4c35b 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -12,6 +12,7 @@ from polygateway import ( AllSourcesExhausted, GatewayClient, GatewaySettings, + RequestRejectedError, gather_bounded, ) from polygateway.backends.memory.breaker import InMemoryGate @@ -24,6 +25,7 @@ from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.types import ( BackpressurePolicy, BreakerConfig, + Effort, GlobalLimits, RetryPolicy, SourceConfig, @@ -198,6 +200,246 @@ class TestSamplingOverlay: assert [c["seed"] for c in captured] == [1, 2] +class TestReasoningEffortPriority: + """三层优先级: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。 + + 一律抓**真实请求体**而非只查 `ChatRequest` 字段: 档位的价值全在发出去的那几个 + 字节上,只断言中间态会让"字段填了但一路没人读"这种缺口继续通过测试——issue #20 + 的 `extra_body` 绕行正是这么长出来的。 + """ + + def _capturing_client(self, captured, **overrides): + def handler(request): + captured.append(json.loads(request.content)) + return _sse() + + return _client(handler=handler, **overrides) + + def _zhipu(self, **overrides): + # glm-5.3 的档位是 low/high/max(能力表已登记),zhipu 的 wire 三样俱全, + # 是唯一能同时看清"开启形态"与"档位键"的组合 + overrides.setdefault("model", "glm-5.3") + return _source(provider="zhipu", **overrides) + + async def test_request_effort_wins_over_source(self): + captured = [] + source = self._zhipu(reasoning_effort=Effort.LOW) + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.MAX) + assert captured[0]["reasoning_effort"] == "max" + assert captured[0]["thinking"] == {"type": "enabled"} + + async def test_none_request_does_not_clear_source(self): + """请求级不表态 ≠ 请求级要求"不推理": 前者必须让源级默认继续生效。""" + captured = [] + source = self._zhipu(reasoning_effort=Effort.LOW) + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}]) + assert captured[0]["reasoning_effort"] == "low" + + async def test_request_none_tier_is_an_opinion_not_an_absence(self): + """请求级 `none` 是"要求不推理",不得被当成"没表态"而回落到源级档位。""" + captured = [] + source = self._zhipu(model="glm-5.2", reasoning_effort=Effort.MAX) + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.NONE) + assert captured[0]["thinking"] == {"type": "disabled"} + assert "reasoning_effort" not in captured[0] + + @pytest.mark.parametrize( + ("provider", "model", "fragment"), + [ + ("qwen", "qwen-max", {"enable_thinking": True}), + ("deepseek", "deepseek-v4-pro", {"thinking": {"type": "enabled"}}), + ("zhipu", "glm-5.3", {"thinking": {"type": "enabled"}}), + ("moonshot", "kimi-k3", {"thinking": {"type": "enabled"}}), + ], + ) + async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment): + """存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。 + + **只覆盖 `on_base` 自己就说全了"开"的四段**。openai/anthropic/google 的开档 + 旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位——那是设计 + §4.2 声明过的**有意变更**(medium 在 GLM/kimi/deepseek 的档位表里根本不存在, + 是库替下游做的档位判断),不是本门要守的不变量;这三家的模型经 OpenRouter + 登记均为默认推理,不注入也仍是"开"。minimax 不在此列: 它的模型不满足该前提, + 已按 issue #21 改回 medium,由下一条用例单独守。 + + qwen/deepseek 两条字面量逐字取自升级前的 `ProviderProfile.thinking_on`; + zhipu/moonshot 升级前没有对应段,断言的是它们 2026-09-04 登记的形态。 + """ + captured = [] + source = _source(provider=provider, model=model, enable_thinking=True) + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}]) + body = captured[0] + assert {k: body[k] for k in fragment} == fragment + # `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档 + assert "reasoning_effort" not in body + + async def test_legacy_minimax_on_tier_actually_turns_reasoning_on(self): + """回归门(issue #21): minimax 段的存量 `ENABLE_THINKING=true` 必须真开推理。 + + 本次换代一度把这段的开启形态改成 `on_base={}`(什么参数都不注入),依据是 + "这些模型默认就推理,不注入也仍是'开'"。T10 真实网关实测推翻了该前提: + MiniMax-M3 不带任何推理参数时 5/5 轮**不推理**(六个强度值则全部生效)。 + 于是存量下游从"真开推理"静默变成"不推理",而 `resolve_thinking` 的 Phase 5 + 无条件放行 `auto`、能力表也堵不住这条路。 + + 断言落在**发出去的字节**上而非中间态: 静默不推理这件事只有在请求体里才看得见。 + """ + captured = [] + source = _source(provider="minimax", model="MiniMax-M3", enable_thinking=True) + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}]) + assert captured[0]["reasoning_effort"] == "medium" + + +class TestEffortFallbackWiring: + """源级 `effort_fallback` 必须真的走到 `resolve_thinking`(issue #20)。 + + 只测配置值域与纯函数是不够的: 变异实测显示把 `fallback=source.effort_fallback` + 换成硬编码 `"error"`,全套单测依然全绿——`nearest` 是人类明确要求实现的功能, + 没有端到端用例它会在零告警下变成死代码(2026-09-05 独立验证查出)。 + + **该形态有两道门,必须各测各的**: transport 那道决定发出去的字节,`_guard_thinking` + 那道决定装配能不能过。只钉住 transport,装配守卫退化成硬编码 `error` 时,配了 + `nearest` 的源会在**运行期本来跑得起来**的情况下于装配期当场被判死,而这半边 + 第一轮修复时正是漏掉的那半边(M22)。 + """ + + def _capturing_client(self, captured, **overrides): + def handler(request): + captured.append(json.loads(request.content)) + return _sse() + + return _client(handler=handler, **overrides) + + async def test_nearest_sends_the_mapped_tier(self): + """glm-5.3 只有 low/high/max: 请求 `medium` 等距,按"取弱"落到 low。""" + captured = [] + source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest") + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM) + assert captured[0]["reasoning_effort"] == "low" + + async def test_error_fallback_refuses_the_same_request(self): + """默认 `error` 必须报错而不是映射: 一次静默的换档就是一笔没打招呼的账单。""" + captured = [] + source = _source(provider="zhipu", model="glm-5.3") + async with self._capturing_client(captured, sources=[source]) as client: + with pytest.raises(RequestRejectedError, match="medium"): + await client.chat( + [{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM + ) + assert captured == [] # 请求根本没发出去 + + def test_nearest_survives_the_assembly_guard(self): + """装配守卫也必须用源级 `fallback`: 运行期能映射的源不该在装配期被判死。 + + 源级 `medium` + glm-5.3(只有 low/high/max)是 `nearest` 唯一能被观测到的 + 装配期形态——守卫硬编码 `error` 时,这次 `from_env` 会抛 + `ThinkingUnsupportedError` 而不是返回 client。 + """ + env = dict(_ENV) + for key in list(env): + if key.startswith("LLM__QWEN__"): + del env[key] + env.update( + { + "LLM__ZHIPU__1__BASE_URL": "https://gw.example/v1", + "LLM__ZHIPU__1__API_KEY": "sk-a", + "LLM__ZHIPU__1__MODEL": "glm-5.3", + "LLM__ZHIPU__1__TIMEOUT_S": "120", + "LLM__ZHIPU__1__REASONING_EFFORT": "medium", + "LLM__ZHIPU__1__EFFORT_FALLBACK": "nearest", + } + ) + client = GatewayClient.from_env("LLM", env=env) + assert isinstance(client, GatewayClient) + + +class TestAppliedTierReachesTheCaller: + """`LLMResponse.applied_effort` 报的是**真正发出去的**那一档(计划 T8-5)。 + + 对下游是新能力(它终于能知道这次跑在哪档),对遥测是前置条件: 记请求档会让 + 按档分组的压测把整行挂在一个从未发出过的档下,而那种数据错得看不出来。 + """ + + async def test_response_carries_the_mapped_tier(self): + """glm-5.3 无 `medium`: 开了 nearest 后实际跑的是 low,响应必须这么说。""" + source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest") + async with _client(sources=[source]) as client: + resp = await client.chat( + [{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM + ) + assert resp.applied_effort is Effort.LOW + + async def test_no_statement_leaves_the_field_none(self): + async with _client() as client: + resp = await client.chat([{"role": "user", "content": "hi"}]) + assert resp.applied_effort is None + + +class TestUnsupportedTierIsRefusedNotRetried: + """档位不可满足 = 请求本身的问题: 报 `RequestRejectedError`,不重试、不伤熔断。 + + 重试与换源都不会让它变对(设计 §10),而把它计进熔断更糟——一次配置错误会 + 把一个健康的源关掉,拖垮与推理无关的所有调用。 + """ + + async def test_tier_error_never_reaches_the_gateway_or_the_breaker(self): + sent = [] + + def handler(request): + sent.append(request) + return _sse() + + # 阈值取 1: 只要这次失败被计进熔断,门当场开路,断言立刻可见 + gate = InMemoryGate(config=BreakerConfig(1, 60.0, 120.0)) + source = _source(name="zp", provider="zhipu", model="glm-5.3") + async with _client(sources=[source], handler=handler, breaker=gate) as client: + with pytest.raises(RequestRejectedError, match="无法关闭推理"): + await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.NONE) + assert sent == [], "请求根本不该发出去: 档位不可满足在组装期就已判定" + assert (await gate.try_enter("zp", "w")).allowed, "配置错误不得计入熔断失败" + + +class TestRequestTierNormalization: + """`chat(reasoning_effort=...)` 是公共入口,裸字符串必须在此归一(issue #20)。 + + 两条装配路(工厂 / 构造函数全量注入,CLAUDE.md §4.5)与 `.env` 路的口径必须 + 一致——后者早已是"解析即归一"。不归一的后果不是"少个类型注解"那么轻: 档位 + 一路要被 `is Effort.NONE` 身份比较,裸字符串会在 transport 的错误路径上抛 + `AttributeError`,而它不属错误四分类,会穿透 `except ThinkingUnsupportedError` + 与 RetryMW 的分类捕获,以未分类异常冒出 `chat()`(2026-09-05 独立验证实测)。 + """ + + def _capturing_client(self, captured, **overrides): + def handler(request): + captured.append(json.loads(request.content)) + return _sse() + + return _client(handler=handler, **overrides) + + async def test_bare_string_tier_reaches_the_wire(self): + captured = [] + source = _source(provider="zhipu", model="glm-5.3") + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}], reasoning_effort="max") + assert captured[0]["reasoning_effort"] == "max" + + async def test_illegal_tier_is_a_value_error_listing_the_vocabulary(self): + """非法档位是调用方编程错误: 当场 `ValueError`,不进洋葱、不成为未分类异常。""" + source = _source(provider="zhipu", model="glm-5.3") + async with _client(sources=[source]) as client: + with pytest.raises(ValueError) as exc: + await client.chat([{"role": "user", "content": "hi"}], reasoning_effort="lowest") + message = str(exc.value) + assert "chat(reasoning_effort=...)" in message + assert all(tier.value in message for tier in Effort) + + class _MemoryRecorder: """收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。""" @@ -324,6 +566,61 @@ class TestModelFingerprint: b = build_model_fingerprint([_source(extra_body={"temperature": 1})]) assert a != b + def test_source_tier_enters_fingerprint(self): + """源级 `reasoning_effort` 改变请求体,就必须改变缓存身份(与 issue #5 同理)。 + + 本用例同时守着一个易漏点: 只配 `REASONING_EFFORT`、既无 `extra_body` 也无 + `ENABLE_THINKING` 的源,必须能进入指纹的 marks 集合——否则 `_fingerprint_mark` + 改了也白改,四个指纹会全部相等。 + """ + from polygateway.client import build_model_fingerprint + + plain = build_model_fingerprint([_source()]) + low = build_model_fingerprint([_source(reasoning_effort=Effort.LOW)]) + max_ = build_model_fingerprint([_source(reasoning_effort=Effort.MAX)]) + off = build_model_fingerprint([_source(reasoning_effort=Effort.NONE)]) + assert len({plain, low, max_, off}) == 4 + + def test_source_tier_is_distinguished_from_the_thinking_sugar(self): + """`reasoning_effort=NONE` 与 `enable_thinking=False` 不得摘要成同一个指纹。 + + 两者语义等价但取值不同(`"none"` vs `false`),让它们撞车会把"两种写法" + 变成"一种缓存身份",日后任一侧语义微调都会静默复用另一侧的响应。 + """ + from polygateway.client import build_model_fingerprint + + by_tier = build_model_fingerprint([_source(reasoning_effort=Effort.NONE)]) + by_sugar = build_model_fingerprint([_source(enable_thinking=False)]) + assert by_tier != by_sugar + + def test_absent_tier_fingerprint_is_byte_identical_to_before(self): + """不表态档位的存量源不得因本次升级平白冷启动: 字面量逐字相同。 + + 两条: 纯净源仍是裸 model 合集;只配 extra_body 的源仍是升级前那个摘要。 + """ + import hashlib + import json + + from polygateway.client import build_model_fingerprint + + assert build_model_fingerprint([_source()]) == "qwen-max" + 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_declared_tier_fingerprint_is_a_golden(self): + """配了档位那一侧的指纹字面量也要钉死: 它变了就是该源整段缓存冷启动。 + + 存量(不表态)那侧由 `test_absent_tier_fingerprint_is_byte_identical_to_before` + 守着;本条守的是"档位怎么摘要进 mark"。字面量硬编码,不在测试里重算—— + 重算等于把实现抄一遍,实现改了两边一起变,断言就白写了。 + """ + from polygateway.client import build_model_fingerprint + + assert build_model_fingerprint([_source(reasoning_effort=Effort.LOW)]) == ( + "qwen-max|76f2b3e419e1a727f7f31b6144da0b40d62a00ca93c91ce5901b4ebd48c0b8c0" + ) + class TestFactories: def test_from_env_assembles(self): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e29bc4d..61af01c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -8,6 +8,8 @@ from loguru import logger from polygateway.client import GatewayClient from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings +from polygateway.providers import ProviderProfile, ThinkingWire, register_provider +from polygateway.types import Effort, SourceConfig _BASE_ENV = { "LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1", @@ -132,6 +134,113 @@ class TestExtraBodyParsing: GatewaySettings.from_env("LLM", env=env) +class TestReasoningEffortParsing: + """源级推理档位两个键的 env 解析(issue #20 Task 4)。""" + + def test_effort_key_parsed(self): + env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "low"}) + s = GatewaySettings.from_env("LLM", env=env) + assert s.sources[0].reasoning_effort is Effort.LOW + + def test_absent_keys_keep_the_source_silent(self): + """未配置 = 不表态,与 `Effort.NONE`(要求不推理)是两回事;映射默认关闭。""" + src = GatewaySettings.from_env("LLM", env=_env()).sources[0] + assert src.reasoning_effort is None + assert src.effort_fallback == "error" + + def test_invalid_effort_lists_vocabulary(self): + """写错档位的人要的是"那该填什么",故报错必须把八档全摆出来。""" + env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "lowest"}) + with pytest.raises(ValueError) as exc: + GatewaySettings.from_env("LLM", env=env) + message = str(exc.value) + assert "REASONING_EFFORT" in message + assert all(tier.value in message for tier in Effort) + + def test_effort_fallback_parsed(self): + env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "nearest"}) + s = GatewaySettings.from_env("LLM", env=env) + assert s.sources[0].effort_fallback == "nearest" + + def test_effort_key_tolerates_case_and_whitespace(self): + """`.env` 里的行尾空格与大写写法是常态,档位取值本身没有大小写语义。""" + env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": " LOW "}) + assert GatewaySettings.from_env("LLM", env=env).sources[0].reasoning_effort is Effort.LOW + + def test_effort_fallback_tolerates_case_and_whitespace(self): + """与相邻的 REASONING_EFFORT 同口径: 同一份 .env 里两个键脾气不同即是坑。 + + `EFFORT_FALLBACK=Nearest` 此前会原样落到 `SourceConfig`,被值域校验拒掉 + ——而人看着 .env 里明明写了 nearest(2026-09-05 独立验证查出)。 + """ + env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": " Nearest "}) + assert GatewaySettings.from_env("LLM", env=env).sources[0].effort_fallback == "nearest" + + def test_invalid_effort_fallback_rejected(self): + """`resolve_thinking` 对未知 fallback 值是 fail-closed,不会替配置兜错。""" + env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "closest"}) + with pytest.raises(ValueError) as exc: + GatewaySettings.from_env("LLM", env=env) + message = str(exc.value) + assert "effort_fallback" in message + assert "nearest" in message and "error" in message + + +class TestThinkingFlagContradiction: + """`enable_thinking` 与 `reasoning_effort` 说的是同一件事(设计 §4.2 语法糖)。 + + 矛盾时报错而非「后者赢」: 两个字段表达同一件事时,矛盾是配置错误, + 静默取其一等于替下游猜它想要哪个。 + """ + + def _source(self, enable_thinking, effort): + env = _env( + **{ + "LLM__QWEN__1__ENABLE_THINKING": enable_thinking, + "LLM__QWEN__1__REASONING_EFFORT": effort, + } + ) + return GatewaySettings.from_env("LLM", env=env).sources[0] + + @pytest.mark.parametrize( + ("enable_thinking", "effort"), + [("true", "none"), ("false", "low"), ("false", "auto"), ("false", "max")], + ) + def test_contradictory_thinking_flags_rejected(self, enable_thinking, effort): + with pytest.raises(ValueError) as exc: + self._source(enable_thinking, effort) + message = str(exc.value) + assert "enable_thinking" in message and "reasoning_effort" in message + + @pytest.mark.parametrize( + ("enable_thinking", "effort"), + [("false", "none"), ("true", "auto"), ("true", "low")], + ) + def test_consistent_flags_allowed(self, enable_thinking, effort): + """语义一致就放行: `False`+`none` 与 `True`+某个开启档都只是说了两遍。""" + src = self._source(enable_thinking, effort) + assert src.enable_thinking is (enable_thinking == "true") + assert src.reasoning_effort is Effort(effort) + + def test_one_sided_declaration_never_trips_the_guard(self): + """只配一个键是常态(存量源全是这样),不得被矛盾守卫误伤。""" + assert self._source("true", None).reasoning_effort is None + assert self._source(None, "high").enable_thinking is None + + def test_contradiction_guarded_on_direct_construction(self): + """守卫挂在构造期而非 env 解析处: 构造函数全量注入那条装配路同样过闸。""" + base = SourceConfig( + name="s1", + provider="qwen", + base_url="https://gw.example/v1", + api_key="sk-x", + model="qwen-max", + timeout_s=60.0, + ) + with pytest.raises(ValueError, match="reasoning_effort"): + dataclasses.replace(base, enable_thinking=True, reasoning_effort=Effort.NONE) + + class TestResilienceKeys: def test_flat_legacy_keys(self): s = GatewaySettings.from_env("LLM", env=_env()) @@ -637,10 +746,41 @@ class TestCrossFieldInvariants: GatewayClient.from_settings(settings) def test_unknown_thinking_shape_fails_at_assembly(self): - """provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。""" - settings = self._thinking_sources("openai", "kimi-k3", False) + # 形态未知即报错并指路。2026-09-04 起默认表 8 段全部有形态(openai 段改发 + # OpenAI 标准的 reasoning_effort),故样本改为显式注册一个未知段——守卫测的 + # 是机制,不是某个段当时的配置 + mystery = ProviderProfile( + name="mystery", + thinking=ThinkingWire(off=None, on_base=None, effort_key=None), + strip_think_tags=False, + ) + settings = self._thinking_sources("mystery", "kimi-k3", False) with pytest.raises(ValueError, match="register_provider"): - GatewayClient.from_settings(settings) + GatewayClient.from_settings(settings, registry=register_provider(mystery)) + + def test_source_tier_the_model_cannot_satisfy_fails_at_assembly(self): + """守卫必须读**源级档位**,而不只是 `enable_thinking`(设计 §4.2 三层优先级)。 + + 变异实测: 把 `_guard_thinking` 的 `source_effort=source.reasoning_effort` + 改成 `None`,全套单测依然全绿(2026-09-05 独立验证查出)。文案里必须出现 + 可执行替代 `low`——glm-5.3 官方关不掉推理,只报"不行"会把人推回 + `extra_body` 那条绕过治理的老路(issue #20 的成因)。 + """ + base = self._base() + src = dataclasses.replace( + base.sources[0], provider="zhipu", model="glm-5.3", reasoning_effort=Effort.NONE + ) + with pytest.raises(ValueError) as exc: + GatewayClient.from_settings(dataclasses.replace(base, sources=(src,))) + message = str(exc.value) + assert "glm-5.3" in message + assert "low" in message + + def test_openai_segment_now_assembles_with_the_standard_field(self): + # 行为变更(2026-09-04): reasoning_effort 是 OpenAI 官方字段而非厂商方言, + # 经网关的兼容端点收得下,故兜底段不再把"关闭"判为形态未知 + settings = self._thinking_sources("openai", "gpt-5.5", False) + assert GatewayClient.from_settings(settings) is not None def test_supported_combination_assembles(self): settings = self._thinking_sources("minimax", "MiniMax-M3", False) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 2791828..a36b7a2 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -16,13 +16,14 @@ from polygateway.errors import ( ) from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.pricing import ModelPrice, PricingTable +from polygateway.providers import ProviderProfile, ThinkingWire, register_provider from polygateway.transports._http_errors import summarize_body from polygateway.transports.openai_compat import ( OpenAICompatTransport, _iter_sse_deltas, _sse_data_payload, ) -from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation +from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation def _source(**overrides): @@ -60,20 +61,22 @@ def _sse_stream(*frames, done=True): return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"}) -def _transport_for(handler): +def _transport_for(handler, *, registry=None): mock = httpx.MockTransport(handler) return OpenAICompatTransport( - client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock) + client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock), + registry=registry, ) -async def _complete(transport, source, *, stream=True, overlay=None): +async def _complete(transport, source, *, stream=True, overlay=None, reasoning_effort=None): return await transport.complete( messages=[{"role": "user", "content": "hi"}], source=source, stream=stream, overlay=overlay or {}, call_id="cid-1", + reasoning_effort=reasoning_effort, ) @@ -120,6 +123,7 @@ async def _recorded_cost(result, source): latency_ms=1, response=response, error=None, + reasoning_applies=True, ) return recorder.rows[0]["cost"] @@ -626,6 +630,60 @@ class TestThinkingReconciliation: hits = [m for m in messages if "MiniMax-M3" in m] assert len(hits) == 2, f"两个方向各应告警一次,实得 {len(hits)} 次" + async def test_each_tier_of_one_model_earns_its_own_warning(self): + """同一源同一模型的两个强度档是**两个独立的矛盾**,不得共用一个节流键。 + + 节流键沿用旧的 `enable_thinking` 三态时,两次请求的键逐字相同(都是 + `None`——档位根本不经过那个字段),于是 `max` 档的矛盾被 `low` 档那次 + 永久静音。档位化后 low 与 max 各喊一次,重复的 low 仍只喊一次。 + """ + transport = _transport_for(self._zero_signal) + source = _source(name="zp", provider="zhipu", model="glm-5.3") + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + await _complete(transport, source, reasoning_effort=Effort.LOW) + await _complete(transport, source, reasoning_effort=Effort.LOW) + await _complete(transport, source, reasoning_effort=Effort.MAX) + finally: + logger.remove(sink_id) + hits = [m for m in messages if "glm-5.3" in m] + assert len(hits) == 2, f"low 与 max 应各告警一次,实得 {len(hits)} 次" + + def _zero_signal(self, request): + """零推理信号的成功响应 → UNKNOWN,与"要求开启"矛盾(M3 实测形态)。""" + return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE)) + + +class TestAppliedTierLeavesTheTransport: + """本次**实际**发出去的档必须随 TransportResult 上浮(设计 §4.1 / 计划 T8-5)。 + + 不上浮就只能由遥测自己再算一遍请求档,而 `nearest` 映射后两者不同——压测 + 要按档分组的那一列会挂在一个从未真正发出过的档下,且错得看不出来。 + """ + + def _ok(self, request): + return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE)) + + async def test_result_carries_the_mapped_tier_not_the_requested_one(self): + """glm-5.3 只有 low/high/max: 请求 `medium`,实际发出的是 `low`。""" + transport = _transport_for(self._ok) + source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest") + result = await _complete(transport, source, reasoning_effort=Effort.MEDIUM) + assert result.applied_effort is Effort.LOW + + async def test_result_carries_the_tier_that_was_asked_for_when_supported(self): + transport = _transport_for(self._ok) + source = _source(provider="zhipu", model="glm-5.3") + result = await _complete(transport, source, reasoning_effort=Effort.MAX) + assert result.applied_effort is Effort.MAX + + async def test_no_statement_stays_none(self): + """不表态时库既不注入也不推定模型默认档——"没看见"不许说成"发生了"。""" + transport = _transport_for(self._ok) + result = await _complete(transport, _source()) + assert result.applied_effort is None + class TestNonStreamFastPath: async def test_non_stream_parses_message(self): @@ -666,6 +724,10 @@ class TestRequestShaping: @pytest.mark.parametrize( ("enable_thinking", "expected"), + # 本条断言反复过一次,记下原委以免第三次改回去: + # T2(2026-09-04)按"MiniMax 开启档本就无需参数"的**推定**把 medium 改成不注入; + # T10(2026-09-05)真实网关实测推翻该推定——M3 不发任何推理参数时 5/5 轮不推理, + # 故 medium 回归(issue #21 的权宜之计,正解是让 auto 受能力表约束) [(True, "medium"), (False, "none")], ) async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected): @@ -680,11 +742,20 @@ class TestRequestShaping: name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking ) await _complete(_transport_for(handler), source) - assert seen["reasoning_effort"] == expected + if expected is None: + assert "reasoning_effort" not in seen + else: + 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,两行不可调换。""" + """注入顺序即优先级: profile → extra_body → overlay,两行不可调换。 + + 固定用 **zhipu + glm-5.3 + 源级 low** 这组: 判据必须落在一个 profile + **真的写了值**的键上,两边写同一个键才谈得上谁覆盖谁。不挑 minimax 是因为 + 它的 `on_base` 只写 `reasoning_effort` 一个键(issue #21 的权宜之计), + 覆盖发生后看不见"profile 独有的那半边仍在",判据少一半。 + """ seen = {} def handler(request): @@ -692,14 +763,17 @@ class TestRequestShaping: return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) source = _source( - name="mm", - provider="minimax", - model="MiniMax-M3", - enable_thinking=True, + name="zp", + provider="zhipu", + model="glm-5.3", + reasoning_effort="low", extra_body={"reasoning_effort": "high"}, ) await _complete(_transport_for(handler), source) + # profile 注入的是 low,extra_body 后写故发出去的是 high;顺序一调换就变 low, + # 即下游写在 extra_body 里的覆盖被库悄悄顶掉(issue #20 的成因形态) assert seen["reasoning_effort"] == "high" + assert seen["thinking"] == {"type": "enabled"} # profile 独有的那半边仍在 async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self): """M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。 @@ -758,9 +832,15 @@ class TestRequestShaping: def handler(request): # pragma: no cover - 不该走到发请求 raise AssertionError("请求不该发出") - source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False) + # 2026-09-04 起默认表 8 段全部有形态,守卫样本改为显式注册的未知段 + mystery = ProviderProfile( + name="mystery", + thinking=ThinkingWire(off=None, on_base=None, effort_key=None), + strip_think_tags=False, + ) + source = _source(name="k3", provider="mystery", model="kimi-k3", enable_thinking=False) with pytest.raises(RequestRejectedError, match="register_provider"): - await _complete(_transport_for(handler), source) + await _complete(_transport_for(handler, registry=register_provider(mystery)), source) async def test_overlay_merged_into_payload(self): seen = {} diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 7730a97..c927049 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -60,12 +60,15 @@ def test_thinking_public_surface_exported(): `observe_thinking` / `reconcile_thinking` **不**导出: 它们是 transport 内部 的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份 - 永久承诺。 + 永久承诺。`ThinkingResolution` 则**要**导出——它是已导出的 `resolve_thinking` + 的返回类型,不导出等于下游拿得到实例却写不出类型标注。 """ for name in ( "ThinkingCapability", "ThinkingObservation", + "ThinkingResolution", "ThinkingUnsupportedError", + "ThinkingWire", "get_capability", "register_capability", "resolve_thinking", @@ -74,3 +77,14 @@ def test_thinking_public_surface_exported(): assert name in polygateway.__all__, name assert "observe_thinking" not in polygateway.__all__ assert "reconcile_thinking" not in polygateway.__all__ + + +def test_every_promised_export_is_actually_importable(): + """`__all__` 里的每个名字都必须真的绑在包上。 + + 只维护 `__all__` 而漏掉 import,`from polygateway import X` 与 `import *` + 都会当场炸,而逐个点名的用例只覆盖它当时想到的符号——2026-09-04 的 + `ThinkingWire` 正是这样漏进来的(在 `__all__` 里躺了一个提交却 import 不到)。 + """ + missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)] + assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}" diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 89aea7c..f772271 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -1,15 +1,18 @@ """ports.py 端口冻结测试(M1 设计 §4): Protocol 结构性检查 + Gate 快照校验。""" +import inspect from typing import Any import pytest from polygateway.ports import ( CacheBackend, + EmbeddingTransport, GateDecision, GateState, GateUpdate, Middleware, + OcrTransport, Permit, ProviderGate, RateLimiter, @@ -69,7 +72,7 @@ class _DummyMw: class _DummyTransport: - async def complete(self, *, messages, source, stream, overlay, call_id): + async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort): raise NotImplementedError @@ -142,6 +145,34 @@ def test_protocols_are_runtime_checkable(impl, protocol): assert isinstance(impl, protocol) +class TestReasoningTierIsOnlyOnTheChatPort: + """档位属于 chat 端口,且**只属于**它(Task 5b)。 + + `@runtime_checkable` 只查方法名不查签名,故协议签名本身必须被显式断言—— + 否则实现漏改一个参数,要到运行期调用才会以 `TypeError` 现形,而那时的现场 + 离根因已经很远。 + """ + + def test_chat_transport_carries_the_per_call_tier(self): + params = inspect.signature(Transport.complete).parameters + assert "reasoning_effort" in params + # 不给默认值是有意的(与 TelemetryRecorder 同一既有约定): 库外无第三方 + # 实现者,写全签名成本为零,而默认值会把"漏传"变成静默的"不表态" + assert params["reasoning_effort"].default is inspect.Parameter.empty + + @pytest.mark.parametrize( + ("protocol", "method"), + [ + (EmbeddingTransport, "embed"), + (OcrTransport, "recognize_text"), + (OcrTransport, "parse_layout"), + ], + ) + def test_other_transports_have_no_reasoning_tier(self, protocol, method): + """embedding 与 OCR 没有推理语义,给它们加档位只会静默无效(issue #4 同款决策)。""" + assert "reasoning_effort" not in inspect.signature(getattr(protocol, method)).parameters + + class _DummyStatusProvider(_DummyRecorder): @property def telemetry_status(self) -> TelemetryStatus: @@ -244,7 +275,9 @@ class TestTelemetryRecorderSignature: params = inspect.signature(TelemetryRecorder.record_llm_call).parameters assert {"tenant_id", "meta"} <= set(params) - @pytest.mark.parametrize("name", ["tenant_id", "meta", "thinking_observation"]) + @pytest.mark.parametrize( + "name", ["tenant_id", "meta", "thinking_observation", "reasoning_effort"] + ) def test_caller_dimensions_have_no_default(self, name): import inspect diff --git a/tests/unit/test_pricing.py b/tests/unit/test_pricing.py index cdd76b4..de031a1 100644 --- a/tests/unit/test_pricing.py +++ b/tests/unit/test_pricing.py @@ -177,6 +177,7 @@ class TestEmitterCost: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) assert rec.rows[0]["cost"] == pytest.approx(7.2) @@ -196,6 +197,7 @@ class TestEmitterCost: latency_ms=1, response=None, error="TransientError: boom", + reasoning_applies=True, ) assert rec.rows[0]["cost"] is None @@ -209,6 +211,7 @@ class TestEmitterCost: latency_ms=1, response=_resp(model="mystery"), error=None, + reasoning_applies=True, ) assert rec.rows[0]["cost"] is None @@ -223,5 +226,6 @@ class TestEmitterCost: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) assert rec.rows[0]["cost"] is None diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index 646768e..7f90d38 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -1,57 +1,118 @@ -"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。""" +"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。 + +2026-09-04 起 profile 存的是 `ThinkingWire`(off / on_base / effort_key)而非两个 +固定片段——档位型模型(GLM-5.3、kimi-k3、deepseek-v4…)的"开"档需要附一个档位值, +两个固定片段表达不了。 +""" import pytest from polygateway.providers import ( DEFAULT_PROFILES, ProviderProfile, + ThinkingWire, get_provider, register_provider, ) +_EXPECTED_SEGMENTS = frozenset( + {"qwen", "deepseek", "zhipu", "moonshot", "minimax", "openai", "anthropic", "google"} +) + class TestDefaultProfiles: - def test_qwen_profile(self): - p = get_provider("qwen") - assert p.thinking_on == {"enable_thinking": True} - assert p.thinking_off == {"enable_thinking": False} - assert p.strip_think_tags is True - assert p.supports_native_schema is False + def test_all_eight_profiles_registered(self): + """issue #20: 智谱缺段,下游只能把 GLM 挂在 openai 兜底段下再手写 extra_body。""" + assert set(DEFAULT_PROFILES) == _EXPECTED_SEGMENTS - def test_deepseek_profile(self): - p = get_provider("deepseek") - assert p.thinking_on == {"thinking": {"type": "enabled"}} - assert p.thinking_off == {"thinking": {"type": "disabled"}} - assert p.strip_think_tags is False + def test_qwen_is_a_switch_with_no_tiers(self): + w = get_provider("qwen").thinking + assert w.on_base == {"enable_thinking": True} + assert w.off == {"enable_thinking": False} + assert w.effort_key is None # 百炼靠 thinking_budget 调深度,不是档位 + assert get_provider("qwen").strip_think_tags is True - def test_openai_slots_are_unknown_not_empty(self): - """issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此), + def test_deepseek_carries_both_switch_and_tier(self): + w = get_provider("deepseek").thinking + assert w.on_base == {"thinking": {"type": "enabled"}} + assert w.off == {"thinking": {"type": "disabled"}} + assert w.effort_key == "reasoning_effort" - 故不能下发任何厂商方言参数。None = 形态未知 → 配了 enable_thinking 即报错, - 而不是空字典那种"注入了个寂寞"的静默失效。 + def test_zhipu_matches_the_vendor_migration_note(self): + """智谱官方: thinking.type=enabled + reasoning_effort 才是 GLM-5.3 的正确形态。""" + w = get_provider("zhipu").thinking + assert w.on_base == {"thinking": {"type": "enabled"}} + assert w.off == {"thinking": {"type": "disabled"}} + assert w.effort_key == "reasoning_effort" + + def test_openai_family_sends_the_standard_field_only(self): + """gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。 + + minimax 2026-09-05 起不在本组: 它的形态相同,但"开"这一档被迫带上了一个 + 档位值(见 `test_minimax_on_tier_carries_a_tier_value`)。 """ - p = get_provider("openai") - assert p.thinking_on is None and p.thinking_off is None - assert p.strip_think_tags is False + for name in ("openai", "anthropic", "google"): + w = get_provider(name).thinking + assert w.on_base == {}, name + assert w.off == {"reasoning_effort": "none"}, name + assert w.effort_key == "reasoning_effort", name - def test_minimax_profile_uses_reasoning_effort(self): - """2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。""" - p = get_provider("minimax") - assert p.thinking_off == {"reasoning_effort": "none"} - assert p.thinking_on == {"reasoning_effort": "medium"} - assert p.strip_think_tags is False + def test_minimax_on_tier_carries_a_tier_value(self): + """issue #21 的权宜之计: minimax 的"开"必须真写一个档位值,不能是空片段。 + + 断言反复过一次: T2 按"这些模型默认就推理"的推定把它改成 `{}`,T10 真实 + 网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium。 + """ + w = get_provider("minimax").thinking + assert w.on_base == {"reasoning_effort": "medium"} + assert w.off == {"reasoning_effort": "none"} + assert w.effort_key == "reasoning_effort" def test_unknown_provider_fails_loudly(self): """消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。""" with pytest.raises(ValueError, match="glm"): - get_provider("glm") + get_provider("glm") # 段名是 zhipu,不是 glm with pytest.raises(ValueError): get_provider("qwen2") # 子串相似也不放行 + def test_error_lists_every_registered_segment(self): + with pytest.raises(ValueError, match="zhipu"): + get_provider("nope") + + +class TestWireNoneSemantics: + """三个 `None` 语义互不重叠(issue #5 的成果,不可退回成"注入了个寂寞")。""" + + def test_on_base_none_means_shape_unknown(self): + w = ThinkingWire(off=None, on_base=None, effort_key=None) + assert w.on_base is None + + def test_off_none_means_no_off_shape(self): + """有开启形态但没有关闭形态,与"整个形态未知"是两回事。""" + w = ThinkingWire(off=None, on_base={"x": 1}, effort_key=None) + assert w.on_base is not None and w.off is None + + def test_effort_key_none_means_switch_only(self): + """qwen 是这一档: 能开能关,但没有档位可谈。""" + assert get_provider("qwen").thinking.effort_key is None + + def test_empty_on_base_is_not_none(self): + """`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。 + + 样本 2026-09-05 由 minimax 换成 openai: minimax 的 `on_base` 因 issue #21 + 改回带值,不再是空片段;openai 段是现存 `{}` 语义的代表。 + """ + w = get_provider("openai").thinking + assert w.on_base == {} and w.on_base is not None + class TestPureFunctionRegistration: def test_register_returns_new_mapping(self): - glm = ProviderProfile(name="glm", thinking_on={}, thinking_off={}, strip_think_tags=False) + glm = ProviderProfile( + name="glm", + thinking=ThinkingWire(off={}, on_base={}, effort_key=None), + strip_think_tags=False, + ) table = register_provider(glm) assert get_provider("glm", registry=table) is glm # 默认表未被污染(无可变全局状态铁律) @@ -59,12 +120,14 @@ class TestPureFunctionRegistration: get_provider("glm") def test_register_on_custom_base_and_override(self): - custom_qwen = ProviderProfile( - name="qwen", thinking_on={"x": 1}, thinking_off={}, strip_think_tags=False + custom = ProviderProfile( + name="qwen", + thinking=ThinkingWire(off={}, on_base={"x": 1}, effort_key=None), + strip_think_tags=False, ) - table = register_provider(custom_qwen, base=DEFAULT_PROFILES) - assert get_provider("qwen", registry=table).thinking_on == {"x": 1} - assert get_provider("qwen").thinking_on == {"enable_thinking": True} + table = register_provider(custom, base=DEFAULT_PROFILES) + assert get_provider("qwen", registry=table).thinking.on_base == {"x": 1} + assert get_provider("qwen").thinking.on_base == {"enable_thinking": True} def test_default_profiles_mapping_is_read_only(self): with pytest.raises(TypeError): diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index c11a939..96d566d 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -24,6 +24,7 @@ from polygateway.types import ( BackpressurePolicy, BreakerConfig, ChatRequest, + Effort, GlobalLimits, RetryPolicy, SourceConfig, @@ -63,14 +64,21 @@ def _ok(content="ok"): class FakeTransport: - """按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id)。""" + """按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id) 与收到的档位。 + + `reasoning_effort` 刻意**不给默认值**,与 `Transport` 协议保持逐字一致: + `@runtime_checkable` 只查方法名不查签名,fake 上多一个默认值就会把"中间件漏传" + 这类缺口伪装成"调用方没表态",而报错现场离根因很远。 + """ def __init__(self, script): self.script = list(script) self.calls = [] + self.efforts = [] - async def complete(self, *, messages, source, stream, overlay, call_id): + async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort): self.calls.append((source.name, call_id)) + self.efforts.append(reasoning_effort) action = self.script.pop(0) if isinstance(action, Exception): raise action @@ -135,6 +143,37 @@ def _harness( _REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}]) +class TestRequestTierReachesTransport: + """请求级档位必须一路穿过洋葱到达 transport(Task 5b)。 + + `ChatRequest` 上填了字段而中间件不搬运,是"看起来配了、实际没发出去"的静默 + 失效——正是 issue #20 里下游改用 extra_body 绕过治理的成因。 + """ + + async def test_request_tier_reaches_transport(self): + mw, _, _, transport, *_ = _harness([_src("a")], [_ok()]) + await mw( + ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.HIGH) + ) + assert transport.efforts == [Effort.HIGH] + + async def test_absent_tier_is_carried_as_none(self): + """不表态也要显式传下去: 漏传与"传了 None"在协议上必须区分不开才安全。""" + mw, _, _, transport, *_ = _harness([_src("a")], [_ok()]) + await mw(_REQ) + assert transport.efforts == [None] + + async def test_tier_is_carried_on_every_retry_attempt(self): + """换源重试时档位不得在第二次尝试上丢失。""" + mw, _, _, transport, *_ = _harness( + [_src("a"), _src("b")], [TransientError("boom", source_name="a"), _ok()] + ) + await mw( + ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.LOW) + ) + assert transport.efforts == [Effort.LOW, Effort.LOW] + + class TestSuccessPath: async def test_first_attempt_success_builds_response(self): mw, limiter, gate, transport, sleep, _ = _harness([_src("a")], [_ok("hello")]) @@ -249,6 +288,33 @@ class TestObservabilityPassthrough: resp = await mw(_REQ) assert resp.thinking_observation is ThinkingObservation.UNKNOWN + async def test_applied_tier_reaches_the_response(self): + """issue #20: 实际发出的档由 transport 裁定,本层只搬运。 + + 搬运这一步漏掉,`LLMResponse.applied_effort` 恒为 None,而遥测正是从这个 + 字段取"这一行跑在哪档"——整列会静默地全是 NULL。 + """ + result = TransportResult( + content="ok", + thinking="", + prompt_tokens=10, + completion_tokens=5, + usage_source="measured", + ttft_ms=12.0, + max_inter_token_ms=3.0, + raw={}, + applied_effort=Effort.HIGH, + ) + mw, *_ = _harness([_src("a")], [result]) + resp = await mw(_REQ) + assert resp.applied_effort is Effort.HIGH + + async def test_unstated_tier_stays_none(self): + """不表态的调用不得被填成某个档: 那等于替调用方声称它做过一个选择。""" + mw, *_ = _harness([_src("a")], [_ok()]) + resp = await mw(_REQ) + assert resp.applied_effort is None + class TestRetryAndFailover: async def test_transient_switches_source_then_succeeds(self): diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index c91188e..693db6f 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -25,6 +25,7 @@ from polygateway.types import ( BackpressurePolicy, BreakerConfig, ChatRequest, + Effort, EmbeddingTransportResult, GlobalLimits, LLMResponse, @@ -63,6 +64,7 @@ _EXPECTED_COLUMNS = [ "tenant_id", "meta", "thinking_observation", + "reasoning_effort", ] @@ -132,6 +134,8 @@ async def _record_minimal(recorder, call_id="c1", **overrides): "meta": "{}", # 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str "thinking_observation": "unknown", + # 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开) + "reasoning_effort": None, } fields.update(overrides) await recorder.record_llm_call(**fields) @@ -177,16 +181,17 @@ _FROZEN_SQLITE_INSERT = ( "INSERT OR IGNORE INTO llm_calls (call_id, parent_call_id, session_id, model, provider, " "source_name, messages, response, thinking, prompt_tokens, completion_tokens, usage_source, " "latency_ms, ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, " - "model_reported, sampling, reasoning_tokens, tenant_id, meta, thinking_observation) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "model_reported, sampling, reasoning_tokens, tenant_id, meta, thinking_observation, " + "reasoning_effort) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) _FROZEN_PG_INSERT = ( "INSERT INTO llm_calls (call_id, parent_call_id, session_id, model, provider, source_name, " "messages, response, thinking, prompt_tokens, completion_tokens, usage_source, latency_ms, " "ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, model_reported, " - "sampling, reasoning_tokens, tenant_id, meta, thinking_observation) " + "sampling, reasoning_tokens, tenant_id, meta, thinking_observation, reasoning_effort) " "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, " - "$19, $20, $21, $22, $23, $24, $25) " + "$19, $20, $21, $22, $23, $24, $25, $26) " # 无冲突目标(issue #13 Task 2): 带 `(call_id)` 的版本在按 created_at 分区、 # 主键为 (call_id, created_at) 的表上匹配不到约束,PG 直接拒收整条写入 "ON CONFLICT DO NOTHING" @@ -212,7 +217,7 @@ class TestSchemaModule: # COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"] - assert len(COLUMNS) == 25 + assert len(COLUMNS) == 26 # 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位) for ddl in (SQLITE_DDL, PG_DDL): assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS @@ -226,8 +231,8 @@ class TestSchemaModule: "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER", ) assert PG_BACKFILL[-1] == ( - "thinking_observation", - "ALTER TABLE llm_calls ADD COLUMN thinking_observation TEXT", + "reasoning_effort", + "ALTER TABLE llm_calls ADD COLUMN reasoning_effort TEXT", ) assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL) @@ -275,7 +280,7 @@ class TestSchemaModule: pg = telemetry_schema_sql("postgres") lite = telemetry_schema_sql("sqlite") for script in (pg, lite): - # 25 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致 + # 26 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致 assert _first_occurrence_order(script, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS assert "CREATE TABLE IF NOT EXISTS llm_calls" in script # 人执行的那份必须幂等: PG 用 ADD COLUMN IF NOT EXISTS(与库内那份有意不同) @@ -313,7 +318,7 @@ class TestBackendColumnParity: """新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。""" from polygateway.telemetry.schema import COLUMNS - assert COLUMNS[-3:] == ("tenant_id", "meta", "thinking_observation") + assert COLUMNS[-4:] == ("tenant_id", "meta", "thinking_observation", "reasoning_effort") class TestSQLiteRecorder: @@ -405,6 +410,27 @@ class TestSQLiteRecorder: assert rows["t-absent"] == "absent" # 观测到"确实没推理",与"看不出来"不是一回事 assert rows["t-unknown"] == "unknown" + async def test_reasoning_effort_column_round_trips(self, tmp_path): + """issue #20: 实际档位落库,事后才分得清"这一行跑在哪档"。 + + 断言裸串而非枚举,理由与 `thinking_observation` 逐字相同: `StrEnum` 是 + `str` 子类,而 asyncpg 对子类编码不保证接受,遥测写失败只降级一条 warning + ——PG 那一路会静默少一列,SQLite 本地全绿也发现不了。 + """ + recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True) + await _record_minimal(recorder, call_id="e-low", reasoning_effort="low") + await _record_minimal(recorder, call_id="e-max", reasoning_effort="max") + await _record_minimal(recorder, call_id="e-silent") + recorder.close() + rows = dict( + sqlite3.connect(tmp_path / "t.db") + .execute("SELECT call_id, reasoning_effort FROM llm_calls") + .fetchall() + ) + assert rows["e-low"] == "low" + assert rows["e-max"] == "max" + assert rows["e-silent"] is None # 不表态是 NULL,与任何一档都分得开 + async def test_sampling_column_round_trips(self, tmp_path): """issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。""" recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True) @@ -568,7 +594,7 @@ class TestSQLiteCallerDimensionsAcceptance: conn = sqlite3.connect(db) cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] - assert cols == _EXPECTED_COLUMNS # 22 → 25 个 recorder 字段(+ created_at 共 26 物理列) + assert cols == _EXPECTED_COLUMNS # 22 → 26 个 recorder 字段(+ created_at 共 27 物理列) rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall()) assert rows["new-row"] == "tenant-a" assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉 @@ -610,7 +636,7 @@ class TestSQLiteCallerDimensionsAcceptance: stale = sqlite3.connect(db) assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == ( - _EXPECTED_COLUMNS[:-3] + _EXPECTED_COLUMNS[:-4] ) # 补列确实没成功,用例不是在只读库上空转 @@ -618,7 +644,7 @@ class TestSQLiteSchemaMode: """issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。 列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23, - 补齐后 25 + `created_at` = 26。混用 INSERT 字段数与物理列数是本处最易错的地方。 + 补齐后 26 + `created_at` = 27。混用 INSERT 字段数与物理列数是本处最易错的地方。 """ def _physical_columns(self, db: Path) -> list[str]: @@ -657,7 +683,7 @@ class TestSQLiteSchemaMode: assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path): - """auto + 同款旧表: 现状回归,补列后物理列数 23 → 26。""" + """auto + 同款旧表: 现状回归,补列后物理列数 23 → 27。""" db = tmp_path / "auto_legacy.db" _make_pre_tenant_db(db) @@ -666,10 +692,10 @@ class TestSQLiteSchemaMode: recorder.close() assert self._physical_columns(db) == _EXPECTED_COLUMNS - assert len(self._physical_columns(db)) == 26 + assert len(self._physical_columns(db)) == 27 async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path): - """manual 只管 ALTER,不管 CREATE: 全新库照建,26 个物理列齐全(设计 §4.2)。""" + """manual 只管 ALTER,不管 CREATE: 全新库照建,27 个物理列齐全(设计 §4.2)。""" db = tmp_path / "manual_fresh.db" recorder = SQLiteRecorder(db, auto_migrate=False) await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a") @@ -894,6 +920,7 @@ class TestPostgresBackfillDiscipline: "tenant_id", "meta", "thinking_observation", + "reasoning_effort", ] def _recorder(self, conn): @@ -1120,6 +1147,7 @@ class TestEmitterRecorderContract: latency_ms=42, response=_resp(), error=None, + reasoning_applies=True, ) assert set(rec.rows[0]) == set(COLUMNS) @@ -1137,6 +1165,7 @@ class TestEmitterRecorderContract: latency_ms=1, response=None, error="boom", + reasoning_applies=True, ) elif emit == "cache_hit": await emitter.emit_cache_hit(request=_REQ, response=_resp()) @@ -1165,6 +1194,7 @@ class TestEmitterThinkingObservation: latency_ms=1, response=_resp(thinking_observation=ThinkingObservation.OBSERVED), error=None, + reasoning_applies=True, ) value = rec.rows[0]["thinking_observation"] assert value == "observed" @@ -1187,6 +1217,7 @@ class TestEmitterThinkingObservation: latency_ms=1, response=_resp(thinking_observation="observed"), error=None, + reasoning_applies=True, ) assert len(rec.rows) == 1, "整行被吞了" value = rec.rows[0]["thinking_observation"] @@ -1212,6 +1243,7 @@ class TestEmitterThinkingObservation: latency_ms=1, response=_resp(thinking_observation="OBSERVED"), # 大小写不符即域外 error=None, + reasoning_applies=True, ) finally: logger.remove(sink_id) @@ -1250,10 +1282,205 @@ class TestEmitterThinkingObservation: latency_ms=1, response=None, error="boom", + reasoning_applies=True, ) assert rec.rows[0]["thinking_observation"] == "unknown" +class TestEmitterReasoningEffort: + """issue #20: 每行记下这次调用**实际跑在哪档**,否则压测无从分组。 + + 三个入口的取值口径**有意不同**,故逐个钉死: 只有 `emit_attempt` 手上有生效源, + 它才谈得上"实际档";另两个入口没有选中源,源级档位无从谈起,只能记请求档。 + 与 `sampling` 列的现有做法同构。 + """ + + async def test_attempt_records_the_tier_the_transport_applied(self): + """`nearest` 映射后成功行记的是**映射后**的档,不是请求档。 + + 请求 `medium`、模型只有 low/high/max 时二者分叉(实发 `low`)。emitter 若 + "顺手"重算 `effective_effort`,记的就是一个从未发出过的档,而两个值在没开 + 映射的源上恒等——本地跑不开映射的源永远看不出这个错。 + """ + rec = _MemoryRecorder() + await TelemetryEmitter(rec, text_cap=None).emit_attempt( + request=ChatRequest( + messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM + ), + source=_source(effort_fallback="nearest"), + call_id="c", + latency_ms=1, + response=_resp(applied_effort=Effort.LOW), + error=None, + reasoning_applies=True, + ) + value = rec.rows[0]["reasoning_effort"] + assert value == "low" # 不是 medium: 那一档从未发出去过 + assert type(value) is str # 不是 Effort: 子类实例不得下沉到 recorder + + async def test_failed_attempt_falls_back_to_the_requested_tier(self): + """失败尝试没有响应,实际档不可知,记请求档并接受这层含义差别。 + + 档位错误(resolve 的 Phase 2/4/5)根本没发 HTTP,却照样经 + `RequestRejectedError` 走到这里——记的正是**被拒绝的那一档**,这对 + "哪一档配错了" 是有用信号,不该被过滤掉。 + """ + rec = _MemoryRecorder() + await TelemetryEmitter(rec, text_cap=None).emit_attempt( + request=_REQ, + source=_source(reasoning_effort=Effort.HIGH), + call_id="c", + latency_ms=1, + response=None, + error="boom", + reasoning_applies=True, + ) + assert rec.rows[0]["reasoning_effort"] == "high" + + async def test_failed_attempt_resolves_the_syntactic_sugar_too(self): + """回落走 `effective_effort` 而非裸读字段: `enable_thinking` 也是表态。""" + rec = _MemoryRecorder() + await TelemetryEmitter(rec, text_cap=None).emit_attempt( + request=_REQ, + source=_source(enable_thinking=True), + call_id="c", + latency_ms=1, + response=None, + error="boom", + reasoning_applies=True, + ) + assert rec.rows[0]["reasoning_effort"] == "auto" + + async def test_cache_hit_records_the_request_tier_not_the_replayed_one(self): + """命中行没有选中源,故记请求档;与 model/prompt_tokens 的回放口径相反。""" + rec = _MemoryRecorder() + await TelemetryEmitter(rec, text_cap=None).emit_cache_hit( + request=ChatRequest( + messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM + ), + response=_resp(cache_hit=True, applied_effort=Effort.LOW), + ) + assert rec.rows[0]["reasoning_effort"] == "medium" + + async def test_terminal_failure_records_the_request_tier(self): + """终态失败可能根本没选出源,源级档位无从谈起。""" + rec = _MemoryRecorder() + await TelemetryEmitter(rec, text_cap=None).emit_terminal_failure( + request=ChatRequest( + messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.XHIGH + ), + call_id="c", + latency_ms=1, + error="dead", + ) + value = rec.rows[0]["reasoning_effort"] + assert value == "xhigh" + assert type(value) is str + + @pytest.mark.parametrize("emit", ["attempt", "cache_hit", "terminal_failure"]) + async def test_silence_lands_as_null(self, emit): + """谁都没表态时落 `NULL`: `None` 与 `'low'` 必须分得开(设计 §6)。 + + 库并不观测模型内部的默认档,记一个推定值等于把"没看见"说成"发生了"。 + """ + rec = _MemoryRecorder() + emitter = TelemetryEmitter(rec, text_cap=None) + if emit == "attempt": + await emitter.emit_attempt( + request=_REQ, + source=_source(), + call_id="c", + latency_ms=1, + response=_resp(), + error=None, + reasoning_applies=True, + ) + elif emit == "cache_hit": + await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True)) + else: + await emitter.emit_terminal_failure( + request=_REQ, call_id="c", latency_ms=1, error="dead" + ) + assert rec.rows[0]["reasoning_effort"] is None + + async def test_a_reasonless_path_never_records_a_tier(self): + """embedding/OCR 走同一个 emitter,但它们的 payload 里没有推理参数。 + + 源上误配了 `ENABLE_THINKING` 时,回落若照算就会给一次 embedding 失败 + 挂上 `auto` ——那一档从来没有、也不可能被发出去。 + """ + rec = _MemoryRecorder() + await TelemetryEmitter(rec, text_cap=None).emit_attempt( + request=_REQ, + source=_source(enable_thinking=True), + call_id="c", + latency_ms=1, + response=None, + error="boom", + reasoning_applies=False, + ) + assert rec.rows[0]["reasoning_effort"] is None + + def test_reasoning_applies_has_no_default(self): + """上一条测的是"传了 False 会怎样",这条测的是"**漏传**会怎样"。 + + `reasoning_applies` 的约定是不设默认值(与 `TelemetryRecorder` 同款):库外 + 无第三方调用者,写全签名成本为零,而默认 `True` 会让将来新增的第四条 emit + 路径(又一个非推理客户端)漏传时静默落进 chat 口径——一次 embedding 失败被 + 挂上源上误配的 `auto` 档,正是上一条测试要防的形态,却绕过了它的断言。 + 约定只写在 docstring 里是没有执法点的,故在此以 `inspect.signature` 实测。 + """ + import inspect + + param = inspect.signature(TelemetryEmitter.emit_attempt).parameters["reasoning_applies"] + assert param.default is inspect.Parameter.empty + assert param.kind is inspect.Parameter.KEYWORD_ONLY + + async def test_an_out_of_domain_tier_degrades_but_keeps_the_row(self): + """域外取值降级为 `NULL` 且**不丢整行**(遥测必录);与缓存回放同一方向。 + + `LLMResponse` 无运行时校验,测试替身写裸串完全自然;直接 `Effort(raw)` 会 + 抛 `ValueError`,被 `_record` 的 `except Exception` 吞成丢整行。 + """ + rec = _MemoryRecorder() + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + await TelemetryEmitter(rec, text_cap=None).emit_attempt( + request=_REQ, + source=_source(), + call_id="c", + latency_ms=1, + response=_resp(applied_effort="lowest"), + error=None, + reasoning_applies=True, + ) + finally: + logger.remove(sink_id) + assert len(rec.rows) == 1, "整行被吞了" + assert rec.rows[0]["reasoning_effort"] is None + hits = [m for m in messages if "lowest" in m] + assert len(hits) == 1, f"域外取值必须单独告警: {messages}" + assert [m for m in messages if "遥测记录失败" in m] == [] + + async def test_a_bare_string_tier_still_lands(self): + """裸串在域内时照常归一并落库,整行不得丢失。""" + rec = _MemoryRecorder() + await TelemetryEmitter(rec, text_cap=None).emit_attempt( + request=_REQ, + source=_source(), + call_id="c", + latency_ms=1, + response=_resp(applied_effort="max"), + error=None, + reasoning_applies=True, + ) + assert len(rec.rows) == 1, "整行被吞了" + value = rec.rows[0]["reasoning_effort"] + assert value == "max" + assert type(value) is str + + class TestEmitterObservabilityFields: """issue #3: 三个入口各自的取值口径(设计 §5 表)。""" @@ -1266,6 +1493,7 @@ class TestEmitterObservabilityFields: latency_ms=42, response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7), error=None, + reasoning_applies=True, ) assert rec.rows[0]["cached_prompt_tokens"] == 64 assert rec.rows[0]["model_reported"] == "m-real" @@ -1280,6 +1508,7 @@ class TestEmitterObservabilityFields: latency_ms=7, response=None, error="boom", + reasoning_applies=True, ) assert rec.rows[0]["cached_prompt_tokens"] is None assert rec.rows[0]["model_reported"] is None @@ -1330,6 +1559,7 @@ class TestEmitterSamplingColumn: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) assert json.loads(rec.rows[0]["sampling"]) == {"seed": 42, "temperature": 0} @@ -1344,6 +1574,7 @@ class TestEmitterSamplingColumn: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) await emitter.emit_cache_hit(request=self._SAMPLED, response=_resp()) await emitter.emit_terminal_failure( @@ -1376,6 +1607,7 @@ class TestEmitterSamplingColumn: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) assert rec.rows[0]["sampling"] is None @@ -1408,6 +1640,7 @@ class TestEmitterCallerDimensions: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) elif emit == "cache_hit": await emitter.emit_cache_hit(request=self._REQ_A, response=_resp(cache_hit=True)) @@ -1461,6 +1694,7 @@ class TestEmitterCallerDimensions: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) row = rec.rows[0] assert row["tenant_id"] == "" @@ -1521,6 +1755,7 @@ class TestCostWithCachedTier: latency_ms=1, response=full, error=None, + reasoning_applies=True, ) await emitter.emit_attempt( request=_REQ, @@ -1531,6 +1766,7 @@ class TestCostWithCachedTier: prompt_tokens=1_000_000, completion_tokens=0, cached_prompt_tokens=600_000 ), error=None, + reasoning_applies=True, ) assert rec.rows[0]["cost"] == pytest.approx(10.0) assert rec.rows[1]["cost"] == pytest.approx(5.2) # 400k×10 + 600k×2 @@ -1553,6 +1789,7 @@ class TestCostWithCachedTier: latency_ms=1, response=_resp(usage_source="unavailable", cached_prompt_tokens=5), error=None, + reasoning_applies=True, ) assert rec.rows[0]["cost"] is None @@ -1568,6 +1805,7 @@ class TestEmitter: latency_ms=42, response=_resp(), error=None, + reasoning_applies=True, ) row = rec.rows[0] assert row["call_id"] == "cid-1" and row["error"] is None @@ -1584,6 +1822,7 @@ class TestEmitter: latency_ms=7, response=None, error="TransientError: boom", + reasoning_applies=True, ) row = rec.rows[0] assert row["error"].startswith("TransientError") @@ -1615,6 +1854,7 @@ class TestEmitter: usage_source="unavailable", prompt_tokens=prompt, completion_tokens=completion ), error=None, + reasoning_applies=True, ) assert rec.rows[0]["cost"] is None @@ -1628,6 +1868,7 @@ class TestEmitter: latency_ms=42, response=_resp(prompt_tokens=0, completion_tokens=4000), error=None, + reasoning_applies=True, ) assert rec.rows[0]["cost"] == pytest.approx(0.032) @@ -1661,6 +1902,7 @@ class TestEmitter: latency_ms=1, response=None, error="x", + reasoning_applies=True, ) assert len(rec.rows[0]["messages"]) < 500 # base64 不整段进库(VT R12) @@ -1677,6 +1919,7 @@ class TestEmitter: latency_ms=1, response=_resp(), error=None, + reasoning_applies=True, ) # 不抛(降级不冒泡) @@ -1773,6 +2016,7 @@ async def _emit_with_cap(messages, *, cap, response=_LONG, thinking=_LONG): latency_ms=1, response=_resp(content=response, thinking=thinking), error=None, + reasoning_applies=True, ) return rec.rows[0] diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index c6fa423..77fa8c6 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -8,17 +8,29 @@ import pytest from loguru import logger -from polygateway.providers import get_provider +from polygateway.providers import ProviderProfile, ThinkingWire, get_provider from polygateway.thinking import ( DEFAULT_CAPABILITIES, ThinkingCapability, + ThinkingUnsupportedError, + effective_effort, get_capability, observe_thinking, reconcile_thinking, register_capability, resolve_thinking, ) -from polygateway.types import ThinkingObservation +from polygateway.types import Effort, ThinkingObservation + +_MYSTERY = ProviderProfile( + name="mystery", + thinking=ThinkingWire(off=None, on_base=None, effort_key=None), + strip_think_tags=False, +) +"""形态完全未知的 provider(issue #5 的守卫对象)。 + +2026-09-04 起默认表 8 段全部有形态,故未知样本改为显式构造——测的是**机制** +(不知道怎么表达就报错并指路),不是某个段当时的配置。""" def _warnings(): @@ -125,7 +137,7 @@ class TestThinkingCapability: assert get_capability("some-brand-new-model") is None def test_register_capability_is_pure(self): - table = register_capability("x-1", ThinkingCapability(True, "实测")) + table = register_capability("x-1", ThinkingCapability((Effort.NONE, Effort.AUTO), "实测")) assert get_capability("x-1", table=table) is not None assert get_capability("x-1") is None # 默认表未被污染 @@ -135,66 +147,377 @@ class TestThinkingCapability: class TestResolveThinking: - """五条判定规则(顺序即语义);设计 §5 真值表。""" + """五道关卡(顺序即语义)与 nearest 映射;设计 §4.1。 - def test_rule1_none_injects_nothing(self): + 每一关都有独立的失败模式,漏测哪一关,判定顺序被调换都不会被抓住——而顺序 + 在本函数里**就是**语义(Phase 4 落进 Phase 5 就丢掉"这个模型根本关不掉")。 + """ + + # —— Phase 1: 不表态 —— + + def test_phase1_absent_effort_injects_nothing(self): + """没表态就什么都不注入,用模型自己的默认档(与 `none` 严格区分)。""" got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3") - assert got == {} + assert got.payload == {} + assert got.applied_effort is None - @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) + # —— Phase 2: 形态未知 —— - def test_rule3_unregistered_model_warns_but_passes(self): + @pytest.mark.parametrize("effort", [Effort.NONE, Effort.AUTO, Effort.HIGH]) + def test_phase2_unknown_wire_points_to_register(self, effort): + """不知道怎么发就报错并指路;静默放行是 issue #5 修掉的那种欺骗。 + + 文案必须报出**请求的档位**而非"开/关"方向: `Effort` 是非空字符串,拿它 + 的真值判方向会把 `none` 说成"开启形态未知",指错了排查方向。 + """ + with pytest.raises(ThinkingUnsupportedError, match="register_provider") as exc: + resolve_thinking(_MYSTERY, None, effort, model="kimi-k3") + msg = str(exc.value) + assert "extra_body" in msg + assert "kimi-k3" in msg + assert effort.value in msg + + def test_phase2_reads_the_form_the_asked_for_tier_needs(self): + """请求 `none` 只需要**关闭**形态: 开启形态未知与这次请求无关。 + + 旧版 `slot = thinking_on if enable_thinking else thinking_off` 即按请求方向 + 取字段;档位化后一度写成"只看 `on_base`",于是一个已注册了关闭形态的自定义 + provider 在请求 `none` 时被误拒,还被指向它已经做过的 `register_provider` + ——指错方向比不指更糟(设计 §2 处置表第 2 条,2026-09-05 独立验证查出)。 + """ + profile = ProviderProfile( + name="off_only", + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, on_base=None, effort_key=None + ), + strip_think_tags=False, + ) + cap = ThinkingCapability((Effort.NONE, Effort.AUTO), "构造: 关得掉,开启形态却未登记") + got = resolve_thinking(profile, cap, Effort.NONE, model="x-1") + assert got.payload == {"thinking": {"type": "disabled"}} + assert got.applied_effort is Effort.NONE + + @pytest.mark.parametrize("effort", [Effort.AUTO, Effort.HIGH]) + def test_phase2_still_fires_when_the_on_form_is_the_missing_half(self, effort): + """反方向不得被一并放过: 要开推理而开启形态未知,仍须报错并指路注册。""" + profile = ProviderProfile( + name="off_only", + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, on_base=None, effort_key=None + ), + strip_think_tags=False, + ) + cap = ThinkingCapability((Effort.NONE, Effort.AUTO, Effort.HIGH), "构造") + with pytest.raises(ThinkingUnsupportedError, match="register_provider") as exc: + resolve_thinking(profile, cap, effort, model="x-1") + assert effort.value in str(exc.value) + + def test_phase2_beats_the_capability_checks(self): + """形态未知时无从注入,能力如何无关紧要——Phase 2 必须先于 4/5。""" + cap = ThinkingCapability((Effort.AUTO,), "构造") + with pytest.raises(ThinkingUnsupportedError, match="register_provider"): + resolve_thinking(_MYSTERY, cap, Effort.NONE, model="whatever") + + # —— Phase 3: 能力未登记 —— + + def test_phase3_unregistered_warns_then_injects(self): + """新模型上线不该被库挡住,但也不该假装成功: 喊一声再尽力注入。""" messages, sink_id = _warnings() try: - got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9") + got = resolve_thinking(get_provider("minimax"), None, Effort.NONE, model="MiniMax-M9") finally: logger.remove(sink_id) - assert got == {"reasoning_effort": "none"} + assert got.payload == {"reasoning_effort": "none"} + assert got.applied_effort is Effort.NONE assert any("MiniMax-M9" in m for m in messages) - def test_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_phase3_can_be_silenced_on_the_hot_path(self): + """装配期已经喊过一次,逐次调用再喊只会刷屏;判定结果不受影响。""" + messages, sink_id = _warnings() + try: + got = resolve_thinking( + get_provider("minimax"), + None, + Effort.NONE, + model="MiniMax-M9", + warn_unregistered=False, + ) + finally: + logger.remove(sink_id) + assert got.payload == {"reasoning_effort": "none"} + assert not [m for m in messages if "MiniMax-M9" in m] - def test_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_phase3_does_not_validate_tiers(self): + """能力未知就没有清单可比对,拿空清单去拒绝档位等于凭空报错。""" + got = resolve_thinking( + get_provider("zhipu"), + None, + Effort.XHIGH, + model="glm-9-not-registered", + warn_unregistered=False, + ) + assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "xhigh"} + assert got.applied_effort is Effort.XHIGH - def test_rule5_normal_path(self): + # —— Phase 4: 关不掉 —— + + def test_phase4_before_phase5(self): + """请求 `none` 而模型关不掉: 文案必须给出可执行替代与 env 键名。 + + 若落进 Phase 5 的通用分支,报错会退化成"不支持 none,可选 low/high/max", + 丢掉"这个模型根本关不掉"这个关键信息——下游随后就会去找 extra_body 那条 + 绕过的路,而那正是 issue #20 的成因。 + """ + cap = get_capability("glm-5.3") + with pytest.raises(ThinkingUnsupportedError) as exc: + resolve_thinking(get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3") + msg = str(exc.value) + assert "glm-5.3" in msg + assert "'low'" in msg, "必须给出 cheapest_effort 的值" + assert "REASONING_EFFORT" in msg, "必须给出 env 键名" + assert "可选档位" not in msg, "退化成 Phase 5 的通用文案即失去可执行替代" + + def test_phase4_never_maps_even_with_nearest(self): + """`none` 不走映射: 把"关不掉"映射成"开着最低档"就是又一次静默降级。""" + cap = get_capability("glm-5.3") + with pytest.raises(ThinkingUnsupportedError, match="REASONING_EFFORT"): + resolve_thinking( + get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest" + ) + + def test_phase4_only_blocks_the_off_direction(self): + """关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。 + + 期望片段 2026-09-05 由 `{}` 改成 minimax 的 `on_base` 实际值: issue #21 把 + 该段的"开"改回带 medium(T2 的"开档不注入"是推定,T10 实测推翻)。本用例守的 + 是 Phase 4 只拦关闭方向,注入什么由 wire 决定,故随 wire 走。 + """ + cap = get_capability("MiniMax-M2.7") + got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7") + assert got.payload == {"reasoning_effort": "medium"} + assert got.applied_effort is Effort.AUTO + + def test_phase4_passes_when_none_is_registered(self): cap = get_capability("MiniMax-M3") - assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == { - "reasoning_effort": "none" - } + got = resolve_thinking(get_provider("minimax"), cap, Effort.NONE, model="MiniMax-M3") + assert got.payload == {"reasoning_effort": "none"} + assert got.applied_effort is Effort.NONE - 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") + # —— Phase 5: 档位打空 —— + + def test_phase5_lists_tiers_for_tiered_model(self): + """档位型模型: 文案必须列出它真有的档,否则下游只能猜。""" + cap = get_capability("glm-5.3") + with pytest.raises(ThinkingUnsupportedError) as exc: + resolve_thinking(get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3") + msg = str(exc.value) + assert "medium" in msg and "glm-5.3" in msg + assert "可选档位" in msg + assert "low" in msg and "high" in msg and "max" in msg + + def test_phase5_says_toggle_only_for_switch_model(self): + """纯开关型模型没有档位,对它说"可选档位"是错的(设计 §3.2 第三个派生量)。 + + 样本 2026-09-05 由 MiniMax-M3 换成 glm-4.6v: T10 实测 M3 的六个强度值全部生效, + 它不再是纯开关型;glm-4.6v 是实测证据最硬的 (none, auto) 模型,且 zhipu 的 wire + 有 effort_key——这两点缺一不可,否则命中的是"该 provider 没有档位键"那条分支。 + """ + cap = get_capability("glm-4.6v") # (none, auto): 能开能关,但没有强度档 + with pytest.raises(ThinkingUnsupportedError) as exc: + resolve_thinking(get_provider("zhipu"), cap, Effort.HIGH, model="glm-4.6v") + msg = str(exc.value) + assert "可选档位" not in msg + assert "该模型只有开关" in msg + assert "auto" in msg and "none" in msg + + def test_phase5_wording_forks_on_is_tiered(self): + """两条分叉必须真的不同——同一句话套两种模型等于没分叉。""" + with pytest.raises(ThinkingUnsupportedError) as tiered: + resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.3"), Effort.MEDIUM, model="glm-5.3" + ) + with pytest.raises(ThinkingUnsupportedError) as switch: + resolve_thinking( + get_provider("zhipu"), + get_capability("glm-4.6v"), + Effort.MEDIUM, + model="glm-4.6v", + ) + assert str(tiered.value) != str(switch.value) + + def test_phase5_passes_a_supported_tier(self): + cap = get_capability("glm-5.3") + got = resolve_thinking(get_provider("zhipu"), cap, Effort.MAX, model="glm-5.3") + assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "max"} + assert got.applied_effort is Effort.MAX + + def test_auto_never_trips_phase5(self): + """`auto` = 不指定档位,可满足性只取决于 wire 有没有 on_base。 + + 它不是写进 `effort_key` 的取值,故不受档位清单约束。反过来判会让存量的 + `ENABLE_THINKING=true`(T5 起等价于 auto)在 deepseek/glm-5.3 这类清单里 + 没有 auto 的模型上当场报错——设计 §12 明确承诺存量配置继续可跑。 + """ + cap = get_capability("deepseek-v4-pro") # (none, high, max),清单里没有 auto + got = resolve_thinking(get_provider("deepseek"), cap, Effort.AUTO, model="deepseek-v4-pro") + assert got.payload == {"thinking": {"type": "enabled"}} + assert got.applied_effort is Effort.AUTO + + # —— nearest 映射(fallback 的逃生口)—— + + def test_nearest_ties_go_cheaper(self): + """等距取弱: 省钱优先,库不替下游涨价(一次 medium→max 是数倍账单)。""" + cap = get_capability("glm-5.3") # (low, high, max) + messages, sink_id = _warnings() + try: + got = resolve_thinking( + get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3", fallback="nearest" + ) + finally: + logger.remove(sink_id) + assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "low"} + assert any("glm-5.3" in m and "medium" in m and "low" in m for m in messages) + + def test_nearest_ties_go_cheaper_on_the_strong_side_too(self): + """xhigh 与 high/max 位序各差 1,同样取弱侧——规则不因方向而变。""" + cap = get_capability("glm-5.3") + got = resolve_thinking( + get_provider("zhipu"), cap, Effort.XHIGH, model="glm-5.3", fallback="nearest" + ) + assert got.applied_effort is Effort.HIGH + + def test_nearest_goes_up_when_the_only_neighbour_is_stronger(self): + """minimal 之下无档可选,映射必须上行到 low,而不是无解报错。""" + cap = get_capability("glm-5.3") + got = resolve_thinking( + get_provider("zhipu"), cap, Effort.MINIMAL, model="glm-5.3", fallback="nearest" + ) + assert got.applied_effort is Effort.LOW + + def test_nearest_never_turns_reasoning_off(self): + """请求"想得浅一点"绝不能被映射成"别想了": 那是方向反转,不是省钱。""" + cap = get_capability("glm-4.6v") # (none, auto) + got = resolve_thinking( + get_provider("zhipu"), cap, Effort.HIGH, model="glm-4.6v", fallback="nearest" + ) + assert got.applied_effort is Effort.AUTO + assert got.payload == {"thinking": {"type": "enabled"}} + + def test_nearest_still_errors_when_no_on_tier_exists(self): + """只能关不能开的模型,映射无解——报错而非挑一个反向的档。""" + cap = ThinkingCapability((Effort.NONE,), "构造: 只登记了关闭档") + with pytest.raises(ThinkingUnsupportedError, match="only-off"): + resolve_thinking( + get_provider("minimax"), cap, Effort.HIGH, model="only-off", fallback="nearest" + ) + + def test_error_fallback_is_the_default(self): + """默认关闭映射的理由是钱: 静默的 medium→max 在 GLM-5.3 上是数倍账单。""" + cap = get_capability("glm-5.3") + with pytest.raises(ThinkingUnsupportedError): + resolve_thinking(get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3") + + def test_resolution_reports_applied_effort_after_mapping(self): + """遥测记的必须是**实际**发出去的档,否则压测按档分组时挂在从未发出的档下。""" + cap = get_capability("glm-5.3") + got = resolve_thinking( + get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3", fallback="nearest" + ) + assert got.applied_effort is Effort.LOW + assert got.applied_effort is not Effort.MEDIUM + + # —— 注入形态 —— + + def test_auto_injects_on_base_only(self): + """`auto` 逐字节等于旧的 `thinking_on`: 开启,但不附任何档位。""" + got = resolve_thinking( + get_provider("qwen"), get_capability("qwen3.7-plus"), Effort.AUTO, model="qwen3.7-plus" + ) + assert got.payload == {"enable_thinking": True} + + def test_effort_key_none_rejects_a_tier(self): + """qwen 系只有开关没有档位键: 硬塞一个档位只会发出一个厂商不认的字段。""" + cap = ThinkingCapability((Effort.NONE, Effort.LOW), "构造: 假设它有档位") + with pytest.raises(ThinkingUnsupportedError, match="没有档位键"): + resolve_thinking(get_provider("qwen"), cap, Effort.LOW, model="qwen-hypothetical") + + def test_provider_without_an_off_form_says_which_half_is_missing(self): + """`off is None` ≠ `on_base is None`: 前者是"关不了",后者是"不知道怎么发"。""" + profile = ProviderProfile( + name="no_off", + thinking=ThinkingWire(off=None, on_base={}, effort_key="reasoning_effort"), + strip_think_tags=False, + ) + cap = ThinkingCapability((Effort.NONE, Effort.LOW), "构造: 能力表说能关,形态却没有") + with pytest.raises(ThinkingUnsupportedError, match="没有关闭形态") as exc: + resolve_thinking(profile, cap, Effort.NONE, model="x-1") + assert "register_provider" not in str(exc.value), "形态已知,不该指向注册" + + # —— 归一化: 本函数是档位进入库内的第四条入口(设计 §4.4) —— + + def test_a_bare_string_tier_is_normalised_at_the_door(self): + """`resolve_thinking` 在 `__all__` 里,下游直调时传的天然是裸串。 + + 第三参数本次由 `bool` 换成 `Effort`,而下游最自然的写法是从 JSON/配置读出来 + 的 `"low"`。不在入口归一,`_inject` 撞 `.value` 抛的是 `AttributeError`—— + 一个未文档化、也不属错误四分类的异常(2026-09-05 独立验证查出)。 + """ + got = resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.3"), "low", model="glm-5.3" + ) + assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "low"} + assert got.applied_effort is Effort.LOW + + def test_a_bare_none_string_still_means_the_off_tier(self): + """裸 `"none"` 必须走到关闭形态,而不是被当成某个开启档。 + + 身份比较 `"none" is Effort.NONE` 恒假,漏归一的后果是**静默判否**: + `_wire_unknown_for` 的 `effort is not Effort.NONE` 恒真,于是关闭请求会去看 + `on_base`——正是设计 §2 处置表第 2 条点名要避免的误判方向。 + """ + got = resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.2"), "none", model="glm-5.2" + ) + assert got.payload == {"thinking": {"type": "disabled"}} + assert got.applied_effort is Effort.NONE + + def test_a_bare_none_string_reaches_phase4_on_a_model_that_cannot_disable(self): + """漏归一时 Phase 4 整条被绕过: 关不掉的模型会被静默放行成"开启"。""" + with pytest.raises(ThinkingUnsupportedError, match="无法关闭推理") as exc: + resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.3"), "none", model="glm-5.3" + ) + assert "'low'" in str(exc.value), "Phase 4 的可执行替代不能丢" + + def test_an_illegal_tier_string_names_this_function_as_the_origin(self): + """非法档位报 `ValueError` 并指回**是哪一处**填错——档位有四条入口,不说清 + 就得让人自己去翻。""" + with pytest.raises(ValueError, match="resolve_thinking") as exc: + resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.3"), "lowest", model="glm-5.3" + ) + assert "非法推理档位" in str(exc.value) class TestReconcileThinking: - """声明 × 观测对账(设计 §5): 矛盾出文案,不表态出 None。 + """声明 × 观测对账(设计 §4.3): 矛盾出文案,不表态出 None。 文案本身是被断言对象——判定与日志分离正是为此: 告警内容可直接比对,不必 去解析日志格式。 + + 判据自 2026-09-05 起是**档位**而非布尔(设计 §4.3): `Effort.NONE` 走"要求 + 关闭"一支,其余档走"要求开启"一支。档位化不是换个参数名——文案里写的是本次 + 真正发出去的那一档,而 transport 的节流键正按它分离,两者必须同源。 """ _CAP = ThinkingCapability( - can_disable=True, evidence="2026-08-02 实测 reasoning_effort=none 可关闭" + (Effort.NONE, Effort.AUTO), "2026-08-02 实测 reasoning_effort=none 可关闭" ) def test_off_but_observed_with_a_registered_capability_blames_the_table(self): """已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。""" msg = reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.OBSERVED, capability=self._CAP, model="MiniMax-M3", @@ -207,7 +530,7 @@ class TestReconcileThinking: def test_off_but_observed_unregistered_never_claims_a_table_entry(self): """未登记模型没有"能力表声称"这回事——说它就是撒谎。""" msg = reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.OBSERVED, capability=None, model="MiniMax-M9", @@ -219,13 +542,13 @@ class TestReconcileThinking: def test_registered_and_unregistered_wordings_differ(self): registered = reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.OBSERVED, capability=self._CAP, model="MiniMax-M3", ) unregistered = reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.OBSERVED, capability=None, model="MiniMax-M3", @@ -236,7 +559,7 @@ class TestReconcileThinking: def test_on_but_absent_is_a_contradiction(self, capability): """上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。""" msg = reconcile_thinking( - enable_thinking=True, + effort=Effort.AUTO, observation=ThinkingObservation.ABSENT, capability=capability, model="qwen3.7-plus", @@ -248,7 +571,7 @@ class TestReconcileThinking: def test_on_but_unknown_admits_it_cannot_confirm(self, capability): """issue #17 的诚实版本: 明说"我注入了,但我看不见结果"。""" msg = reconcile_thinking( - enable_thinking=True, + effort=Effort.AUTO, observation=ThinkingObservation.UNKNOWN, capability=capability, model="MiniMax-M3", @@ -265,7 +588,7 @@ class TestReconcileThinking: """ assert ( reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.ABSENT, capability=self._CAP, model="qwen3.7-plus", @@ -277,7 +600,7 @@ class TestReconcileThinking: """UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。""" assert ( reconcile_thinking( - enable_thinking=False, + effort=Effort.NONE, observation=ThinkingObservation.UNKNOWN, capability=self._CAP, model="MiniMax-M3", @@ -293,7 +616,7 @@ class TestReconcileThinking: """调用方不表态,就无从谈"违背"。""" assert ( reconcile_thinking( - enable_thinking=None, + effort=None, observation=observation, capability=self._CAP, model="MiniMax-M3", @@ -304,10 +627,201 @@ class TestReconcileThinking: def test_on_and_observed_is_exactly_what_was_asked_for(self): assert ( reconcile_thinking( - enable_thinking=True, + effort=Effort.AUTO, observation=ThinkingObservation.OBSERVED, capability=self._CAP, model="MiniMax-M3", ) is None ) + + @pytest.mark.parametrize("effort", [Effort.LOW, Effort.HIGH, Effort.MAX]) + def test_a_strength_tier_is_an_on_request_not_an_off_one(self, effort): + """强度档必须走"要求开启"一支: 观测到推理正是它要的结果,不得报警。 + + 判据写成真值性(`if not effort`)会在这里翻车——`Effort.NONE` 的取值是 + 非空串 `"none"`,恒为真;那种写法会把每一个强度档都送进"要求关闭"分支, + 于是"想了"被当成矛盾,而"没想"反倒沉默,告警方向整个颠倒。 + """ + assert ( + reconcile_thinking( + effort=effort, + observation=ThinkingObservation.OBSERVED, + capability=ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "构造"), + model="glm-5.3", + ) + is None + ) + + def test_the_wording_names_the_tier_that_was_asked_for(self): + """文案要写出**本次这一档**: 节流键按档分离,文案不分档就看不出是哪一档。""" + low = reconcile_thinking( + effort=Effort.LOW, + observation=ThinkingObservation.ABSENT, + capability=None, + model="glm-5.3", + ) + max_ = reconcile_thinking( + effort=Effort.MAX, + observation=ThinkingObservation.ABSENT, + capability=None, + model="glm-5.3", + ) + assert low is not None and max_ is not None + assert "low" in low and "max" in max_ + assert low != max_ + + def test_none_and_observed_is_the_issue_20_contradiction(self): + """请求 `none` 却观测到推理 —— issue #20 要恢复的那条报警,判据是**档位相等**。 + + 与上一条互为对照: 同样是 OBSERVED,`none` 必须喊、强度档必须沉默。把分支 + 条件写反(`is not Effort.NONE`)会让这两条同时红,单有一条则抓不住。 + """ + msg = reconcile_thinking( + effort=Effort.NONE, + observation=ThinkingObservation.OBSERVED, + capability=None, + model="glm-5.3", + ) + assert msg is not None and "none" in msg + + +class TestEffortVocabulary: + """八档封闭词汇(设计 §3.1);`auto` 不可省——9 个纯开关型模型无强度档可填。""" + + def test_none_and_auto_are_distinct_members(self): + assert Effort.NONE != Effort.AUTO + assert Effort("none") is Effort.NONE + assert Effort("auto") is Effort.AUTO + + def test_vocabulary_is_exactly_eight(self): + assert len(list(Effort)) == 8 + + def test_values_are_wire_literals(self): + # 档位值直接写进请求体,改名即改变发出去的字节 + assert [e.value for e in Effort] == [ + "none", + "auto", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ] + + +class TestCapabilityTierList: + """能力表从 bool 变成档位清单(设计 §3.2);三个派生量不存字段,存了必漂移。""" + + def test_capability_derives_can_disable(self): + assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").can_disable is True + assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").can_disable is False + + def test_cheapest_effort_skips_none(self): + # 「关不掉时的可执行替代」取的是除 none 外最弱的一档 + assert ( + ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "实测").cheapest_effort + is Effort.LOW + ) + assert ( + ThinkingCapability((Effort.NONE, Effort.HIGH, Effort.MAX), "实测").cheapest_effort + is Effort.HIGH + ) + assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").cheapest_effort is Effort.AUTO + assert ThinkingCapability((Effort.AUTO,), "实测").cheapest_effort is Effort.AUTO + + def test_cheapest_effort_is_none_when_only_none(self): + # 只能关不能开: 没有可推荐的「最省的开启档」 + assert ThinkingCapability((Effort.NONE,), "实测").cheapest_effort is None + + def test_is_tiered_excludes_none_and_auto(self): + # 纯开关型模型不该被告知「可选档位」——它没有档位 + assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").is_tiered is False + assert ThinkingCapability((Effort.AUTO,), "实测").is_tiered is False + assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").is_tiered is True + + def test_empty_efforts_rejected(self): + with pytest.raises(ValueError, match="至少"): + ThinkingCapability((), "实测") + + def test_duplicate_efforts_rejected(self): + with pytest.raises(ValueError, match="重复"): + ThinkingCapability((Effort.LOW, Effort.LOW), "实测") + + def test_glm53_cannot_be_disabled(self): + # 三源一致(智谱官方文档/cherry-studio/OpenRouter): thinking.type 只接受 enabled + cap = get_capability("glm-5.3") + assert cap is not None + assert cap.can_disable is False + assert cap.cheapest_effort is Effort.LOW + + def test_m2_series_still_cannot_be_disabled(self): + # 迁移回归: 旧表用 can_disable=False 表达的事实,新表用「none 不在清单里」表达 + assert get_capability("MiniMax-M2.7").can_disable is False + assert get_capability("MiniMax-M2.5").can_disable is False + assert get_capability("MiniMax-M3").can_disable is True + + +class TestEffectiveEffort: + """三层优先级的**唯一**判定处(设计 §4.2): 请求级 > 源级 > 语法糖 > 不表态。 + + 收口成一个纯函数,是因为它此前在装配守卫与 transport 里各写了一份就地转换: + 两份各自演化的判定,迟早会在"装配期放行、运行期报错"这种最难查的形态上分叉。 + """ + + def test_request_beats_source(self): + assert ( + effective_effort( + request_effort=Effort.MAX, source_effort=Effort.LOW, enable_thinking=None + ) + is Effort.MAX + ) + + def test_source_beats_sugar(self): + assert ( + effective_effort(request_effort=None, source_effort=Effort.HIGH, enable_thinking=None) + is Effort.HIGH + ) + + def test_none_request_does_not_clear_source(self): + """请求级"没表态"绝不能被读成"要求关闭"——那会静默改掉源级的默认档。""" + assert ( + effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=None) + is Effort.LOW + ) + + def test_request_none_tier_is_an_opinion(self): + """`Effort.NONE` 是一次明确的表态,必须压过源级档位而不是被当成缺省。""" + assert ( + effective_effort( + request_effort=Effort.NONE, source_effort=Effort.MAX, enable_thinking=None + ) + is Effort.NONE + ) + + def test_enable_thinking_true_is_auto(self): + """`True` → `auto`(开启但不指定强度),而**不是**旧版硬编码的 medium。""" + assert ( + effective_effort(request_effort=None, source_effort=None, enable_thinking=True) + is Effort.AUTO + ) + + def test_enable_thinking_false_is_the_none_tier(self): + assert ( + effective_effort(request_effort=None, source_effort=None, enable_thinking=False) + is Effort.NONE + ) + + def test_sugar_is_the_last_word_only(self): + """语法糖排在最末: 显式配了档位就以档位为准(矛盾组合已被构造期挡下)。""" + assert ( + effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=True) + is Effort.LOW + ) + + def test_all_absent_is_no_opinion(self): + """三层都不表态 → None(随模型默认),与 `Effort.NONE` 严格区分。""" + assert ( + effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None + ) diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index e7322da..6efafe8 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -10,6 +10,7 @@ from polygateway.types import ( BackpressurePolicy, BreakerConfig, ChatRequest, + Effort, GlobalLimits, LLMResponse, RetryPolicy, @@ -566,3 +567,46 @@ class TestChatRequestDimensions: ) assert request.tenant_id == "t1" assert request.meta == {"batch": "b-42"} + + +class TestSourceConfigEffortNormalization: + """源级档位在**构造期**归一成 `Effort`(issue #20;2026-09-05 独立验证查出)。 + + 库内一律用 `is Effort.NONE` 做身份比较,而 `Effort` 是 `StrEnum`——下游从 + JSON/配置读出来的天然是裸字符串,不归一就会在**错误路径上**误判并二次崩溃。 + """ + + def test_bare_string_tier_is_normalized(self): + """`reasoning_effort="low"` 必须存成 `Effort.LOW`,而不是原样留个 str。""" + assert _make_source(reasoning_effort="low").reasoning_effort is Effort.LOW + + def test_whitespace_and_case_are_normalized(self): + """与 `.env` 那条路同口径: 行尾空格与大写写法是常态,档位无大小写语义。""" + assert _make_source(reasoning_effort=" LOW ").reasoning_effort is Effort.LOW + + def test_consistent_bare_string_survives_the_contradiction_guard(self): + """设计 §4.2 明说"二者一致则放行",裸字符串写法不得被判成矛盾。 + + 修复前实测: `("none" is Effort.NONE)` 为假 → 判为矛盾 → 拼文案时 `.value` + 抛 `AttributeError`,连承诺的 `ValueError` 都拿不到。 + """ + source = _make_source(enable_thinking=False, reasoning_effort="none") + assert source.reasoning_effort is Effort.NONE + + def test_contradiction_still_caught_through_a_bare_string(self): + """归一化不得把矛盾一并抹平: `True` + `"none"` 仍是配置错误。""" + with pytest.raises(ValueError, match="矛盾"): + _make_source(enable_thinking=True, reasoning_effort="none") + + def test_illegal_tier_lists_the_whole_vocabulary(self): + """写错档位的人要的是"那该填什么",故报错必须把八档全摆出来并指回字段。""" + with pytest.raises(ValueError) as exc: + _make_source(reasoning_effort="lowest") + message = str(exc.value) + assert "reasoning_effort" in message + assert all(tier.value in message for tier in Effort) + + def test_non_string_tier_is_a_value_error_not_a_crash(self): + """非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。""" + with pytest.raises(ValueError, match="推理档位"): + _make_source(reasoning_effort=3) diff --git a/tests/unit/test_usage_source_domain.py b/tests/unit/test_usage_source_domain.py index f1c58d8..8c583a5 100644 --- a/tests/unit/test_usage_source_domain.py +++ b/tests/unit/test_usage_source_domain.py @@ -134,6 +134,7 @@ async def test_salvage_override_stays_in_domain(usage): stream=True, overlay={}, call_id="cid", + reasoning_effort=None, ) assert result.usage_source in USAGE_SOURCES @@ -261,6 +262,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted): latency_ms=10, response=_resp(emitted), error=None, + reasoning_applies=True, ) assert recorder.rows[0]["usage_source"] in USAGE_SOURCES @@ -275,6 +277,7 @@ async def test_emit_attempt_failed_attempt_stays_in_domain(): latency_ms=10, response=None, error="boom", + reasoning_applies=True, ) assert recorder.rows[0]["usage_source"] in USAGE_SOURCES