From 2553fc7f3427aabfa1e714478ffb4a72666f1bfe Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 00:48:31 -0400 Subject: [PATCH 01/17] docs: record approved thinking contracts and implementation plan --- ...026-09-09-134-thinking-contracts-design.md | 325 +++++++++++++++ .../2026-09-09-134-thinking-contracts.md | 370 ++++++++++++++++++ 2 files changed, 695 insertions(+) create mode 100644 research-wiki/designs/2026-09-09-134-thinking-contracts-design.md create mode 100644 research-wiki/plans/2026-09-09-134-thinking-contracts.md diff --git a/research-wiki/designs/2026-09-09-134-thinking-contracts-design.md b/research-wiki/designs/2026-09-09-134-thinking-contracts-design.md new file mode 100644 index 0000000..871b999 --- /dev/null +++ b/research-wiki/designs/2026-09-09-134-thinking-contracts-design.md @@ -0,0 +1,325 @@ +# 1.3.4:推理意图的可满足性与测试证据契约 + +> 日期:2026-09-09。状态:**已通过独立审查并获人类正式批准;进入实施计划阶段,编码须先完成计划审查**。 +> 用户已批准合并处理 #25/#26/#21,及 D1(未知 AUTO 尽力+告警)、D2(受管推理与 raw 冲突拒绝)、D3(下游显式迁移 namespace/salt)。下文细则供正式设计审查,不重新悬置已决方向。 +> 本轮只修订本文件;未改生产代码/测试、未提交、未启动子代理、未执行真实付费调用。既有审查历史与剩余证据门见 §11。 + +## 1. 目标、非目标与事实源 + +本批修复共同问题:库声明的推理意图、实际发送参数和验证证据应一致;无法确认的事实不得变成成功声明。 + +| 范围 | 交付 | 明确不做 | +| --- | --- | --- | +| #21 | 已登记 AUTO 成员检查;移除默认 provider 偷选 medium;冲突守卫;能力证据审计与迁移说明 | 新推理 DSL、通用 overlay 系统、预算型推理、模型名猜测、无证据批量加 AUTO | +| #25 | 测试侧窄归因函数、逐轮留证、未覆盖汇总;纯本地断言离线化 | 改四分类、整类异常 skip、重跑到绿、扩大生产遥测、自动豁免发布覆盖 | +| #26 | 真实 client→治理→emitter→recorder 守护无推理路径,附变异证据 | 新端口、替换 reasoning_applies、遥测 schema 改造 | +| 后续批次 | #19/#23 归 1.3.5,#22 归 1.3.6,#24 独立设计 | 调用上下文 API、deadline、429 算法、对冲或取消结算重构 | + +规范依据:CLAUDE.md、brainstorming/structured-logging skill、ARCHITECTURE D11、§4.5/5.1/6/7.5/7.8、docs-convention。 +旧设计:`2026-09-04-reasoning-effort-design.md` §3–6/8/12;旧实验:`findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`、`2026-08-25-thinking-observability-regression.md`。 +Issue 原文:`/tmp/polygateway-issue-triage/open-issues.json`。旧文档的“26 模型”“仅 reasoning_tokens 可靠”“下游尚未迁移”不是本轮验证结论。 + +## 2. 当前代码审计 + +| 证据 | 事实与根因 | +| --- | --- | +| `thinking.py::_settle_tier` | `effort is AUTO or effort in supported_efforts` 无条件放行 AUTO;形态与模型能力不能共同约束它 | +| `providers.py::ThinkingWire` | 空 on_base 当前文案混同协议形态与“任意模型默认推理”;需分开 | +| `DEFAULT_PROFILES[minimax]` | on_base 当前为 reasoning_effort=medium,M3 不是仍必然不推理;问题是 applied_effort=auto 却发 medium | +| `openai_compat.py::_build_payload` | resolution 后依次浅层 update extra_body/overlay;raw 可改写或新增推理控制,成功档位与最终字节失配 | +| `client.py::_guard_thinking`/`middleware/cache.py` | 工厂仅解析源级意图;缓存命中早于 transport,请求级、全量注入及同版本策略变化都可能回放旧语义 | +| `middleware/telemetry.py` | 成功尝试、失败尝试、缓存命中、终态失败的档位来源不同,不能统称“成功记实际档” | +| `embedding.py::_emit`/`ocr.py::_emit` | 当前正确传 False;只测 emitter 不覆盖调用点,成功响应默认 None 还会掩盖变异 | +| `test_thinking_live.py`/`test_embed_probe.py` | 前者按异常类别 skip、用正文子串识别 model_not_found;后者捕全部 PolyGatewayError 当“不支持”,都可能遮蔽库回归 | + +本轮核对 `origin/main..HEAD` 为既有 `758a127`/`6a09054`,保留历史提交、不重写;其外因判据按 §6 窄修正。 +当前 `reference/` 只有 cherry-studio、litellm、new-api、vercel-ai-sdk;GovDoc-SaaS、Video-Tree-TRM5、CHSAnalyzer 工作区均缺失,**未核验三项目现行配置**。迁移示例不是下游迁移完成证据。 + +## 3. 备选方案与已批方向 + +| 方案 | 收益 | 代价/结论 | +| --- | --- | --- | +| A:supported_efforts 包含 AUTO 的语义能力 | 不新增模型字段,统一可满足性判据 | 部分旧 True 配置报错,须审计清单并迁移;**用户已选 A** | +| B:新增模型默认推理三态 | 默认开启与未知可分别表达 | 多维护一套事实,易与能力清单漂移;不选 | +| C:provider/模型把 AUTO 映射固定档 | 保持旧配置字节 | 库代选付费档位,模型事实塞进 provider;用户不选 | + +| 决策 | 已批准 | 不采纳的备选及理由 | +| --- | --- | --- | +| D1 | 未登记 AUTO 保留尽力注入+warning,不保证开启;已登记一律成员检查 | 不改成未知即拒绝;也不把未知当能力已验证 | +| D2 | 有受管意图时拒绝 raw 推理冲突;无受管意图保留 raw | 不保留双来源互相覆盖,不从最终 payload 反向猜档位 | +| D3 | 下游显式换 namespace/salt 隔离语义变化 | 不加自动 revision/包版本,不让所有 chat 擅自冷启动,不把能力解析搬进缓存 | + +Issue #25 选“离线硬契约+有限可执行归因+显式未覆盖”;#26 选真实客户端与轻量 recorder 为日常测试、临时 SQLite 为持久化锚点,不要求远程 PG。 + +## 4. 推理语义与最终请求体契约 + +### 4.1 词汇与决策矩阵 + +`supported_efforts` 表示模型在**当前登记的 OpenAI 兼容 wire** 下、有依据可执行的语义集合,不是上游字符串枚举的逐字镜像。 +`AUTO` = 要求开启、不指定强度;不是 Python None(不表态),不是库任选一档,也不是上游必须接收 `auto` 字面值。 +`on_base=None` 是开启形态未知;`on_base={}` 是该协议不加开启字节,**能否据此满足已登记模型 AUTO 另查清单**。非空开关也不豁免成员检查。 +请求档>源级 reasoning_effort>enable_thinking 糖(True→AUTO、False→NONE);None 不覆盖源级表态,源上两字段矛盾的既有构造校验保留。 + +| 生效输入/条件 | 结果 | +| --- | --- | +| effort=None | 不注入,applied_effort=None;raw 仍按旧优先级发送,不推断其档位 | +| 所需方向形态未知 | ThinkingUnsupportedError,指路注册 profile;不因模型未登记而假造 wire | +| 已登记 AUTO 在清单 | 仅发 on_base,不附强度,applied_effort=AUTO | +| 已登记 AUTO 不在清单 | 明确拒绝并列可用档;fallback=nearest 同样拒绝,不能建议 nearest 修复 | +| 已登记显式强度不支持+nearest | 保留最近开启档、等距弱侧;只有纯开关候选时可强度→已登记 AUTO,不能 AUTO→强度 | +| NONE 不支持/没有 off 形态 | 保留专用不可关闭错误;替代配置由用户选择,不自动执行 | +| 未登记 AUTO/强度/NONE | 按已知 wire 尽力注入+warning;wire 表达不了仍拒绝,不编造支持清单,不保证开启/关闭/强度有效 | + +未知 AUTO 的空 on_base 可能发送零推理字节,返回 AUTO 仅表示尽力编码的选择,**不构成能力验证 PASS**。既有 warning 需明确能力未知、可能不生效,保留实例节流。 +NONE 方向仍按 `_wire_unknown_for` 既有分工:off、on_base 皆 None 才是整体未知;off None 而 on_base 已知是缺关闭形态。 + +### 4.2 MiniMax 与能力证据 + +移除默认 minimax.on_base 的 medium,改为空映射;M3 不加 AUTO。显式 medium 仅作为恢复旧字节的迁移示例,不是库推荐的最优成本档。 +按本轮源码清点,默认表 24 个型号、10 个含 AUTO;live 候选 26 型号不等于能力表 26 条。**本候选不授权新增任何 AUTO 条目**;追加须另附逐型号证据并复审,不因未知、不可达或同厂近代型号有能力而补登。 + +| 模型组 | 已有证据与本批处置 | +| --- | --- | +| MiniMax-M3 | 2026-08-25 裸 HTTP 无参数不推理、medium 有推理;2026-09-05 evidence 同向。不加 AUTO,显式档保留 | +| MiniMax-M2.5/M2.7 | 已有 AUTO;历史默认/mandatory 旁证及 T10 开启观测。T10 未留最终 wire,移除 medium 后需补空 on_base 实测,不冒充已复验 | +| qwen3.7-plus/max、qwen3.6-plus、qwen3.5-flash、qwen-plus-latest、glm-4.6v | 保留已有 AUTO 与逐型号证据,不推及其他型号 | +| glm-5/5.1 | 保留既有文档推定 AUTO;T10 回报 glm-5.3,身份不足,不算本型号实测 | +| deepseek-v4-pro/flash/flash-vision-exp、glm-5.2/5.3/5.3-flash、kimi-k3/kimi-for-coding | 现无 AUTO;显式档成立不等于 AUTO wire 成立,本批仍拒绝其 AUTO | +| gpt-5.5 | 现无 AUTO;历史报告 `tier_probe_20260905_184307.md:97` 无参数基线 5/5 rt=18、身份一致,是候选线索,但最终 payload/raw 覆盖未留证,不直接追加 | +| gpt-5.4、claude-opus-5/sonnet-5、gemini-3.1-pro | 现无 AUTO;限额/上游错误导致未覆盖,不能以“默认 medium”或同代替代证据 | +| claude-haiku-5、gemini-3-flash | 未登记 live 候选,按 D1 尽力+告警;不自动登记 | + +历史报告仅局部抽查;本轮未全表复验、未新跑付费实验。默认 wire 改变的 M2 两型是发布前显式缺测项,不能靠缓存回放旧 medium 的成功结果过门。 + +### 4.3 D2 冲突规则(窄边界,不做通用 overlay) + +“受管意图”指按 §4.1 得到的 effort 非 None,**包括 NONE、AUTO、糖及未知模型**。单凭 raw 不构成受管意图。 +当前浅层次序保持为 `基础 payload → resolution.payload → source.extra_body → request.overlay`;禁止改成深合并。守卫只校验所有权,不重写/删除 raw,不反推档位。 + +| 检查对象 | 精确规则 | +| --- | --- | +| 已知 raw 推理控制 | 顶层 `reasoning_effort`、`enable_thinking`、`thinking`、`thinking_budget`、`reasoning`、`thinkingConfig` 为控制根;`output_config` 为对象且含 `effort` 也算控制。这是显式有限词表,不按任意键的子串/模型名猜测 | +| 当前 profile 的控制根 | 加入 on_base、off 的全部顶层键及非 None 的 effort_key;即使本次为 AUTO 且 on_base 为空,也保护 effort_key/off 根。保护范围取开关两向并集,不只取本次实际注入键 | +| 同值与遮蔽 | 有受管意图时,extra_body 或 overlay **任一层**出现控制根即拒绝,值相同也拒绝;被后一层遮蔽也不豁免,避免双来源随配置变化重新失配 | +| 嵌套/浅覆盖 | `thinking={}`、`thinking={"budget_tokens":100}` 也拒绝:替换整个根会删除受管 type。当前 wire 持有某根时,raw 仅改其看似无关子键仍拒绝。根未被 wire 持有时,`output_config={"format":"json"}` 不因兄弟键 effort 被保护而误拒 | +| 新增而非覆盖 | AUTO 的空片段遇 raw reasoning_effort=high 仍拒绝;qwen 受管开关遇 raw reasoning_effort/thinking_budget 也拒绝,不能只检查字典交集 | +| 无受管意图 | 即请求、源级档与糖都不表态,保留 extra_body→overlay 原有浅覆盖(含 raw 推理),applied_effort=None;原 model/messages/stream/stream_options 禁写规则照旧 | + +自定义 wire 不新增路径 DSL:effort_key 仍是一个**顶层字面键**,不把点号解释成嵌套路径;on_base/off 可含嵌套对象,raw 守卫保护其整个顶层根。 +自定义 on_base 不得包含自己的 effort_key,也不得借标准 reasoning_effort 偷带档位;无论其值是 medium、auto 或 None 都拒绝为配置错误,不能默默删键。标准嵌套 `output_config.effort` 同属禁带强度的已知路径;不解析任意私有嵌套方言。需要强度请走显式档,不能将其固化在开启片段。 +其余自定义不透明方言的语义真实性由注册者提供证据;本批保证声明键不被 raw 冲掉,**不宣称可以识别所有未声明的私有别名/预算语义**。扩展别名应登记 wire 后受控,不新增通用参数解释器。 + +### 4.4 守卫时机、错误与保证范围 + +| 入口 | 时机/职责 | +| --- | --- | +| 工厂 `_guard_thinking` | 已有 profile/capability/source 材料齐全;装配期(网络及准入前)校验 wire、源级可满足性与源 extra_body 冲突,抛 ThinkingUnsupportedError(配置 ValueError)。工厂拒绝的源不能靠未来请求覆盖“救活” | +| `chat` 前置参数校验 | 保留 validate_request_overlay;请求显式档+调用 overlay 的已知控制词表冲突可在进入洋葱/准入前报配置 ValueError。不新增全源能力预解析,不声称这里可见自定义 transport 的注册表 | +| 默认 transport `_build_payload` | 以本次选中源、实际 profile、请求覆盖后的意图,对两层 raw 再做完整守卫;全量注入与自定义注册表同样覆盖。ThinkingUnsupportedError 翻译 RequestRejectedError,HTTP 发送前拒绝,无重试/换源/故障熔断计数 | +| 自定义 Transport | 不通过默认 transport 的调用仍由端口实现方履约;本批不添加 preflight 端口,不反射读取私有注册表,不承诺能验证任意注入实现 | + +transport 守卫**可能已经经过选源、限流预扣与准入**,拒绝后的结算沿既有 finally 路径;“零 HTTP”不等于“零准入操作”。不移动洋葱层次以制造所有请求均前置拒绝的过宽保证。 +真实成功尝试将同一 resolution.applied_effort 传给响应与 emitter,不重算;AUTO 是未指定强度的选择,不是服务端内部强度。服务端是否接受/执行推理由 ThinkingObservation 回答,失败与缓存行另见 §8。 +缓存命中不经过上述 transport 检查,所有“缓存不得绕过新拒绝”验收都以 §5 完成迁移为前置。 + +## 5. D3 缓存迁移与下游配置 + +### 5.1 显式迁移边界 + +缓存身份继续沿 ARCH §7.5;**不加入自动 revision、包版本、fallback、能力表或 wire 版本字段**,不删除旧键、修改共享 Redis 或重写历史遥测。 +语义变更包括本次 AUTO 成员检查/raw 冲突规则、MiniMax wire 变化,以及同版本下 fallback=nearest→error、能力增删/映射变化、自定义 profile wire 变化;这些都由下游显式换 namespace/salt 隔离。现有源指纹包含部分配置不代表包含全部语义。 + +| 操作 | 下游必须做/边界 | +| --- | --- | +| 迁移前 | 盘点工厂与全量注入、scope/租户、源级糖/档/raw、请求级覆盖、fallback、自定义 wire,以及实际 per-call namespace/salt 覆盖 | +| 切换 | 为受影响调用集合选择从未承载旧语义的 namespace 或 salt;在首次新语义读写前部署到该集合全部调用者。租户前缀与原 epoch salt 保留后再追加人工迁移标记,不共享租户身份 | +| 多源 | 一个缓存 scope 内只要有源受影响,必须隔离该共享身份的调用集合;要求更细范围由下游拆独立 scope/namespace,本批不替下游重分组 | +| 并行与回滚 | 新旧客户端不得共享迁移后身份;回滚到旧 namespace 会重见旧语义,不能说旧键已被清理。未来策略变更须再次显式迁移,不能复用一次标记包办所有变化 | +| 未完成迁移 | 旧缓存或同版本 nearest 写入可被 error 客户端命中,库不会自动验证其新可满足性。文档警示是操作前置,不是新增的自动安全机制 | + +**不要求所有 chat 冷启动**;未受影响调用可保留身份。若受影响与不受影响调用原本共享一套身份,下游须明确选择整体换标记的成本或先拆分,库不代选。 + +### 5.2 旧→新配置与验证矩阵 + +下表是**配置迁移示例及拟新增离线节点**,不是已执行测试。显式档均为当前清单成员示例,不代表所有渠道已实测、不作成本代选。所有受影响且启用缓存的行还须执行 §5.1。 + +| ID/旧配置 | 受影响模型 | 新行为(源级工厂/请求级默认 transport) | 用户明确选择的新配置 | 离线验证锚点(拟) | +| --- | --- | --- | --- | --- | +| M1 `ENABLE_THINKING=true` 或 `REASONING_EFFORT=auto` | MiniMax-M3 | 装配 ThinkingUnsupportedError/请求 RequestRejected,零 HTTP | 删除糖,`REASONING_EFFORT=medium` 可恢复旧 medium 字节;也可选择表内其他档 | `test_thinking.py`:M3 AUTO 拒绝/medium payload | +| M2 同上 | deepseek-v4-pro/flash/flash-vision-exp、glm-5.2 | 同上;开关 on_base 非空也拒绝 | 删除糖,显式 `REASONING_EFFORT=high` 或经用户选择 max | `test_thinking.py`:非空 wire AUTO 成员约束 | +| M3 同上 | glm-5.3/5.3-flash、kimi-k3/kimi-for-coding | 同上,nearest 不能解 AUTO | 删除糖,显式 `REASONING_EFFORT=low`(也可选 high/max) | `test_thinking.py`:AUTO+nearest 拒绝 | +| M4 同上 | gpt-5.4/5.5、claude-opus-5/sonnet-5、gemini-3.1-pro | 同上;不可达不补 AUTO | 删除糖,用户选表内 `REASONING_EFFORT=medium`;这不是新增 live 证明 | `test_thinking.py`:空 wire 非成员拒绝 | +| M5 同上 | MiniMax-M2.5/M2.7 | 仍 AUTO,on_base 从 medium 改空,真实语义待补测 | 保留 True/AUTO 并迁移缓存;需要旧 raw 字节者须完全退出受管意图,不可谎称该模型支持 medium | `test_thinking.py`:空 wire AUTO 字节;live 单列缺测 | +| M6 同上 | qwen 五型、glm-5/5.1/4.6v | AUTO 仍是成员,原开关形态保留 | 保留配置;glm-5/5.1 仍身份未覆盖 | `test_thinking.py`:已登记 AUTO 放行 | +| M7 同上 | 未登记模型(含两个 live 候选) | 已知形态尽力+warning,不保证开启 | 可保留 AUTO;要求确定保证者先取得能力证据再登记,不自动加表 | `test_thinking.py`:未知空/非空 wire 警告 | +| M8 源 HIGH+`EXTRA_BODY={"reasoning_effort":"high"}`;或请求 AUTO+raw HIGH | 所有受管模型,含未知 | 同值也拒绝;工厂或前置/transport 对应守卫报错 | 保留受管档并删除两层 raw 控制键;或清空源糖/档、请求不表态,仅 raw | transport 单测:同值、嵌套、被遮蔽、raw-only | +| M9 请求 medium,源 `EFFORT_FALLBACK=nearest` 改 error | 如 glm-5.3(映射 low→拒绝) | 源未表态时两工厂均可装配;旧身份可命中,新隔离身份在 transport 拒绝 | 改 error 的同时显式迁移 namespace/salt | cache 单测:nearest 写入→error 读,新身份必须 miss | + +配置实例(仅列需替换项,其他已校验的源配置保留): + +| 场景 | 旧 | 新 | +| --- | --- | --- | +| M3 源级 | `LLM__MINIMAX__1__ENABLE_THINKING=true` | 删除该键;`LLM__MINIMAX__1__REASONING_EFFORT=medium` | +| 请求级 AUTO | 源不表态;`chat(..., reasoning_effort="auto")` | 源仍不表态;用户选择 `chat(..., reasoning_effort="medium", cache_salt="epoch-7:thinking-134-a")`(M3 示例) | +| 工厂缓存 | `PGW_CACHE_NAMESPACE=lab:tenant-a` | `PGW_CACHE_NAMESPACE=lab:tenant-a:thinking-134-a`(人工标记,不是新增配置键) | +| per-call 租户覆盖 | `cache_namespace="tenant-a", cache_salt="epoch-7"` | `cache_namespace="tenant-a:thinking-134-a", cache_salt="epoch-7"`;只改工厂默认值对此路径无效 | +| 全量注入 | 构造参数 `cache_namespace="lab:tenant-a"` | 改成上述新 namespace;既有 cache/ttl 参数照常注入 | +| 自定义 wire 偷带强度 | `on_base={"reasoning_effort":"high"}` | `on_base={}`、保留 effort_key;用户显式选 HIGH(须模型支持),并迁移缓存 | + +三项目迁移验收须由各自负责人提供脱敏的实际配置/装配与调用位置,映射 M1–M9、提交所选替代与缓存身份切换证据。**当前三项目均为未核验**;Protocol 合成兼容测试通过也不能代替现行配置迁移验收。 + +### 5.3 旧行为处置 + +| 旧行为 | 处置 | +| --- | --- | +| True→AUTO、请求>源>糖、None/NONE、未知尽力+warning | 保留;未知仍受 wire 可表达性约束 | +| 已登记 AUTO 无条件放行、MiniMax 偷带 medium、受管与 raw 双来源 | 替换,明确放弃这些隐式兼容;迁移见上 | +| 原 raw-only、普通采样优先级、显式强度 nearest | 保留,不做通用 overlay 重构 | +| 缓存旧键/历史遥测/响应字段/端口签名/成本口径 | 保留数据及签名,语义变化靠显式缓存身份迁移,历史不伪造新观测 | +| 任务恢复/断点续跑 | 不适用,无任务状态;并行版本与持久化纪律见 §5.1 | + +## 6. #25:可执行的测试侧归因与证据 + +### 6.1 输入来自哪里 + +仅在测试侧增加一个纯分类函数及窄取证 fixture,复用 Markdown 报告;不新增生产事件、字段、端口或通用诊断框架。**不能从压平的最终异常字符串重建缺失尝试**。 + +| 输入 | 可实现来源与限制 | +| --- | --- | +| 预期请求 | 测试矩阵显式提供目标 model、POST 端点路径、stream、允许的源 origin、预期推理/结构化片段与提示词摘要;不能调用待测 `_build_payload` 生成“预期” | +| 实际请求与 HTTP 响应 | 利用既有 `OpenAICompatTransport(client_factory=...)` 注入带 httpx request/response hooks 的真实 AsyncClient;request hook 检查 method、规范 URL、JSON model/stream/控制片段,Authorization 与该源凭据仅在内存精确比较,输出布尔值 | +| HTTP 错误体 | response hook 保留本次响应引用;该次 complete 结束后读取**已缓冲** content(最多接受 64 KiB 完整内容作为分类输入)。未缓冲/超限/解析失败均标证据不足;不在 hook 预读成功 SSE,不另发请求,不以摘要假装完整 JSON | +| attempt 关联 | 测试专用窄 Transport 委托器原样转发 complete/embed 参数与异常,将入参 call_id(attempt UUID)放入任务局部 ContextVar 供 hooks 使用;finally 复位。只改测试装配,实际 payload/解析仍由真实 transport 执行 | +| 逻辑调用与错误 | 每轮 chat 显式传 session_id=运行 ID、parent_call_id=本轮 UUID,并由测试在 chat 外围将同一二元组绑定任务局部上下文、finally 复位;委托器从该上下文建立二元组→attempt UUID 关联,保存原始异常类/cause 与 HTTP 记录。归因用逐次记录,不靠最后一个错误推断所有前序 | + +带取证的 live 用例走既有全量注入路径,工厂与默认 client_factory 的装配/鉴权构造另有离线回归,不能用测试工厂替换后宣称原工厂已验证。需验证工厂本身的 live 用例若没有该证据通道,失败就保持 FAIL,不补生产接口凑证据。 +请求 hook 校验不通过须记证据并使测试 FAIL;不得改写请求后再称原请求正确。凭据不写哈希、不落盘;URL 去 userinfo/query,只存安全 origin 标识与路径。 + +### 6.2 精确分类:默认 FAIL + +分类输出为 FAIL 或外部未覆盖;外部未覆盖在 pytest 中可呈 SKIP,但报告与覆盖汇总必须记“未覆盖”。成功响应的行为断言仍单独执行,不经此分类器放宽。 + +| 情形 | 精确规则 | +| --- | --- | +| 环境前置缺失 | 测试矩阵列出的必需凭据/可选外部 Protocol 包未提供,网络前记未覆盖;键存在但配置格式错误、值校验失败是 FAIL | +| 404 model_not_found | 仅当本轮恰有一次实际 HTTP 尝试、无其他错误,请求检查全通过,响应为 404,完整 JSON 对象的 `error` 是对象且 `error.type == "model_not_found"`,默认 transport 对外为 RequestRejectedError 且 status 一致,才记“端点回报该请求型号不可用,未覆盖”。JSON 重复键也拒绝作为证据 | +| 普通 404/伪机器字段 | 只在 message 出现子串、字段类型错误、截断体、未缓冲体、端点/model/鉴权不符、响应与 attempt 无法配对,一律 FAIL | +| 429/5xx/401/403、网络错误 | **本批不建立自动外因豁免**:当前资料未给可核验的网关机器码白名单及独立归因来源,全部 FAIL 并保存实收证据;不能仅凭 HTTP 状态、Transient/SourceDead 类或“请求离线测过”跳过 | +| no_sources/stalled/retry_exhausted/AllSourcesExhausted | FAIL;本批不从生产遥测补失踪逐次原因,不将“曾见过一条 429”推断为整个终态均外因 | +| SSE/JSON 解析、空补全、ValueError、一般 RequestRejected/ResultInvalid、断言失败 | FAIL;请求正确不证明解析器或治理正确 | +| CancelledError | 原样穿透,finally 清理,不变成 SKIP | +| 成功但模型身份缺失/不符 | 实发 model 检查不通过是 FAIL;最终 model_reported 缺失/不符且没有独立原始响应身份取证时,保持 FAIL 并标记“身份来源无法区分”,不能推断上游没报。只有独立原始响应证据证明上游身份缺失/不在显式别名集合,才可记能力未覆盖;原始身份正确而解析/搬运丢失或改错必须 FAIL。本批不新增成功 SSE 捕获器,无该证据通道时按 FAIL 处理;别名不按前缀猜测 | + +没有证据通道时宁可 FAIL,不用抽象的“已证明外因”做逃生条件。运维人工确认可附外部证据供发布负责人决定豁免,**不自动把 FAIL 改 PASS 或扩大分类白名单**;未来扩大自动归因须独立给真实样本及反例契约。 +不追加裸 HTTP 对照诊断、自动重跑或悄悄缩小超时/stall;新增调用须先有人类模型/轮次/并发预算。 + +### 6.3 覆盖判据与报告 + +**先声明测试命题,再解释观测**:下述关闭成功判据只适用于“支持 NONE 的型号应成功关闭”,不是要求所有型号均可关闭。 + +| 测试命题 | 观测与结论 | +| --- | --- | +| 已声明可关闭,验证受支持 NONE | 任一 OBSERVED 证伪关闭保证,FAIL;完整必需轮次且请求/身份合格、每轮 ABSENT 才可关闭覆盖 PASS;混入 UNKNOWN 记未覆盖 | +| 已声明不可关闭,T10 绕过库能力守卫验证上游 | 合格 OBSERVED 是本轮仍推理的证据,可支持该测试条件下的不可关闭声明,不是关闭成功,也不得仅因 OBSERVED 而 FAIL;必须按预先固定命题及轮次集合比较观测与声明,UNKNOWN 不补足证据,不据有限探测声称证明所有上游参数均无法关闭 | +| 有意请求不支持档位,验证预期拒绝 | 独立于外因分类器,直接断言预先声明的拒绝类型/状态与机器字段。符合预期的 400/RequestRejected 是负向契约通过,不是外因 SKIP;非预期错误仍 FAIL。上游探测照过请求资格、独立身份可得性与逐轮证据要求 | + +增加离线反例:可关闭声明+OBSERVED 必须 FAIL;不可关闭声明+合格 OBSERVED 不得仅因出现推理而 FAIL;原始 JSON/SSE 含正确 model 但公共响应丢失/改错,必须 FAIL 而非 SKIP。全 UNKNOWN 绝不能算关闭覆盖通过。 +本候选不采用 completion_tokens 长短作为通用关闭证明;旧 prompt_tokens 差异只保留为指定历史样本的 wire/观测回归锚点,不使 UNKNOWN 升格关闭能力 PASS。没有经单独审定的独立关闭证据就记未覆盖,运行时 UNKNOWN 不告警的既有语义不变。 +开启测试保留显式轮数和多数 OBSERVED 规则(`observed_count > planned_rounds / 2`),但必须先保证全部计划轮次完成且请求/身份合格;失败/缺轮不得从分母删除。未知模型单次成功不自动变成能力登记。 + +逐轮先保存结果再判断后续处置;第 2 轮失败不能丢第 1 轮。报告字段包含矩阵 ID、provider、请求/回报模型、stream、请求档/响应 applied_effort、轮次、parent/session、attempt UUID、请求校验结果、异常类/status/安全摘要、已完成轮数及覆盖状态。 +完整错误体仅内存分类,落盘只存机器字段与沿用 2048 字符上限的脱敏摘要;清除已知凭据及私有提示词回显,无法安全保留则摘要省略并标明。不得写 Authorization、完整 .env 或私有提示词。 +每轮用唯一运行目录与轮次文件安全写入,汇总不能覆盖前轮失败;报告写失败使测试 FAIL,不允许无证据 skip。取消 finally 关闭自建资源,不引入无界网络等待。 + +| 既有测试接缝 | 窄修正 | +| --- | --- | +| L9 `_MYSTERY_PROFILE` | 保留显式全 None profile,将“未知形态报错”纳入离线断言;默认 openai 已非未知 | +| L8 `can_disable=False` | 装配拒绝只记本地契约通过,不写“与实测一致”;L8/T10 的 UNKNOWN 按上述覆盖门修正 | +| `_rounds_or_skip`、默认基线、T10 | 统一精确分类与逐轮留证;部分档未覆盖不能汇总为模型全覆盖 | +| compat Protocol/平铺键 | 完整合成 env/注入组件离线测装配;外部 Protocol 缺包只标该兼容项未覆盖,不声称读过缺失项目 | +| embedding 探测 | 去掉捕全错误为“不支持”,使用同一窄判据及 finally 关闭;不扩展端点能力 | + +发布门分开统计离线契约与预先声明的 live 单元。必需单元 SKIP/UNKNOWN/缺行/未完成不能凭 pytest exit 0 放行,须重测或人类明示豁免;不自动缩小覆盖集合。 + +## 7. #26:真实调用链与变异证据 + +复用 `test_embedding.py`、`test_ocr_client.py` 的真实 client、脚本 transport、recorder 工装;仅替换外部传输,不 mock emitter,不手工 emit_attempt 伪造 False。 + +| 验证 | 必须断言 | +| --- | --- | +| embed、recognize_text、parse_layout | 源分别填 True 糖/显式 HIGH;一次 Transient 后成功,每路径确有两条尝试行(一失败一成功),reasoning_effort 全 None | +| 拒绝与耗尽 | 至少一条已发生的失败尝试落库且档位 NULL,主异常仍上抛;不凭本 issue 新增不存在的终态行 | +| chat 阳性 | 已登记 AUTO 源的 True 糖失败行 auto;显式档失败行请求意图;nearest 成功行映射档;emitter 恒 NULL 必须被抓住 | +| SQLite 锚点 | 每个无推理入口至少一组真实 client+临时 SQLite,断言总行数、失败行数和 NULL 数,不用 all([]),不接共享 llm_calls | +| wire 边界 | MockTransport/录制响应验证 embedding、OCR 不发推理参数;layout POST+ZIP GET 属同一次治理尝试,不误算两条遥测 | +| 并发 | 共享 recorder 并发 chat/无推理调用,**按测试指定的 (session_id, parent_call_id) 分组逻辑调用**;组内 call_id 是不同 attempt UUID,集合无交集、档位不串,不把 call_id 当所有重试共用的 ID | + +隔离副本/worktree 逐一变异并还原:embedding False→True;OCR False→True(两个入口分别红);chat True→False;移除 emitter applies 短路。记录节点、目标语义失败断言、退出码,原实现及还原后通过。 +Issue #26 当前实现正确,先红来自上述变异,不为 TDD 改坏主工作区;仅因无关签名异常变红不算杀死目标变异。注入资源由测试自己关闭。 + +## 8. 非功能与四种遥测行口径 + +继续通过 `TelemetryEmitter` 唯一出口与现有 `llm_calls.reasoning_effort`;**不新增生产遥测字段、表、事件或旁路日志流水**。 + +| 行类型 | reasoning_effort 来源 | 分析限制 | +| --- | --- | --- | +| 真实成功尝试 | response.applied_effort(nearest 后);无推理路径由 applies=False 短路为 NULL | 不重算;未知 AUTO 仅尽力编码,不证明上游能力;raw-only 为 NULL | +| 失败尝试 | effective_effort(请求>源>糖);无推理路径 NULL | 是请求意图,不是已发出的/映射后的档,也可能零 HTTP | +| 缓存命中 | **本次请求级** reasoning_effort | 不取历史 response.applied_effort,不推源级档;thinking_observation 回放历史,不是本次实测 | +| scope 终态失败 | **本次请求级** reasoning_effort | 可能未选源;不推源级档或实际档,不凭本批补充逐次根因 | + +实际档分析仅使用真实成功尝试(排除 cache_hit、失败/终态行),还须保留未知/raw-only 的语义限制。#26 的 NULL 与 chat 阳性不能误套到缓存/终态来源上。 + +| 维度 | 约束 | +| --- | --- | +| 并发/幂等 | 能力表不可变、注册返回新表;解析局部结果,不用全局最后档;同声明同意图同结果;报告按运行/逻辑调用/attempt 隔离 | +| 取消 | 同步守卫不捕 BaseException;CancelledError 穿透,既有 in-flight finally 释放;不重构真实调用/遥测取消时序 | +| 降级 | 缓存/遥测故障 warning 降级,限流/熔断后端不可用仍报错;配置守卫与运行期四分类见 §4.4 | +| 持久化/原子性 | 无 schema/DDL;SQLite 临时文件,旧缓存不改写,报告安全写且失败显式失败;无任务恢复子系统 | +| 告警/评估 | 未登记与对账 warning 保持实例节流;错误含 model、请求档、支持集合与可执行配置例,不泄露凭据;离线矩阵和变异须全过,live 覆盖基线待实际运行 | + +## 9. 实施接缝与文档同步 + +| 接缝 | 预期改动 | +| --- | --- | +| thinking/providers | AUTO 成员约束、nearest 边界、文案、空 wire 语义、D2 窄纯校验;已有能力证据保留出处,不无证据追加 | +| client/默认 transport | 工厂及请求前置可执行的守卫、最终双 raw 守卫与错误翻译;不改端口、不移动层序、不引入全源请求准入解析 | +| cache | 本批不加语义 revision 或自动校验;只交付显式迁移回归与边界说明 | +| 单元/轻集成/e2e | 真实调用链+SQLite、分类输入与反例、逐轮完整性、隔离变异;M3 True 改为“本地拒绝”和“medium 真实开启”两个命题 | +| 文档 | 实施时同步 ARCH D11/§5.1/7.5/7.8、README、CHANGELOG、.env.example、源码 docstring;旧设计标注被替代段,不追改历史实验事实 | + +Wiki 已下线,按 docs-convention 落到上述文件,不虚报 wiki 多页;schema/端口未变不 bump 字段数。本次仅写本设计,其他同步留正式批准后的计划。 + +## 10. 验收矩阵与审批门 + +下列均为**待实施验收**,不是本轮通过记录。凡缓存不得绕过/救活新拒绝的断言,统一以**已完成 §5.1 显式迁移前置**为条件。 + +| 层 | 必需验收 | +| --- | --- | +| 解析离线 | None/NONE/AUTO/强度、已登记含/不含 AUTO、未知空/非空/未知 wire、nearest 两方向、默认与自定义注册表;AUTO 拒绝不提示 nearest | +| raw 冲突 | 源/请求双入口,同值、被遮蔽、嵌套根替换、新增控制键、自定义 effort_key/off 根、非法 on_base;无受管意图保留 raw,普通采样不误拒 | +| 守卫时机 | 工厂失败零准入/零 HTTP;前置可判请求冲突零准入;默认 transport 拒绝允许既有准入但零 HTTP、正确结算、无重试换源;全量注入同测 | +| 缓存迁移 | 旧客户端写旧身份→新客户端使用全新身份 miss 并执行新拒绝;nearest 写→error 读、能力表/wire 变化均换身份;工厂默认、per-call 覆盖、全量注入、多源集合、并行旧新客户端覆盖 | +| 缓存已知边界 | 另测未迁移的共享身份可能命中并绕过 transport;将其明确记录为操作风险,不把迁移前未拒绝伪装为迁移后安全已验证;无强制全部 chat 冷启动 | +| 归因/防假绿 | 404 精确 type/仅 message/截断/重复键;400/429/503/SSE/no_sources 默认 FAIL;故意改错 model、Authorization、端点、SSE 解析必须红;第二轮失败保留第一轮证据;取消穿透 | +| 遥测 | 四种行来源逐项断言;三个无推理入口真实调用链/SQLite NULL 与 chat 阳性;按 parent/session 归组且 attempt UUID 唯一;四类变异被目标断言杀死 | +| 真实核心 | M3 AUTO/True 拒绝零网络、M3 medium 流/非流、M2.5/M2.7 空 wire AUTO、qwen AUTO;缺观测保留未覆盖,不靠旧缓存 | +| 真实扩展 | 逐型号/模式列出默认基线、开启/关闭;身份缺失、UNKNOWN、不可达不算关闭 PASS,不用同系替代;新增 AUTO 如另获批准,逐型单列最终 wire/身份/信号 | +| 迁移/全局门 | 三项目实际配置另取证;conda 静态检查、日常套件、独立 verifier;发布按 CLAUDE 显式跑 slow 与下游视角包验证,不能以本设计代替运行结果 | + +付费 live 必须先通过离线构造契约,再经人类批准模型/轮次/并发预算;生产超时不为赶结果压小。本文件不授权任何新真实付费调用。 + +## 11. 审查历史、修订对应与当前状态 + +前稿记录 Codex 4 项 Important;本轮按下表修订,**修订不等于独立复审通过**。上一轮 Claude 输出不对应本仓库,整份无效且不作为任何技术结论/通过证据引用。 + +| Codex 项目 | 本轮修订与复审锚点 | +| --- | --- | +| 缓存迁移边界不足 | §5.1/5.2 M9、§10:D3 显式迁移,不加 revision;同版本 fallback/能力/wire 变化、全量注入和 per-call 覆盖均纳入;未迁移仍可能绕过 | +| 遥测“成功=实际档”过宽 | §8 四种行逐项来源,真实成功尝试才读 applied_effort;缓存与终态仍读请求级,不扩大生产遥测 | +| UNKNOWN 被计关闭覆盖 PASS | §6.3/§10:UNKNOWN 不证实关闭,未覆盖不占 PASS;长度差不作通用关闭证明,运行时 UNKNOWN 语义不变 | +| 缺下游迁移矩阵 | §5.2 M1–M9 与具体键/调用实例;受影响型号、错误时机、人工替代、离线锚点齐全;三项目现行配置明确未核验 | + +已决 D1–D3 的旧“未决推荐”段已移除。2026-09-09 第二轮 Codex 审查对修订版无 Critical/Important/Minor;native reviewer 另发现两个测试归因 Important:公共响应身份不足以归因上游,以及 NONE 关闭成功与不可关闭负向研究混同。父会话已在 §6.2/6.3 作最小修订(证据不足 FAIL、不新增成功 SSE 捕获器、按测试命题判定),native reviewer 定向复审通过(run `2f92c90a-298d-459d-8fcb-087dea475770`),无新增 Critical/Important/Minor。 +剩余证据门为 M2 空 wire 实测、其他 live 缺测及三项目实际迁移,不重新悬置已决 D1–D3。2026-09-09 用户已明确选择“批准并继续”,正式批准本文件;独立审查及 native reviewer 定向复审均已通过。 +**当前结论:设计已批准,进入实施计划;计划独立审查通过后直接执行。** 设计批准不等于变异、真实矩阵或下游迁移已通过;尚未取得的证据仍按 §10 门控。 diff --git a/research-wiki/plans/2026-09-09-134-thinking-contracts.md b/research-wiki/plans/2026-09-09-134-thinking-contracts.md new file mode 100644 index 0000000..772842f --- /dev/null +++ b/research-wiki/plans/2026-09-09-134-thinking-contracts.md @@ -0,0 +1,370 @@ +# 1.3.4 推理契约与测试证据实施计划 + +> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),进入执行;尚未编码/执行验收**。 +> 设计:`research-wiki/designs/2026-09-09-134-thinking-contracts-design.md`,用户已正式批准。 +> 目标:解决 #21 的受管推理语义漏洞、#25 的测试归因漏洞、#26 的客户端遥测守卫缺口,不扩展生产端口或遥测 schema。 +> 方案:在既有推理决策层添加窄校验并接入工厂/默认 transport;测试侧独立保留请求与响应证据,按明确命题判定覆盖。缓存仍由下游显式迁移,生产治理循环不重写。 +> 技术:Python 3.12+、asyncio、httpx hooks/MockTransport、frozen dataclass、pytest、临时 SQLite、ruff、import-linter;不新增依赖。 + +本计划不涉及参考实现迁移,保真校验不适用;不得变更 Redis Lua、429/stall、取消结算、结构化重试或 #19/#23/#24 的生产机制。 + +## 1. 基线、授权与执行纪律 + +| 项目 | 固定边界 | +| --- | --- | +| 分支/历史 | `feature/1.3.4-thinking-contracts`;保留已有 `758a127`/`6a09054`,不重写 main 历史;开始时记录实际 HEAD 与 origin/main | +| D1 | 已登记 AUTO 必须为清单成员;未知 AUTO 保留尽力+warning,空 wire 可能不发送推理字节,不保证开启 | +| D2 | 受管意图非 None 时,两层 raw 推理控制同值/被遮蔽也拒绝;raw-only 保留,不反向推断实际档 | +| D3 | 不添加 fallback 指纹、语义 revision、能力表版本或新缓存前置解析;显式更换 namespace/salt 是操作前置,未迁移可能回放旧语义 | +| 实施权限 | 一工作区仅一 writer;父会话负责前台委派与审核。用户已授权门通过后自行合并 main、测试并发布 1.3.X,无需逐步请示;1.4、新公共决策、验证豁免须停下确认 | +| 证据与秘密 | 不打印 `.env`、token、Authorization、私有提示词;不提交 `.pi/`、运行报告或 reference;命令输出只记录安全路径、状态、退出码 | + +T0 开始调用 `writing-plans`;T1–T7 行为测试执行 `test-driven-development` 并阅读其 testing-anti-patterns;T1/T5 落日志前执行 `structured-logging`。每次提交执行 `commit` skill(英文祈使标题、无 AI 签名、显式路径暂存),T8 前执行 `requesting-code-review`/`verification-before-completion`,收到意见执行 `receiving-code-review`;异常先用 `systematic-debugging` 定根因。 + +用户自主发布授权涵盖既有发布清单的真实 slow 套件,沿既有配置/轮次/并发执行,不重复索取这一授权。超出既有测试矩阵的新研究实验先提交型号、轮次、并发和费用预算;禁止借研究名义追加裸 HTTP 对照。设计要求的新增覆盖先用现有矩阵表达,无法表达且增加调用量时升级该预算决策。 + +## 2. 文件职责与不变接缝 + +| 创建/修改 | 精确路径 | 职责 | +| --- | --- | --- | +| 修改 | `src/polygateway/thinking.py` | AUTO 成员检查、nearest 边界、wire 与 raw 冲突纯校验、错误及未知告警文案 | +| 修改 | `src/polygateway/providers.py` | MiniMax on_base 改空;修正空 wire docstring,不在声明层引入决策依赖 | +| 修改 | `src/polygateway/client.py` | `_guard_thinking` 校验源 raw;`chat` 请求显式档+已知 raw 冲突前置校验;指纹不改 | +| 修改 | `src/polygateway/transports/openai_compat.py` | `_build_payload` 完整守卫,两层浅覆盖次序不改,沿 complete 的异常翻译 | +| 修改 | `tests/unit/test_thinking.py`、`tests/unit/test_providers.py` | 纯解析、声明、已知/未知/自定义 wire、告警与边界 | +| 修改 | `tests/unit/test_client.py`、`tests/unit/test_config.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py` | 工厂、请求前置、真实 transport、无 HTTP 拒绝与治理收尾;离线兼容 | +| 修改 | `tests/unit/test_cache.py` | 显式迁移和未迁移风险回归;保留旧键黄金值 | +| 修改 | `tests/unit/test_embedding.py`、`tests/unit/test_ocr_client.py`、`tests/unit/test_telemetry.py`、`tests/unit/test_monkey_ocr.py` | 三入口 NULL、阳性、SQLite 与 wire;不改变生产 emitter/client 循环 | +| 新建 | `tests/live_evidence.py` | 测试专用 frozen 证据、有限归因、身份与覆盖判据、逐轮安全报告;无环境自读取 | +| 新建 | `tests/e2e/conftest.py` | 测试侧 hooks、任务局部关联、薄 transport 委托与配置装配;不复制生产 payload/重试算法 | +| 新建 | `tests/unit/test_live_evidence.py` | 分类、hooks/委托器和报告离线反例;导入新 conftest 中无副作用定义,不导入读 .env 的 live 模块 | +| 修改 | `tests/e2e/test_smoke_gateway.py`、`tests/e2e/test_compat_projects.py`、`tests/e2e/test_embed_probe.py`、`tests/e2e/test_thinking_live.py` | 迁入窄证据通道、逐轮完整性与命题分流,保留必须真实执行的行为断言 | +| 修改 | `README.md`、`CHANGELOG.md`、`.env.example`、`research-wiki/ARCHITECTURE.md`、`research-wiki/designs/2026-09-04-reasoning-effort-design.md` | 用户可达迁移说明、架构同步、旧设计被替代指针;不追改历史实验事实 | +| 修改/登记 | `research-wiki/schemas/llm-calls.md`、`research-wiki/metrics/call-telemetry-coverage.md`、`research-wiki/graph/edges.json`、`research-wiki/index.md`、`research-wiki/log.md` | 复用既有实体,登记本计划与四种遥测口径;只接受工具对相关实体的必要索引更新 | +| 新建(验收时) | `research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md` | 红绿、变异、失败与豁免索引,≤300 行;原始输出留 `tests/outputs/134/` | +| 修改(发布时) | `pyproject.toml`、`src/polygateway/__init__.py` | 两处版本一致到 1.3.4,不改变依赖或导出面 | + +生产不修改 `ports.py`、`types.py`、`errors.py`、cache/telemetry 实现及 embedding/OCR 循环;若实际实现需要突破该清单,先说明设计要求与最小原因,由父会话核定,不顺手改动。 + +## 3. 跨任务接口(内部实现约定,不新增公共导出) + +### 3.1 推理守卫 + +新函数置于 `thinking.py`,其余模块显式 import;保持决策方向 `client/transport → thinking → providers/types`。`Mapping`、`Any`、`Effort`、`ThinkingWire` 均为既有类型。函数体由 T2 实现,以下固定消费者签名: + +```python +def validate_thinking_wire(wire: ThinkingWire, *, model: str) -> None: + """拒绝 on_base 偷带已知强度,抛 ThinkingUnsupportedError。""" +def validate_thinking_raw( + raw: Mapping[str, Any], *, effort: Effort | None, + wire: ThinkingWire | None, origin: str, +) -> None: + """effort 表态时拒绝 raw 控制;wire=None 只检查标准词表。""" +``` + +签名中的 wire 必填但可 None,None 是 chat 前置看不到实际 profile 的事实,不是容错默认;origin 只取固定位置名/源名,不含 raw 值。两函数无 I/O,不改变输入,抛现有 `ThinkingUnsupportedError`(ValueError 子类),不新建错误类。`validate_thinking_wire` 在 `resolve_thinking` 的 None 早退之前验证声明结构;不会要求无意图时 wire 必须已知,只拒绝结构上偷带强度。 + +标准 raw 根:`reasoning_effort`、`enable_thinking`、`thinking`、`thinking_budget`、`reasoning`、`thinkingConfig`;`output_config` 为 Mapping 且有 `effort` 时冲突。wire 可见时并入 on_base/off 全部顶层根与 effort_key,点号只是字面键。 + +wire 校验只拒绝 on_base 中自己的非 None effort_key、标准 reasoning_effort、标准嵌套 output_config.effort(含值为 auto/None);不解析任意私有方言。未知/非当前方向的形态检查继续由现有 `_wire_unknown_for` 负责,不改变 off-only 可用性。 + +### 3.2 测试证据与归因 + +`tests/live_evidence.py` 不 import e2e conftest、不读环境、不发网络。上下文中的密钥只做内存比较,不进下列值类型。一个 attempt 可以记录零/一/多 HTTP 事件;不能用 len(attempts) 冒充 HTTP 数。 + +```python +@dataclass(frozen=True) +class HttpEvidence: + call_id: str + request_checks: tuple[tuple[str, bool], ...] + status_code: int + error_body: bytes | None + raw_identity: tuple[bool, str | None] +``` + +`raw_identity` 是 T5 生产、T6 消费的**成功非流式原始身份快照**:(False, None) 表示未取证,(True, None) 表示已独立解析完整 JSON 对象且 model 缺失/为 null,(True, str) 表示原始字符串。由 response hook 保存的响应引用在该次 complete 结束后读取已缓冲 content,独立 JSON 解码(拒绝重复键)取得;不得从 TransportResult/LLMResponse.model_reported 回填。未缓冲、无可配对响应、非成功非流式、JSON 非对象/非法或 model 非字符串非 null 均不生成肯定证据,并保存原因供 FAIL;不把解析异常解释成身份缺失。成功 SSE 始终 (False, None),不新增捕获器、不预读流。 + +`request_checks` 必须完整包含 method/origin/path/model/stream(embedding 为 input_shape)/authorization/control/messages_digest,缺项不算全过;error_body 只允许 ≤65536 字节已缓冲完整错误体,超限或未缓冲为 None,并在安全报告写证据不足,不把截断文本拿来解析。记录只存在测试内存,不能直接 asdict 后落盘。 + +```python +@dataclass(frozen=True) +class AttemptEvidence: + call_id: str + http: tuple[HttpEvidence, ...] + error: Exception | None +``` + +```python +@dataclass(frozen=True) +class LiveVerdict: + status: Literal["PASS", "FAIL", "UNCOVERED"] + reason: str +``` + +消费者固定为 T5→T6,纯函数与报告出口如下;参数所用 Path、Mapping、Sequence、ThinkingObservation、Effort 为标准库/现有领域类型: + +```python +def classify_live_failure( + error: Exception, attempts: Sequence[AttemptEvidence], +) -> LiveVerdict: + """异常分类仅 FAIL/UNCOVERED;取消不交给此函数。""" +def write_live_round( + output_dir: Path, *, run_id: str, matrix_id: str, round_index: int, + safe_fields: Mapping[str, Any], +) -> Path: + """只接受已脱敏报告字段,唯一文件写失败必须冒泡。""" +``` + +身份与命题判定继续放 `tests/live_evidence.py`,避免 e2e 中四份条件分支: + +```python +def assess_model_identity( + *, requested: str, aliases: frozenset[str], reported: str | None, + raw_identity: tuple[bool, str | None], request_valid: bool, +) -> LiveVerdict: + """raw_identity[0] 表示有独立原始身份取证;无证据不得归因上游。""" +def assess_thinking_coverage( + observations: Sequence[ThinkingObservation], *, planned_rounds: int, + proposition: Literal["enabled", "disabled", "cannot_disable"], +) -> LiveVerdict: + """输入须先过请求/身份资格;缺轮与 UNKNOWN 不补足证明。""" +``` + +拒绝能力测试不送入以上观测函数,按预声明异常类型/状态/机器字段独立断言。`cannot_disable` 保留 T10“实际未关闭与声明比较”的反证形态:完整合格轮次有 OBSERVED 可支持本条件下不可关闭;全 ABSENT 证伪声明;无 OBSERVED 但有 UNKNOWN 只能未覆盖。不得扩大成“证明所有私有上游参数都无法关闭”。 + +### 3.3 测试侧取证装配 + +新 `tests/e2e/conftest.py` 内 `ObservedTransport` 包裹**同一个**真实 `OpenAICompatTransport`,complete/embed 签名逐字保持 `ports.py`,参数原样传递。每次调用将 call_id 绑定实例持有的 ContextVar,finally reset;异常原样上抛,CancelledError 不转普通错误。不得在委托器做治理重试或 payload 修正。 + +```python +class LiveCapture: + def __init__(self, *, expectations: Mapping[str, Mapping[str, Any]]) -> None: + """按源名持有测试矩阵显式预期,不含凭据或真实响应。""" + def round_context(self, *, session_id: str, parent_call_id: str) -> AbstractContextManager[None]: + """外围绑定逻辑轮次,finally 复位,嵌套任务不串线。""" + def client_factory(self, source: SourceConfig) -> httpx.AsyncClient: + """按实际源构造带 hooks 客户端,沿生产 timeout/trust_env。""" + def attempts(self, *, session_id: str, parent_call_id: str) -> tuple[AttemptEvidence, ...]: + """返回本轮快照,含零 HTTP 的尝试,不从最终异常猜前序。""" + def raw_identity(self, *, session_id: str, parent_call_id: str, call_id: str) -> tuple[bool, str | None]: + """精确读取本逻辑轮次和成功 attempt 唯一 HTTP 事件的原始身份快照。""" +``` + +`LiveCapture.expectations` 由调用者按源名提供,内层必需键为 model、origin、path、stream(embedding 用 input_shape)、control、messages_digest;缺键直接测试配置错误,不自动从待测 payload 补齐。结构化预期片段放 control 的显式预期对象,输出路径由 write_live_round 单独接收;并发异构轮次使用各自 capture 实例,不共享可变“当前预期”。预期不调用生产 `_build_payload` 生成。实际请求 hooks 逐项比较,凭据不进 repr/序列化;可保留失败事实后由轮次出口 FAIL,不能改写实发请求“修正”它。 + +取证 live 使用 `GatewayClient(...)` 全量注入。conftest 可复用 `client.py` 现有 `_build_limiter`/`_build_breaker`/`_build_selector`/`_build_structured` 等装配函数,但不新增生产注入口、不复制它们实现;自己创建的组件用 ExitStack/显式 finally 关闭,注入 GatewayClient 不会代关。工厂行为单独离线验证。未获得取证通道的工厂 live,异常一律保存后 FAIL。 + +T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidence` 保存快照;T6 使用最终 `LLMResponse.call_id` 调用 `capture.raw_identity(...)`,将返回值交给 `assess_model_identity(raw_identity=...)`,不可取本轮最后一条响应猜关联。查找必须精确匹配本轮且只有一条成功 HTTP 事件;重复/跨轮 call_id、多个候选响应是取证契约错误,报告后 FAIL,不降成外因未覆盖。未发 HTTP 或没有独立身份快照时返回 (False, None)。 +成功非流式原始 model 通过上述快照接口交付;成功 SSE 不加捕获器、不预读流。原始身份无从确认而公共结果缺失/不符时 FAIL;原始正确而结果丢失/改错也 FAIL。只要公共身份合格且请求合格,正常能力测试不要求新增成功 SSE 的原始副本。 + +## 4. 任务与提交点 + +### T0:基线、计划审查与文档回滚点 + +- [ ] 修改设计批准状态,新增本计划;父会话自审后前台 Codex 独立审,具体问题修正后方可执行 T1。计划无需再走人类门,不能把“已生成”当“已审”。 +- [ ] 记录 `git status --short --branch`、`git log --oneline origin/main..HEAD`、实际 HEAD;确认源代码零差异,保存未跟踪文件清单,禁止暂存 `.pi/`。 +- [ ] 执行基线:`make check`;`conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_cache.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py -q`。记录实际失败,不能先改期待绕过;本步骤预期现有非 slow 测试通过。 +- [ ] 以 `research-wiki` 工具登记 design/plan 节点及 implements 边,现有同路径文档不可被 add_entity 模板覆盖;先读工具已有文件处理行为,再登记、重建索引、检查生成 diff。只在本计划 writer 移交后由父会话执行这些额外文件写入。 +- [ ] 调用 commit skill,提交点 `docs: record approved thinking contracts and implementation plan`,形成生产修改前回滚点。 + +### T1:AUTO 成员语义与默认 MiniMax wire + +**文件**:`thinking.py`、`providers.py`;`tests/unit/test_thinking.py`、`tests/unit/test_providers.py`,路径均按 §2。 + +1. 先添加/替换 `test_auto_never_trips_phase5`,以已登记不含 AUTO 的空/非空 on_base 为反例;调用 resolve_thinking 的 error/nearest 都明确拒绝且不提示 nearest。先跑新测试,旧实现因未拒绝而红;再修改 `_settle_tier`、`_tier_unsupported`,避免 AUTO 进入 `EFFORT_ORDER.index`。 +2. 保留强度→纯开关 AUTO、等距弱侧、NONE 不自动映射、None 不表态、未知空/非空 wire 尽力警告。删除 default MiniMax 的 medium 并修正注释;先测试 M3 AUTO 拒绝、M3 medium payload、M2.5/M2.7 AUTO 空 payload 与 applied=AUTO。默认能力表成员不增删。 +3. 用 loguru sink 检查未知告警确实说明不保证生效,default transport 实例节流不改;不引入新日志通道,不输出 raw 密钥。按 structured-logging 明确这是既有 warning 与既有列的修正。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py -q`;新拒绝和 wire 回归先红后绿,其余保留行为通过。若旧下游形态测试依赖 M3 True,需要在 T3 明确改为已批准迁移样本,不能暗改能力表让它绿。 + +- [ ] 提交点:`fix: enforce registered auto reasoning capabilities`。 + +### T2:纯 wire/raw 所有权校验 + +**文件**:`src/polygateway/thinking.py`、`tests/unit/test_thinking.py`。实现 §3.1 两个签名;`resolve_thinking` 集中校验 wire,providers 不反向 import thinking。 + +| 红绿组 | 最小反例/保留不变量 | +| --- | --- | +| 标准控制 | 六个顶层根+output_config.effort;AUTO 空片段仍拒绝 raw high;NONE/糖/未知同测 | +| 双来源 | 同值仍拒绝;分别传源与请求 raw 验证,被后层遮蔽也拒绝;不修改两个 Mapping | +| 自定义 | on_base/off 根并集、effort_key 自定义字面键、点号不解释路径;on_base 偷带自己的键或标准强度值(含 None)拒绝 | +| 嵌套 | 替换 thinking 整个根即拒绝;profile 不拥有 output_config 时仅 format 可过、effort 不可过;拥有根时 format 也不可覆写 | +| 不误伤 | effort=None 时 raw 原样允许;temperature/seed/response_format 不属词表,合法普通采样保持;off-only 形态及当前方向未知语义保持 | + +新校验首次未实现导致的 import 错误不算行为红;可先在隔离基线把同输入经现有 payload 路径表现记录为“覆盖成功但本应拒绝”,或待 T3 在旧实现回放其失败断言,补齐语义红证据。纯函数自身还需逐例断言异常及未修改输入。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_thinking.py -q`,每类目标反例有有效红绿,保留行为绿。 + +- [ ] 提交点:`fix: validate ownership of managed reasoning parameters`。 + +### T3:接入工厂、请求入口和默认 transport + +**文件**:`src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`;`tests/unit/test_client.py`、`test_config.py`、`test_openai_compat.py`、`test_retry.py`。 + +先在旧路径跑源 HIGH+相同 raw HIGH、本次 AUTO+raw HIGH 的行为反例,确认旧实现实际发出 raw 参数而未拒绝。再接入两守卫:工厂先求 effective_effort 并校验 source.extra_body;chat coerce 后调用 wire=None 的已知词表检查;transport 对当前 profile 和 effective_effort 分别检查 extra_body/overlay,再保持原浅 update 顺序。 + +| 接缝 | 验收 | +| --- | --- | +| 工厂 | SourceConfig 仍能表达 raw-only;from_env/from_settings 对受管源拒绝发生在 limiter/HTTP client 创建之前;记录构建计数零,不以网络偶然没发代替 | +| 请求前置 | 显式请求档与 overlay 已知键冲突,ValueError 且 handler/准入未触发;源级意图或自定义根留 transport 再查 | +| 全量注入 | 真实 OpenAICompatTransport 翻译为 RequestRejectedError;MockTransport 记录零 HTTP,RetryMW 不换源不重试、limiter inflight=0、已有探针收尾路径正常 | +| 参数保真 | raw-only 允许,applied=None;普通采样源<请求<结构化 overlay 的现状保留;显式档/nearest 成功 payload、TransportResult/LLMResponse applied 与真实成功遥测相符 | +| 多源/并发 | 每次以选中源 profile 校验,不因另一个源清单不同提前判整个池死;共享 client 无“最后档”串线;错误不包含 raw 值 | + +工厂将来被请求覆盖不能救活一个已拒绝源,这是已批行为。不要为全量注入自定义 transport 添加 preflight 端口。已有 fixture 需要调整时,只将不再合法的受管+raw 双来源改为显式单来源,新增拒绝反例保留迁移证明。 + +**新增生产默认 HTTP factory 离线守卫**:现有 `tests/unit/test_openai_compat.py` 没有 auth/timeout/trust_env 构造断言,不能写作“保留”。新增 `TestDefaultClientFactory`,直接调用生产 `_default_client_factory(source)` 返回真实 AsyncClient,不使用 T5 的测试 factory,也不 mock 整个 AsyncClient。用两组不同假 api_key、非默认 timeout_s、trust_env=True/False 参数化;不发送网络,finally aclose。节点为 `test_authorization_uses_source_api_key`(检查 client.headers 及 build_request 生成的 Authorization)、`test_timeout_uses_source_timeout_for_all_phases`(connect/read/write/pool 全部等于输入 timeout_s)、`test_trust_env_uses_source_setting`(检查 client.trust_env)。在隔离副本逐个删除 Authorization 传入、遗漏 timeout 参数、遗漏 trust_env 参数/写死 True,指定节点必须因值不符红,再恢复通过;当前实现本来正确,以这些语义变异作为红证据,不改生产 factory 凑红。 +独立命令:`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py::TestDefaultClientFactory -q`,原始实现绿、每个遗漏变异被对应断言杀死、恢复绿;这套测试与 T5 hooks 校验分别验证生产装配和测试取证两条路径。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -q`,加 T1/T2 的测试一起跑;工厂/请求/全量注入拒绝均有旧实现红、新实现绿。 + +- [ ] 提交点:`fix: reject conflicting raw reasoning overrides before sending`。 + +### T4:显式缓存迁移和四种遥测口径回归 + +**文件**:`tests/unit/test_cache.py`、`tests/unit/test_client.py`、`tests/unit/test_telemetry.py`;不改生产 cache、指纹或 emitter。 + +使用真实 InMemoryCache、GatewayClient、默认 transport+MockTransport 构造两个客户端。旧语义 payload 可按 1.3.3 真实序列化形态预写(历史数据夹具,不需要在当前生产放回漏洞);同版本 nearest→error 则运行真实客户端写入。 + +| 场景 | 断言 | +| --- | --- | +| 旧 AUTO/raw 记录 | 旧身份可回放是已知风险;换全新 namespace 或 salt 后 miss,实际进入新拒绝,异常不缓存 | +| nearest→error | 源不表态、请求 medium、模型 glm-5.3;nearest 写入后 error 同身份可命中;error 换身份后零 HTTP 拒绝,不添加 fallback 指纹 | +| 能力表变化 | 新增 `TestExplicitCacheMigration::test_capability_change_requires_explicit_identity`:相同源配置、请求 AUTO 和 wire,两客户端注入同一测试模型的不同能力表(旧含 AUTO+HIGH,新仅 HIGH),源级不表态以允许装配。旧客户端真实写入后新客户端同身份回放且无新 HTTP;换全新 namespace 或 salt(参数化)后 miss,进入新能力表并 RequestRejected、无新 HTTP、不写失败值。只用局部测试能力表,不修改 DEFAULT | +| 自定义 wire 变化 | 新增 `TestExplicitCacheMigration::test_custom_wire_change_requires_explicit_identity`:相同源/模型/能力表及请求 HIGH,分别注入同名自定义 profile 的旧/新 effort_key(例如 depth_a/depth_b),on_base 均为空。旧客户端写缓存,新客户端同身份回放旧值且无新 HTTP;新 namespace 或 salt 后 miss,MockTransport 必须收到 depth_b=high 且无 depth_a,返回可区分的新结果,旧身份仍能回放旧值。profile 仅局部注入,不改源配置让现有指纹意外变化 | +| 入口与范围 | 工厂默认 namespace、per-call 覆盖默认、构造全量注入、共享多源 scope、两个租户原前缀保留;只改默认无法覆盖 per-call,需专门反例 | +| 并行/回滚 | 旧新身份可并行且不覆盖对方;回到旧身份确实重见旧值;未受影响调用 key 黄金值逐字不变 | +| 四行口径 | 真实成功=applied、失败尝试=effective 意图、cache_hit/scope 终态=本次请求级;缓存不读取历史 applied 作本次遥测档 | + +该任务多数是已有正确行为的守卫,不人为改生产获得红:隔离变异遗漏 namespace/salt、将 cache_hit 遥测改读历史 applied,要求相应行为断言红,恢复后绿。T3 新拒绝路径旧实现红绿可复用,但不能只报它替代迁移维度证据。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_client.py tests/unit/test_telemetry.py -q`。两项新增能力/wire 迁移节点均置于 `tests/unit/test_cache.py::TestExplicitCacheMigration`,单跑 `conda run -n PolyGateway pytest tests/unit/test_cache.py::TestExplicitCacheMigration -q`;分别在隔离副本去掉其 namespace/salt 隔离输入,必须因没有新拒绝/新 wire 而红,恢复后绿,不能只以 nearest→error 的测试代替这两类。两客户端的源指纹必须断言相等,生产指纹算法一字不改。 + +- [ ] 提交点:`test: pin explicit cache migration and reasoning row semantics`。 + +### T5:有限测试归因和独立取证 + +**文件**:新增 `tests/live_evidence.py`、`tests/e2e/conftest.py`、`tests/unit/test_live_evidence.py`。按 §3.2/3.3 实现;不读 .env 的模块可被日常单测安全 import。执行 structured-logging:记录内容按设计 §6,不另建库表。 + +1. 纯分类默认 FAIL。仅一条完整 HTTP 错误、请求检查齐全、无别的 attempt 异常、404、完整无重复键 JSON 的 error.type 精确匹配、外抛 RequestRejectedError 且 status 一致,才 UNCOVERED;多次尝试/多 HTTP、不同 call_id、空检查元组、重复 JSON 键均 FAIL。 +2. hooks 不预读成功 SSE,不将 summary 当 JSON;response 引用等该次 transport 完成后检查 content 是否已缓冲。64 KiB 上限、0 字节、非对象 error、重复键、坏编码各有反例。薄委托器 finally 恢复上下文,零 HTTP 尝试也保存。 +3. 身份函数区分 raw 取证缺失和原始响应明确缺 model;增加 `test_raw_identity_snapshot_reaches_round_consumer`,用真实默认 transport+MockTransport 非流式响应依次覆盖正确 model、缺失/null,以及 JSON 非对象/非法/重复键,断言 §3.3 accessor 的来源和区别;故意让公共响应丢 model 时原始快照仍保留正确串并判 FAIL。并发两逻辑轮次+一次重试验证按 session/parent/成功 call_id 精确选择,不回放前次失败的身份;成功 SSE 快照未取证且公共身份异常时必须 FAIL。覆盖函数按 enabled/disabled/cannot_disable 命题判断,UNKNOWN 不假绿;预期 400 负向契约单独测。 +4. 用 MockTransport 驱动 request/response hooks:改错 model、Authorization、端点、SSE/JSON 解析→FAIL;成功 SSE 不被提前消费;原始 model 正确但公共字段错误→FAIL。交错并发及取消证明 context reset、凭据不泄露、资源释放;薄委托器不额外调用一次 HTTP。 +5. 安全报告每轮独立文件,采用 run UUID+轮次与矩阵安全标识;只接受白名单 safe_fields,拒绝原始异常/HttpEvidence 对象直接序列化。第二轮失败仍可读第一轮;写入失败是 FAIL;最终汇总统计 PASS/FAIL/UNCOVERED 和缺轮,不能只数 pytest 退出码。 + +对旧策略红证据:用合成记录隔离执行现有“整类 skip/正文子串/UNKNOWN 安静”判据,目标测试要求 FAIL/UNCOVERED,确认语义不符;恢复新纯函数后通过。新文件缺失造成 import error 不计红。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py -q`。安全测试使用假的唯一 sentinel 凭据/私有提示词,逐文件检查不出现 sentinel,不能拿真实密钥做输出搜索。 + +- [ ] 提交点:`test: distinguish unsupported live coverage from library failures`。 + +### T6:迁移四个 live 文件并离线化装配断言 + +**文件**:四个 `tests/e2e/test_*.py` 路径见 §2,`tests/e2e/conftest.py`、`tests/unit/test_live_evidence.py`、`tests/unit/test_client.py`、`tests/unit/test_config.py`。 + +| 原接缝 | 改动与离线验收 | +| --- | --- | +| smoke/compat chat | 每轮 session_id+parent_call_id,委托原参数,先报告再 skip/raise;仍验证流/非流、结构化 JSON/模型。断言异常也必须留报告,不只包 await 的异常 | +| compat 平铺键 | 移到 test_config.py 的完整合成 env,删除逐源 TIMEOUT_S 才能验证 LLM_TIMEOUT 回落;不靠真实配置“恰好已有覆盖”过测。无 HTTP、无可达 Redis/PG,明确其仅是本库兼容契约 | +| compat Protocol | 既有真实外部 Protocol 缺包时记录未覆盖;合成 runtime Protocol 和本库调用签名在 test_client.py 无 slow 执行,不能宣称缺失仓库原测试通过 | +| embedding probe | 走同一薄 embed 委托和窄分类,所有路径 finally 关闭;model_not_found 不写成“网关不支持 embeddings”;timeout/trust_env 取已校验源配置,不用 30s 硬编码压紧生产预算 | +| L1–L9 | M3 True 拒绝单独离线/本地断言,真实开启用显式 medium,保留未登记/未知 wire 场景;L8 装配拒绝只计本地契约,不算 live 能力 | +| T10 | 预声明 NONE 可关闭/不可关闭/档位预期拒绝;每轮结束即留证。去掉“仅保留可用轮降低分母”、completion 长短提升 UNKNOWN 的成功逻辑;模型部分档未覆盖不可汇总全 PASS | + +保持原 `_MODEL_PROVIDER`/显式别名表,不新增 AUTO 成员。`_run_rounds`、`_probe_effort` 返回路径不许遗漏失败轮;默认基线也走同一证据出口。T10 临时能力表仅为探测绕过清单,不写回 DEFAULT;其控制字段预期由矩阵声明,不能调用被测 resolver 产生预期。 + +既有 `_tier_settings` 将 stall 强制压到 60s,迁移时去掉该临时缩小值,沿已校验生产配置;不得因持续 429 慢而修改 #22 算法。保留既有轮次/并发设置;先收集矩阵和预计调用数,额外研究不自动展开。M2.5/M2.7 AUTO 复用 T10 现有登记档,M3 medium 流/非流复用对应原开启用例,不以新增多轮研究暗增预算。 + +测试工厂替代仅改变取证装配,不能靠调用生产私有 `_client_factory` 的同一实现来证明鉴权构造正确;生产默认 factory 的头/timeout/trust_env 离线守卫由 T3 **新增** `TestDefaultClientFactory`,T6 验证时一并运行,不宣称旧源码已有覆盖。各能力轮次用 §3.3 的 `raw_identity(session_id=..., parent_call_id=..., call_id=resp.call_id)` 给身份函数提供独立快照,缺失来源不得猜测。live 无取证通道的失败按 FAIL,不为凑分类额外开生产接口。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py::TestDefaultClientFactory -q`;`conda run -n PolyGateway pytest tests/e2e/ -m slow --collect-only -q`(只采集,不视作真实通过)。在离线注入旧整类 skip、丢第一轮、UNKNOWN→PASS、identity 丢失→skip 变异,分别红;新实现恢复绿。 + +- [ ] 提交点:`test: apply evidence-based live checks without hiding regressions`。 + +### T7:无推理路径真链路与四类变异 + +**文件**:`tests/unit/test_embedding.py`、`tests/unit/test_ocr_client.py`、`tests/unit/test_telemetry.py`、`tests/unit/test_monkey_ocr.py`;生产不改。 + +复用 `_embed_client`/`_client`/脚本 transport/内存 recorder,参数化 embed、recognize_text、parse_layout 与源 True/HIGH,两次尝试(Transient→成功)必须恰有 2 行、一错一成、所有 reasoning_effort None。再覆盖 RequestRejected 一行和耗尽非零失败行;不要求新增不存在的逻辑终态。SQL 锚点用真实 `SQLiteRecorder(tmp_path / "reasonless.sqlite", auto_migrate=True)`,三入口分别走 client→emitter→SQLite,查询总数/失败数/NULL 数,finally 同步 close 注入 recorder。 + +chat 阳性走真实 RetryMW+emitter:True 糖失败 auto、显式请求失败保留意图、nearest 成功为实际映射档。四行遥测继续沿 T4 口径。共享 recorder 并发用测试 session/parent 配对,attempt call_id 唯一且集合不相交。embedding 默认 transport 和 MonkeyOCR text/layout 真实 MockTransport 回包验证 wire 无推理键,不仅断言 emitter 的 False 实参。 + +| 隔离变异 | 必须被哪些断言杀死 | +| --- | --- | +| `embedding.py::_emit` False→True | 误配 True/HIGH 的失败尝试 NULL 断言 | +| `ocr.py::_emit` False→True | text 和 layout 各一个独立节点均因错误行非 NULL 红 | +| `middleware/retry.py::_emit` True→False | chat 阳性实际档/请求档断言,不是签名 TypeError | +| `middleware/telemetry.py::_attempt_effort` 去掉 applies 短路 | 无推理真实失败行断言;确认不是全空数据或假 recorder | + +工作方法:以 T7 当前提交建仓库外临时副本(仅 src/tests/必要工程文件,不复制 `.env`/reference/.pi),用 `PYTHONPATH=<副本>/src` 和副本 cwd 执行 conda pytest;先检查 `polygateway.__file__` 指向副本。逐个变异、跑指定节点记录 exit 1 和目标断言、恢复文件校验散列,再跑 exit 0。绝不在主工作区改 False 假装先红。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py tests/unit/test_monkey_ocr.py tests/unit/test_retry.py -q`,再执行上表隔离变异;原实现绿、四类有效红、还原绿。 + +- [ ] 提交点:`test: guard reasoning-free telemetry through real client paths`。 + +### T8:文档、日志登记与独立验证 + +**文件**:§2 列出的用户文档/架构/旧设计/schema/metric/知识索引,以及验收 finding;不新增运行时字段。 + +同步设计 M1–M9 到 README 可执行迁移节与 `.env.example` 注释,保留型号证据来源;CHANGELOG 未发布段点名 AUTO 新拒绝、未知尽力、raw 同值拒绝、显式缓存身份迁移、UNKNOWN/SKIP 限制。schema 既有 reasoning_effort “实际发出”总括修成四种行来源,不修改 DDL;metric 复用既有 call-telemetry-coverage,记录三个无推理入口错误行 NULL/chat 阳性为 100% 契约,真实覆盖基线留待首次实际运行,不能填伪百分比。 + +由父会话前台派全新 verifier:只给批准设计、计划、分支 diff、验证命令,不给实现自评。至少覆盖正确性/回归和测试归因/范围两个角度;Critical/Important 清零。审查先核对实际路径与仓库语言,不接受不存在文件的结果。补丁回到单 writer,重跑受影响红绿及静态门。 + +| 检查 | 命令/证据要求 | +| --- | --- | +| 静态与边界 | `make check`;`git diff --check`;`conda run -n PolyGateway python -m compileall -q src/polygateway tests/live_evidence.py tests/e2e/conftest.py` | +| 日常全量 | `make test`,保存真实退出码/coverage ≥80%,不能只运行改动文件;连接依赖 skip 单列 | +| LSP | 若会话已有 LSP diagnostics 工具,对四个生产变更文件和新增测试支持文件取诊断;本轮检查 conda 内 pyright/basedpyright 均未安装且工程无其配置,不安装新依赖或虚报 LSP 通过。可用时命令 `conda run -n PolyGateway pyright src/polygateway/thinking.py src/polygateway/providers.py src/polygateway/client.py src/polygateway/transports/openai_compat.py tests/live_evidence.py tests/e2e/conftest.py`,不可用明确记未执行,ruff/import-linter/compileall 是实际既有静态门,不冒称等价 LSP | +| 真实采集清单 | `conda run -n PolyGateway pytest tests/ -m slow --collect-only -q`,先列必需节点、型号/模式/轮次/并发/所用配置身份(不含秘密) | +| 真实执行 | `conda run --no-capture-output -n PolyGateway pytest tests/ -m slow -ra`;保持生产超时,检查每项报告而非仅 exit 0 | +| 反回归 | 四类 #26 变异+T4 缓存迁移+T5/T6 假绿反例全部有独立失败断言与还原通过,finding 引用原始报告路径 | + +长跑用 tmux,`PYTHONUNBUFFERED=1`,命令 stdout/stderr 重定向到 `tests/outputs/134/`,原命令后立刻独立保存 `$?`;不得接 `tail` 管道改写退出码。父会话等待准确 tmux 完成信号/PID,不能 pgrep 完整命令自匹配。不把初次失败覆盖成重跑后的单一绿日志。 + +- [ ] 提交点:`docs: document reasoning ownership and explicit cache migration`;必要修复各自按 commit skill 提交,不把 verifier 自动反馈当授权扩范围。 + +### T9:发布准备、合并后复验与 1.3.4 发布 + +仅在 T8 无未处理阻塞后执行。用户已授权所有本节动作,无需为 merge/push/上传再请示;发现 1.3.4 已存在不可覆盖,停下协调版本,不能私自跳到 1.4。 + +| 顺序 | 精确动作与完成证据 | +| --- | --- | +| 文档先行 | 更新 README 安装约束与能力说明;CHANGELOG 定版为 1.3.4(实际日期),pyproject 和包 `__version__` 同步;本计划复选框只能按已得证据勾选 | +| 发版提交 | 执行 commit skill,标题 `chore: prepare release 1.3.4`,先核对测试报告和 staged 无秘密;运行 `conda run -n PolyGateway pytest tests/unit/test_package.py -q`,不改变公共字段计数 | +| 合并 | `git fetch origin`,确认远端未出现未审变更;`git switch main`,`git merge --no-ff feature/1.3.4-thinking-contracts`。保留已有两个本地提交,禁止 reset/force push | +| 合并后门 | main 上重新 `make lint`、`make test`、`conda run --no-capture-output -n PolyGateway pytest tests/ -m slow -ra`;若 lint --fix 改代码,重新审 diff、提交并重跑,不把脏代码与 tag 分离 | +| 推送与 tag | `git push origin main`;`git tag -a v1.3.4 -m "Release 1.3.4"`;`git push origin v1.3.4`,核对远端 tag 指向最终已验证提交 | +| 构建 | 核实 cwd 后按 CLAUDE 清除旧 dist 产物;`conda run -n PolyGateway python -m build`;`conda run -n PolyGateway python -m twine check dist/*`;缺构建工具先报告环境缺项,不更改核心依赖 | +| 上传 | 从既有 tea 配置安全取 token,仅放 TWINE_PASSWORD 环境变量;`conda run -n PolyGateway python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*`,TWINE_USERNAME 沿已有账号;不在 argv/日志输出 token,不把命令成功当最终发布完成 | +| 下载检查 | `conda run -n PolyGateway pip download --no-deps --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ polygateway==1.3.4 -d <临时目录>`;解包核对新守卫与 MiniMax wire、版本、README 元数据;从仓库外使用该环境 Python 将 wheel 安装到独立 target 并验证 import 来源及拒绝行为 | +| 外部可见 | 建 Gitea v1.3.4 Release(正文来自定版 CHANGELOG);调用 `POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/PolyGateway`;查看 Release/registry 包页面正文、仓库链接、下载产物,逐项记录 URL 与实际结果 | + +测试不在 wheel 内,下载后以无网络小调用核对已安装 `resolve_thinking` 和冲突守卫;不要从工作树 src import 后宣称发布包通过。只在外部结果确认后评论/关闭 #21/#25/#26,正文引用各自验证与迁移边界,不能称所有渠道故障已自动识别。若上传成功但页面/下载校验失败,记录部分发布状态,不重发同版本不同字节。 + +- [ ] 提交/发布点:main 的发布提交与 `v1.3.4` 对齐;Release 与 registry 外部验证全部成立。 + +## 5. 阻塞矩阵:哪些可以执行,哪些不能冒充通过 + +| 缺口 | 本库可完成 | 不可自行宣称/处置 | +| --- | --- | --- | +| 下游工作区缺失 | 本库完整合成 env、runtime Protocol、M1–M9 与显式缓存迁移回归 | GovDoc/CHS 实际配置未取证、Video-Tree 已退出迁移但历史兼容面仍可测;三者均不能虚构实测。提前向父会话登记缺口,发布前须拿到相关负责人脱敏配置与验证证据,或人类明确豁免缺失项;不阻止独立离线实现继续 | +| M2 空 wire AUTO | 默认 wire 单测、实际既有 T10 型号档位复验 | 真实缺身份/无信号/不可达不能当已验证;需有效重测或人类具名豁免,不补回 medium 或无证据改表 | +| M3 非流式 UNKNOWN | 可验证 payload、响应形态、UNKNOWN 不假绿 | 不把长度差当开启/关闭证明;必需能力单元无法满足时保留未覆盖并走人类决策,不新增临时“通过”阈值 | +| 429/5xx/网络失败 | 完整证据保存,库回归用离线契约定位 | 本批归因默认 FAIL 是设计批准范围,不能为了 #25 关闭率改宽 skip;外部证据由人类决定发布豁免 | +| 研究新增预算 | 原有 slow 套件按已有授权跑,已有配置保持 | 额外模型/轮次/对照实验需预算批准;不得将批准设计偷换成无限研究调用授权 | +| 设计外漏洞 | 独立记录实际文件与反例,父会话核定是否阻塞 | 不顺手实施 #19/#22/#23/#24、新 schema 或新 deadline;无强制单源分支 | + +## 6. 自审与验收映射 + +| 设计节/需求 | 任务 | +| --- | --- | +| §4.1/4.2 AUTO 与 MiniMax、未知尽力 | T1,T3 双入口,T6/T8 真实证据 | +| §4.3/4.4 raw 同值/嵌套/自定义/时机 | T2、T3;无公共端口新增、无深合并 | +| §5 D3 与 M1–M9 | T4、T8;未迁移风险显式保留,绝不补指纹 | +| §6 归因/身份/UNKNOWN/负向命题 | T5、T6;完整请求证据、默认 FAIL、逐轮持久化 | +| §7 无推理路径与变异 | T7;三入口真 client+SQLite,四类隔离变异 | +| §8 四种行口径与日志 | T1/T4/T7/T8;既有 schema/emitter,不新增数据面 | +| §9/10 文档、下游与发布门 | T8/T9及阻塞矩阵;外部结果与测试缺口不冒充通过 | + +自审已核对:生产守卫所有消费者在 §3 定义;新增测试文件有确定路径;conftest 当前不存在故明确新建;默认工厂不支持 transport 注入故使用已批准全量注入而非偷扩 API;缓存不改指纹;无从公共 model_reported 倒推上游身份;所有命令均在 conda 环境;未执行的测试不写为已通过。 +计划审查由父会话组织,完成后直接实施,不新增人类计划审批门。执行中本文件任务勾选与 finding 保持实际状态一致;本次计划编写未运行 pytest、变异或真实模型调用。 From dda55567ae17a6dd42322e78371d81f1a725fdd8 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 00:48:57 -0400 Subject: [PATCH 02/17] docs: register thinking contracts and record baseline checks --- .../2026-09-09-134-thinking-contracts-design.md | 7 +++++++ research-wiki/graph/edges.json | 17 +++++++++++++++++ research-wiki/index.md | 8 +++++--- research-wiki/log.md | 2 ++ .../plans/2026-09-09-134-thinking-contracts.md | 17 ++++++++++++----- 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/research-wiki/designs/2026-09-09-134-thinking-contracts-design.md b/research-wiki/designs/2026-09-09-134-thinking-contracts-design.md index 871b999..a278fe2 100644 --- a/research-wiki/designs/2026-09-09-134-thinking-contracts-design.md +++ b/research-wiki/designs/2026-09-09-134-thinking-contracts-design.md @@ -1,3 +1,10 @@ +--- +type: design +node_id: design:2026-09-09-134-thinking-contracts-design +title: "1.3.4 推理意图与测试证据设计" +date: 2026-09-09 +--- + # 1.3.4:推理意图的可满足性与测试证据契约 > 日期:2026-09-09。状态:**已通过独立审查并获人类正式批准;进入实施计划阶段,编码须先完成计划审查**。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 82e2095..0038b39 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -210,6 +210,16 @@ "id": "plan:reasoning-effort", "label": "实现计划: 推理档位一等化", "type": "plan" + }, + { + "id": "design:2026-09-09-134-thinking-contracts-design", + "label": "1.3.4 推理意图与测试证据设计", + "type": "design" + }, + { + "id": "plan:2026-09-09-134-thinking-contracts", + "label": "1.3.4 推理契约实施计划", + "type": "plan" } ], "links": [ @@ -429,6 +439,13 @@ "relation": "implements", "evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定", "added": "2026-09-05T04:07:17.723586+00:00" + }, + { + "source": "plan:2026-09-09-134-thinking-contracts", + "target": "design:2026-09-09-134-thinking-contracts-design", + "relation": "implements", + "evidence": "已批准设计;T0基线660 passed、make check通过", + "added": "2026-09-09T04:48:57.560089+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index ebf56d3..7a3c4a5 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,8 +1,9 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-09-05 04:07 UTC +> 自动生成,更新时间:2026-09-09 04:48 UTC -## design (41) +## design (42) +- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design` - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-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` @@ -61,7 +62,8 @@ - [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 (36) +## plan (37) +- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts` - [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` diff --git a/research-wiki/log.md b/research-wiki/log.md index 67ba932..17161eb 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -150,3 +150,5 @@ - [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort) - [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort - [2026-09-05 04:07 UTC] 重建索引: 95 篇页面 +- [2026-09-09 04:48 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --implements--> design:2026-09-09-134-thinking-contracts-design +- [2026-09-09 04:48 UTC] 重建索引: 97 篇页面 diff --git a/research-wiki/plans/2026-09-09-134-thinking-contracts.md b/research-wiki/plans/2026-09-09-134-thinking-contracts.md index 772842f..3fae291 100644 --- a/research-wiki/plans/2026-09-09-134-thinking-contracts.md +++ b/research-wiki/plans/2026-09-09-134-thinking-contracts.md @@ -1,3 +1,10 @@ +--- +type: plan +node_id: plan:2026-09-09-134-thinking-contracts +title: "1.3.4 推理契约实施计划" +date: 2026-09-09 +--- + # 1.3.4 推理契约与测试证据实施计划 > 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),进入执行;尚未编码/执行验收**。 @@ -161,11 +168,11 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc ### T0:基线、计划审查与文档回滚点 -- [ ] 修改设计批准状态,新增本计划;父会话自审后前台 Codex 独立审,具体问题修正后方可执行 T1。计划无需再走人类门,不能把“已生成”当“已审”。 -- [ ] 记录 `git status --short --branch`、`git log --oneline origin/main..HEAD`、实际 HEAD;确认源代码零差异,保存未跟踪文件清单,禁止暂存 `.pi/`。 -- [ ] 执行基线:`make check`;`conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_cache.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py -q`。记录实际失败,不能先改期待绕过;本步骤预期现有非 slow 测试通过。 -- [ ] 以 `research-wiki` 工具登记 design/plan 节点及 implements 边,现有同路径文档不可被 add_entity 模板覆盖;先读工具已有文件处理行为,再登记、重建索引、检查生成 diff。只在本计划 writer 移交后由父会话执行这些额外文件写入。 -- [ ] 调用 commit skill,提交点 `docs: record approved thinking contracts and implementation plan`,形成生产修改前回滚点。 +- [x] 修改设计批准状态,新增本计划;父会话自审后前台 Codex 独立审,具体问题修正后方可执行 T1。计划无需再走人类门,不能把“已生成”当“已审”。 +- [x] 记录 `git status --short --branch`、`git log --oneline origin/main..HEAD`、实际 HEAD;确认源代码零差异,保存未跟踪文件清单,禁止暂存 `.pi/`。 +- [x] 执行基线:`make check`;`conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_cache.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py -q`。记录实际失败,不能先改期待绕过;本步骤预期现有非 slow 测试通过。 +- [x] 以 `research-wiki` 工具登记 design/plan 节点及 implements 边,现有同路径文档不可被 add_entity 模板覆盖;先读工具已有文件处理行为,再登记、重建索引、检查生成 diff。只在本计划 writer 移交后由父会话执行这些额外文件写入。 +- [x] 调用 commit skill,提交点 `docs: record approved thinking contracts and implementation plan`,形成生产修改前回滚点。 ### T1:AUTO 成员语义与默认 MiniMax wire From 4ed144c9e49fc506ab2601275104860171111be6 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:22:18 -0400 Subject: [PATCH 03/17] fix: enforce registered auto reasoning capabilities --- src/polygateway/providers.py | 18 +++-------- src/polygateway/thinking.py | 28 +++++++++-------- tests/unit/test_providers.py | 10 ++---- tests/unit/test_thinking.py | 60 ++++++++++++++++++++++++------------ 4 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/polygateway/providers.py b/src/polygateway/providers.py index 644f1bb..5e4b885 100644 --- a/src/polygateway/providers.py +++ b/src/polygateway/providers.py @@ -29,9 +29,8 @@ class ThinkingWire: ``=None`` ``thinking_budget`` 调深度,不是档位) ============== ========================================================== - `on_base={}` 与 `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于 - 开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加), - 后者是"不知道怎么表达"。 + `on_base={}` 与 `on_base=None` 不可混: 前者是协议无需额外开启字节, + 是否满足 AUTO 由模型能力清单决定;后者是“不知道怎么表达”。 **为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat / openai-responses / anthropic-messages / google-generate-content 四种端点协议, @@ -117,21 +116,12 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType( # 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 待下一版处理。 + # 开启片段不代选强度;AUTO 可满足性由具体模型能力清单决定。 "minimax": ProviderProfile( name="minimax", thinking=ThinkingWire( off={"reasoning_effort": "none"}, - on_base={"reasoning_effort": "medium"}, + on_base={}, effort_key="reasoning_effort", ), strip_think_tags=False, diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 3f262cd..15b6f26 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -471,11 +471,8 @@ def resolve_thinking( 信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是 issue #20 的成因。 - **`auto` 不受档位清单约束**: 它表达的是"开启,但不指定强度",在请求体里就是 - "不写 `effort_key`",而不是写进 `effort_key` 的某个取值,故 Phase 5 放行它。 - 反过来判会让存量的 `ENABLE_THINKING=true`(T5 起等价于 `auto`)在 deepseek-v4 - 与 glm-5.3 这类清单里没有 `auto` 的模型上当场报错,而设计 §12 明确承诺存量 - 配置继续可跑——那里唯一允许新报错的是"关闭一个官方不可关的模型"。 + **已登记的 AUTO 同样受清单约束**: 开启形态不证明模型支持不指定强度。 + AUTO 不在强弱轴上,不允许 nearest 静默代选付费档位;未知模型仍尽力并告警。 `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而 `capability` 为 None(未登记)时无从从别处取得模型名。 @@ -517,7 +514,7 @@ def resolve_thinking( # 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由) if effort is Effort.NONE and not capability.can_disable: raise ThinkingUnsupportedError(_cannot_disable(model, capability)) - # Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring) + # Phase 5: 已登记选择必须可满足;AUTO 不允许按强度距离映射 applied = _settle_tier(effort, capability, model=model, fallback=fallback) return ThinkingResolution(_inject(profile, applied, model=model), applied) @@ -544,12 +541,15 @@ def _settle_tier( ) -> Effort: """Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。 - `auto` 直接放行: 它不是写进 `effort_key` 的取值,而是"不写 effort_key" - (理由见 `resolve_thinking` 的 docstring)。 + AUTO 与强度档统一检查成员,但不参与最近强度映射。 """ - if effort is Effort.AUTO or effort in capability.supported_efforts: + if effort in capability.supported_efforts: return effort - mapped = _nearest_effort(effort, capability) if fallback == "nearest" else None + mapped = ( + _nearest_effort(effort, capability) + if fallback == "nearest" and effort is not Effort.AUTO + else None + ) if mapped is None: raise ThinkingUnsupportedError( _tier_unsupported(model, effort, capability, fallback=fallback) @@ -634,7 +634,11 @@ def _tier_unsupported( else f"该模型只有开关、没有强度档位,可用: {listed}" ) # 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话 - hint = "" if fallback == "nearest" else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest" + hint = ( + "" + if fallback == "nearest" or effort is Effort.AUTO + else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest" + ) return f"{head}{body}{hint}" @@ -676,7 +680,7 @@ def _warn_unregistered( ) -> None: logger.warning( "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});" - "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", + "不保证开启、关闭或强度生效。实测后请用 register_capability 登记", model, profile.name, dict(payload), diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index 7f90d38..8653b4d 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -57,14 +57,10 @@ class TestDefaultProfiles: assert w.off == {"reasoning_effort": "none"}, name assert w.effort_key == "reasoning_effort", name - def test_minimax_on_tier_carries_a_tier_value(self): - """issue #21 的权宜之计: minimax 的"开"必须真写一个档位值,不能是空片段。 - - 断言反复过一次: T2 按"这些模型默认就推理"的推定把它改成 `{}`,T10 真实 - 网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium。 - """ + def test_minimax_on_does_not_select_a_tier(self): + """形态不代替模型能力,也不替调用者选择付费档位。""" w = get_provider("minimax").thinking - assert w.on_base == {"reasoning_effort": "medium"} + assert w.on_base == {} assert w.off == {"reasoning_effort": "none"} assert w.effort_key == "reasoning_effort" diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 77fa8c6..b5f630a 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -285,16 +285,13 @@ class TestResolveThinking: 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"} + @pytest.mark.parametrize("model", ["MiniMax-M2.5", "MiniMax-M2.7"]) + def test_phase4_only_blocks_the_off_direction(self, model): + """已登记 AUTO 只发开启片段,不由库代选 medium。""" + got = resolve_thinking( + get_provider("minimax"), get_capability(model), Effort.AUTO, model=model + ) + assert got.payload == {} assert got.applied_effort is Effort.AUTO def test_phase4_passes_when_none_is_registered(self): @@ -351,17 +348,42 @@ class TestResolveThinking: 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。 + @pytest.mark.parametrize( + "provider,model", [("deepseek", "deepseek-v4-pro"), ("minimax", "MiniMax-M3")] + ) + @pytest.mark.parametrize("fallback", ["error", "nearest"]) + def test_unregistered_auto_choice_is_rejected(self, provider, model, fallback): + """有开启形态也不代表已登记模型支持 AUTO,nearest 不可代选。""" + with pytest.raises(ThinkingUnsupportedError) as exc: + resolve_thinking( + get_provider(provider), + get_capability(model), + Effort.AUTO, + model=model, + fallback=fallback, + ) + assert model in str(exc.value) + assert "auto" in str(exc.value) + assert "EFFORT_FALLBACK=nearest" not in str(exc.value) - 它不是写进 `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"}} + def test_minimax_explicit_medium_restores_old_wire(self): + got = resolve_thinking( + get_provider("minimax"), get_capability("MiniMax-M3"), Effort.MEDIUM, model="MiniMax-M3" + ) + assert got.payload == {"reasoning_effort": "medium"} + assert got.applied_effort is Effort.MEDIUM + + @pytest.mark.parametrize("provider", ["openai", "qwen"]) + def test_unknown_auto_warns_without_promising_effect(self, provider): + messages, sink = _warnings() + try: + got = resolve_thinking( + get_provider(provider), None, Effort.AUTO, model="unregistered-model" + ) + finally: + logger.remove(sink) assert got.applied_effort is Effort.AUTO + assert any("不保证" in str(message) for message in messages) # —— nearest 映射(fallback 的逃生口)—— From 1ee74c35a8a30a3f7998f8f040d5114317a0939c Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:24:54 -0400 Subject: [PATCH 04/17] fix: validate ownership of managed reasoning parameters --- src/polygateway/thinking.py | 57 +++++++++++++++++++++++++++++++++ tests/unit/test_thinking.py | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 15b6f26..da8af1f 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -441,6 +441,62 @@ def effective_effort( return Effort.AUTO if enable_thinking else Effort.NONE +_RAW_THINKING_ROOTS = frozenset( + { + "reasoning_effort", + "enable_thinking", + "thinking", + "thinking_budget", + "reasoning", + "thinkingConfig", + } +) + + +def _has_output_effort(raw: Mapping[str, Any]) -> bool: + """标准嵌套强度只检查明确的路径,不猜私有方言。""" + output = raw.get("output_config") + return isinstance(output, Mapping) and "effort" in output + + +def validate_thinking_wire(wire: ThinkingWire, *, model: str) -> None: + """拒绝开启片段代选强度,未知形态仍交由请求方向检查。""" + base = wire.on_base + if base is not None and ( + "reasoning_effort" in base + or (wire.effort_key is not None and wire.effort_key in base) + or _has_output_effort(base) + ): + raise ThinkingUnsupportedError( + f"模型 {model!r} 的 on_base 不得包含强度档位;请移除强度并显式传 reasoning_effort" + ) + + +def validate_thinking_raw( + raw: Mapping[str, Any], + *, + effort: Effort | None, + wire: ThinkingWire | None, + origin: str, +) -> None: + """受管推理只有一个来源;同值或被遮蔽的 raw 控制也拒绝。""" + if effort is None: + return + roots = set(_RAW_THINKING_ROOTS) + if wire is not None: + for fragment in (wire.on_base, wire.off): + if fragment is not None: + roots.update(fragment) + if wire.effort_key is not None: + roots.add(wire.effort_key) + if roots.intersection(raw) or _has_output_effort(raw): + # 不打印 raw 或自定义键名,防配置中夹带秘密。 + raise ThinkingUnsupportedError( + f"{origin} 与受管 reasoning_effort={effort.value!r} 冲突;" + "请删除 raw 推理控制,或移除源级/请求级推理表态后仅用 raw" + ) + + def resolve_thinking( profile: ProviderProfile, capability: ThinkingCapability | None, @@ -490,6 +546,7 @@ def resolve_thinking( 而是**静默判否**: Phase 2 按开启方向取字段、Phase 4 整条被绕过,最后在拼错误 文案时才以 `AttributeError` 现形(一个未文档化、也不属四分类的异常)。 """ + validate_thinking_wire(profile.thinking, model=model) # Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举 if effort is not None: effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})") diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index b5f630a..e3bb6bc 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -847,3 +847,67 @@ class TestEffectiveEffort: assert ( effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None ) + + +class TestThinkingWireOwnership: + """开启片段只能表达开启,不能携带隐式强度。""" + + @pytest.mark.parametrize( + "base", + [ + {"reasoning_effort": "high"}, + {"reasoning_effort": None}, + {"depth": "auto"}, + {"output_config": {"effort": "low"}}, + ], + ) + @pytest.mark.parametrize("effort", [None, Effort.AUTO, Effort.HIGH]) + def test_on_base_cannot_hide_a_tier(self, base, effort): + profile = ProviderProfile( + name="custom", + thinking=ThinkingWire(off={"depth": "none"}, on_base=base, effort_key="depth"), + strip_think_tags=False, + ) + with pytest.raises(ThinkingUnsupportedError, match="on_base"): + resolve_thinking(profile, None, effort, model="custom-model") + + +class TestThinkingRawOwnership: + """纯规则覆盖标准、自定义根与无意图逃生口。""" + + @pytest.mark.parametrize( + "raw", + [ + {"reasoning_effort": "high"}, + {"enable_thinking": True}, + {"thinking": {}}, + {"thinking_budget": 100}, + {"reasoning": {}}, + {"thinkingConfig": {}}, + {"output_config": {"effort": None}}, + {"depth.key": None}, + {"off_control": {}}, + ], + ) + @pytest.mark.parametrize("effort", [Effort.NONE, Effort.AUTO, Effort.HIGH]) + def test_control_roots_rejected_without_mutating_input(self, raw, effort): + from copy import deepcopy + + from polygateway.thinking import validate_thinking_raw + + wire = ThinkingWire(off={"off_control": False}, on_base={}, effort_key="depth.key") + before = deepcopy(raw) + with pytest.raises(ThinkingUnsupportedError): + validate_thinking_raw(raw, effort=effort, wire=wire, origin="test") + assert raw == before + validate_thinking_raw(raw, effort=None, wire=wire, origin="test") + assert raw == before + + def test_output_format_is_not_effort_unless_wire_owns_root(self): + from polygateway.thinking import validate_thinking_raw + + raw = {"output_config": {"format": "json"}, "temperature": 0, "seed": 7} + validate_thinking_raw(raw, effort=Effort.AUTO, wire=None, origin="test") + wire = ThinkingWire(off=None, on_base={"output_config": {"enabled": True}}, effort_key=None) + with pytest.raises(ThinkingUnsupportedError): + validate_thinking_raw(raw, effort=Effort.AUTO, wire=wire, origin="test") From 8e61a663421deb0b7940055e98ee775e8bbe145e Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:26:34 -0400 Subject: [PATCH 05/17] fix: reject conflicting raw reasoning overrides before sending --- src/polygateway/client.py | 27 +++- src/polygateway/transports/openai_compat.py | 14 +- tests/unit/test_client.py | 116 +++++++++----- tests/unit/test_openai_compat.py | 166 +++++++++++++------- 4 files changed, 213 insertions(+), 110 deletions(-) diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 5b3e1b7..c5f347c 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -34,7 +34,12 @@ from polygateway.sources import ( RoundRobinSelector, SourceCooldownMemo, ) -from polygateway.thinking import effective_effort, get_capability, resolve_thinking +from polygateway.thinking import ( + effective_effort, + get_capability, + resolve_thinking, + validate_thinking_raw, +) from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.types import ( ChatRequest, @@ -82,21 +87,26 @@ def _guard_thinking( 就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。 """ for source, profile in zip(sources, profiles, strict=True): + effort = effective_effort( + request_effort=None, + source_effort=source.reasoning_effort, + enable_thinking=source.enable_thinking, + ) resolve_thinking( profile, get_capability(source.model, table=capabilities), - # 装配期看不见请求级档位(它逐次调用才产生),故只解源级两层;请求级 - # 只能在运行期由 transport 校验(设计 §10 的装配期/运行期分工) - effective_effort( - request_effort=None, - source_effort=source.reasoning_effort, - enable_thinking=source.enable_thinking, - ), + effort, model=source.model, # 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被 # 判死,而它在运行期本来是能映射到最近档跑起来的 fallback=source.effort_fallback, ) + validate_thinking_raw( + source.extra_body, + effort=effort, + wire=profile.thinking, + origin=f"源 {source.name} extra_body", + ) def _fingerprint_mark(source: SourceConfig) -> str: @@ -359,6 +369,7 @@ class GatewayClient: if reasoning_effort is None else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)") ) + validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay") request = ChatRequest( messages=messages, session_id=session_id, diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 207fcaf..a76673a 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -34,6 +34,7 @@ from polygateway.thinking import ( observe_thinking, reconcile_thinking, resolve_thinking, + validate_thinking_raw, ) from polygateway.transports._http_errors import compose_message, summarize_body from polygateway.types import ( @@ -371,20 +372,23 @@ class OpenAICompatTransport: self._warned_models.add(source.model) # 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。 # 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错" + effort = effective_effort( + request_effort=reasoning_effort, + source_effort=source.reasoning_effort, + enable_thinking=source.enable_thinking, + ) resolution = resolve_thinking( profile, capability, - effective_effort( - request_effort=reasoning_effort, - source_effort=source.reasoning_effort, - enable_thinking=source.enable_thinking, - ), + effort, model=source.model, # 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确 # 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出) fallback=source.effort_fallback, warn_unregistered=first_time, ) + for raw, origin in ((source.extra_body, "source extra_body"), (overlay, "request overlay")): + validate_thinking_raw(raw, effort=effort, wire=profile.thinking, origin=origin) payload.update(resolution.payload) # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级 # overlay(含结构化注入)在后覆盖之。两行不可调换 diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 1a4c35b..af51c6b 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -247,52 +247,35 @@ class TestReasoningEffortPriority: assert "reasoning_effort" not in captured[0] @pytest.mark.parametrize( - ("provider", "model", "fragment"), + "provider,model,tier", [ - ("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"}}), + ("deepseek", "deepseek-v4-pro", "high"), + ("zhipu", "glm-5.3", "low"), + ("moonshot", "kimi-k3", "low"), + ("minimax", "MiniMax-M3", "medium"), ], ) - async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment): - """存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。 + async def test_legacy_auto_requires_explicit_migration(self, provider, model, tier): + """旧糖配置明确拒绝,显式选择才能恢复可执行请求。""" + from dataclasses import replace - **只覆盖 `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" + client = self._capturing_client(captured, sources=[source]) + try: + with pytest.raises(RequestRejectedError): + await client.chat([]) + assert captured == [] + finally: + await client._transport.aclose() + client = self._capturing_client( + captured, sources=[replace(source, enable_thinking=None, reasoning_effort=tier)] + ) + try: + await client.chat([]) + assert captured[0]["reasoning_effort"] == tier + finally: + await client._transport.aclose() class TestEffortFallbackWiring: @@ -1280,3 +1263,56 @@ class TestTelemetryStatusExposure: assert _ocr_client().telemetry_status is None assert _ocr_client(telemetry=_Closable()).telemetry_status is None self._assert_snapshot(_ocr_client(telemetry=self._recorder(tmp_path)).telemetry_status) + + +class TestManagedReasoningAdmission: + """入口前置与实际准入边界保持明确。""" + + @pytest.mark.parametrize("factory", ["env", "settings"]) + def test_factory_rejects_conflict_before_building_backends(self, monkeypatch, factory): + env = { + **_ENV, + "LLM__QWEN__1__MODEL": "qwen3.7-plus", + "LLM__QWEN__1__ENABLE_THINKING": "true", + "LLM__QWEN__1__EXTRA_BODY": '{"enable_thinking":true}', + } + built = [] + + def forbidden(*args, **kwargs): + built.append(True) + raise AssertionError("后端不得构造") + + monkeypatch.setattr("polygateway.client._build_limiter", forbidden) + with pytest.raises(ValueError, match="冲突"): + if factory == "env": + GatewayClient.from_env(env=env) + else: + GatewayClient.from_settings(GatewaySettings.from_env(env=env)) + assert not built + + async def test_request_conflict_does_not_enter_onion(self): + client = _client() + + async def forbidden(request): + raise AssertionError("不得进入洋葱") + + client._handler = forbidden + try: + with pytest.raises(ValueError, match="冲突"): + await client.chat([], reasoning_effort="high", overlay={"reasoning_effort": "high"}) + finally: + await client._transport.aclose() + + async def test_source_conflict_releases_admitted_permit(self): + source = _source( + reasoning_effort="high", provider="openai", extra_body={"reasoning_effort": "high"} + ) + sent = [] + client = _client(sources=[source], handler=lambda request: sent.append(request) or _sse()) + try: + with pytest.raises(RequestRejectedError, match="冲突"): + await client.chat([]) + assert sent == [] + assert (await client._limiter_backend.source_stats(source.name)).inflight == 0 + finally: + await client._transport.aclose() diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index a36b7a2..0fa3d0a 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -623,8 +623,8 @@ class TestThinkingReconciliation: try: await _complete(transport, self._minimax(False)) await _complete(transport, self._minimax(False)) - await _complete(transport, self._minimax(True)) - await _complete(transport, self._minimax(True)) + await _complete(transport, self._minimax(True), reasoning_effort=Effort.MEDIUM) + await _complete(transport, self._minimax(True), reasoning_effort=Effort.MEDIUM) finally: logger.remove(sink_id) hits = [m for m in messages if "MiniMax-M3" in m] @@ -705,75 +705,44 @@ class TestNonStreamFastPath: class TestRequestShaping: - @pytest.mark.parametrize( - ("enable_thinking", "expected"), - [(True, {"enable_thinking": True}), (False, {"enable_thinking": False}), (None, {})], - ) - async def test_thinking_tri_state_injection(self, enable_thinking, expected): + @pytest.mark.parametrize("tier", [Effort.MEDIUM, Effort.NONE]) + async def test_minimax_explicit_tier_is_sent(self, tier): seen = {} def handler(request): seen.update(json.loads(request.content)) return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) - await _complete(_transport_for(handler), _source(enable_thinking=enable_thinking)) - assert {k: seen[k] for k in expected} == expected - if enable_thinking is None: + transport = _transport_for(handler) + try: + result = await _complete( + transport, _source(provider="minimax", model="MiniMax-M3"), reasoning_effort=tier + ) + assert seen["reasoning_effort"] == tier.value + assert result.applied_effort is tier assert "enable_thinking" not in seen - assert seen["stream_options"] == {"include_usage": True} + finally: + await transport.aclose() - @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): - """issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。""" + async def test_raw_only_keeps_source_then_request_priority(self): seen = {} def handler(request): seen.update(json.loads(request.content)) return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) - source = _source( - name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking - ) - await _complete(_transport_for(handler), source) - 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,两行不可调换。 - - 固定用 **zhipu + glm-5.3 + 源级 low** 这组: 判据必须落在一个 profile - **真的写了值**的键上,两边写同一个键才谈得上谁覆盖谁。不挑 minimax 是因为 - 它的 `on_base` 只写 `reasoning_effort` 一个键(issue #21 的权宜之计), - 覆盖发生后看不见"profile 独有的那半边仍在",判据少一半。 - """ - seen = {} - - def handler(request): - seen.update(json.loads(request.content)) - return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) - - source = _source( - 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 独有的那半边仍在 + transport = _transport_for(handler) + try: + result = await _complete( + transport, + _source(extra_body={"reasoning_effort": "low", "temperature": 0}), + overlay={"reasoning_effort": "high", "temperature": 1}, + ) + assert seen["reasoning_effort"] == "high" + assert seen["temperature"] == 1 + assert result.applied_effort is None + finally: + await transport.aclose() async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self): """M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。 @@ -1056,3 +1025,86 @@ class TestLifecycle: await _complete(transport, _source()) await transport.aclose() await transport.aclose() + + +class TestManagedReasoningOwnership: + """通过真实 transport 验证拒绝发生在 HTTP 之前。""" + + @pytest.mark.parametrize( + "raw", + [ + {"reasoning_effort": "high"}, + {"enable_thinking": True}, + {"thinking": {}}, + {"thinking_budget": 100}, + {"reasoning": {}}, + {"thinkingConfig": {}}, + {"output_config": {"effort": "low"}}, + ], + ) + @pytest.mark.parametrize("layer", ["source", "request", "shadowed"]) + async def test_raw_control_is_rejected_before_http(self, raw, layer): + sent = [] + + def handler(request): + sent.append(request) + return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE)) + + source = _source( + provider="openai", + reasoning_effort=Effort.HIGH, + extra_body=raw if layer != "request" else {}, + ) + overlay = raw if layer != "source" else {} + transport = _transport_for(handler) + try: + with pytest.raises(RequestRejectedError): + await _complete(transport, source, overlay=overlay) + assert sent == [] + finally: + await transport.aclose() + + +class TestDefaultClientFactory: + """直接检查生产 factory,不用取证测试的另一套 factory 代替。""" + + @pytest.mark.parametrize("key", ["fake-a", "fake-b"]) + async def test_authorization_uses_source_api_key(self, key): + from polygateway.transports.openai_compat import _default_client_factory + + client = _default_client_factory(_source(api_key=key)) + try: + assert client.headers["Authorization"] == f"Bearer {key}" + assert ( + client.build_request("POST", "https://gw.example/v1/chat/completions").headers[ + "Authorization" + ] + == f"Bearer {key}" + ) + finally: + await client.aclose() + + @pytest.mark.parametrize("timeout", [17.0, 53.0]) + async def test_timeout_uses_source_timeout_for_all_phases(self, timeout): + from polygateway.transports.openai_compat import _default_client_factory + + client = _default_client_factory(_source(timeout_s=timeout)) + try: + assert [ + client.timeout.connect, + client.timeout.read, + client.timeout.write, + client.timeout.pool, + ] == [timeout] * 4 + finally: + await client.aclose() + + @pytest.mark.parametrize("trust_env", [True, False]) + async def test_trust_env_uses_source_setting(self, trust_env): + from polygateway.transports.openai_compat import _default_client_factory + + client = _default_client_factory(_source(trust_env=trust_env)) + try: + assert client.trust_env is trust_env + finally: + await client.aclose() From 71f1bdf26b67f052318762a8346bac341c856d28 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:27:25 -0400 Subject: [PATCH 06/17] test: isolate factory checks from developer proxy settings --- tests/unit/test_openai_compat.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 0fa3d0a..497b062 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -1068,6 +1068,13 @@ class TestManagedReasoningOwnership: class TestDefaultClientFactory: """直接检查生产 factory,不用取证测试的另一套 factory 代替。""" + @pytest.fixture(autouse=True) + def isolate_proxy_environment(self, monkeypatch): + """离线构造测试不继承开发机代理;仍真实验证 trust_env 传递。""" + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"): + monkeypatch.delenv(key, raising=False) + monkeypatch.delenv(key.lower(), raising=False) + @pytest.mark.parametrize("key", ["fake-a", "fake-b"]) async def test_authorization_uses_source_api_key(self, key): from polygateway.transports.openai_compat import _default_client_factory From d0078c1be53c9d5d6eb8d0fab5cda98300064bb4 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:34:33 -0400 Subject: [PATCH 07/17] test: pin explicit cache migration and reasoning row semantics --- tests/unit/test_cache.py | 226 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index c83e423..3f5b4b6 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -592,3 +592,229 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey: assert "(略 112 字)" in logged[1]["content"][0]["text"] assert build_cache_key("m", messages, "proj", None) == before + + +class TestExplicitCacheMigration: + """相同模型身份不代表相同推理策略,隔离必须由调用方显式选择。""" + + def _client(self, cache, source, *, capabilities=None, registry=None): + import httpx + + from polygateway.transports.openai_compat import OpenAICompatTransport + from tests.unit.test_client import _client, _sse + + sent = [] + + def handler(request): + payload = json.loads(request.content) + sent.append(payload) + return _sse(json.dumps(payload, sort_keys=True)) + + transport = OpenAICompatTransport( + client_factory=lambda src: httpx.AsyncClient(transport=httpx.MockTransport(handler)), + capabilities=capabilities, + registry=registry, + ) + client = _client( + sources=[source], + transport=transport, + cache=cache, + cache_namespace="tenant-a", + cache_ttl_s=60, + ) + return client, transport, sent + + @pytest.mark.parametrize("isolation", ["namespace", "salt"]) + @pytest.mark.parametrize("change", ["capability", "fallback"]) + async def test_capability_change_requires_explicit_identity(self, isolation, change): + from polygateway.client import build_model_fingerprint + from polygateway.errors import RequestRejectedError + from polygateway.thinking import ThinkingCapability + from tests.unit.test_client import _source + + cache = InMemoryCache() + source = _source(provider="openai", model="migration-model") + if change == "capability": + old_cap = ThinkingCapability((Effort.AUTO, Effort.HIGH), "本地旧声明") + new_cap = ThinkingCapability((Effort.HIGH,), "本地新声明") + tier = Effort.AUTO + new_source = source + else: + old_cap = new_cap = ThinkingCapability((Effort.LOW, Effort.HIGH), "本地映射声明") + source = dataclasses.replace(source, effort_fallback="nearest") + new_source = dataclasses.replace(source, effort_fallback="error") + tier = Effort.MEDIUM + assert build_model_fingerprint([source]) == build_model_fingerprint([new_source]) + old, old_transport, old_sent = self._client( + cache, source, capabilities={source.model: old_cap} + ) + new, new_transport, new_sent = self._client( + cache, new_source, capabilities={source.model: new_cap} + ) + identity = ( + {"cache_namespace": "tenant-a:migrated"} + if isolation == "namespace" + else {"cache_salt": "migrated"} + ) + try: + original = await old.chat(_MSGS, reasoning_effort=tier) + replay = await new.chat(_MSGS, reasoning_effort=tier) + assert replay.cache_hit and replay.content == original.content + assert len(old_sent) == 1 and not new_sent + with pytest.raises(RequestRejectedError): + await new.chat(_MSGS, reasoning_effort=tier, **identity) + assert not new_sent + assert (await old.chat(_MSGS, reasoning_effort=tier)).cache_hit + finally: + await old_transport.aclose() + await new_transport.aclose() + + @pytest.mark.parametrize("isolation", ["namespace", "salt"]) + async def test_custom_wire_change_requires_explicit_identity(self, isolation): + from polygateway.client import build_model_fingerprint + from polygateway.providers import ProviderProfile, ThinkingWire + from polygateway.thinking import ThinkingCapability + from tests.unit.test_client import _source + + source = _source(provider="custom", model="migration-model") + caps = {source.model: ThinkingCapability((Effort.HIGH,), "本地声明")} + + def profile(key): + return { + "custom": ProviderProfile( + name="custom", + thinking=ThinkingWire(off=None, on_base={}, effort_key=key), + strip_think_tags=False, + ) + } + + cache = InMemoryCache() + old, t1, sent1 = self._client(cache, source, capabilities=caps, registry=profile("depth_a")) + new, t2, sent2 = self._client(cache, source, capabilities=caps, registry=profile("depth_b")) + assert build_model_fingerprint(old._terminal._sources) == build_model_fingerprint( + new._terminal._sources + ) + identity = ( + {"cache_namespace": "tenant-a:migrated"} + if isolation == "namespace" + else {"cache_salt": "migrated"} + ) + try: + original = await old.chat(_MSGS, reasoning_effort=Effort.HIGH) + assert (await new.chat(_MSGS, reasoning_effort=Effort.HIGH)).cache_hit + migrated = await new.chat(_MSGS, reasoning_effort=Effort.HIGH, **identity) + assert not migrated.cache_hit and migrated.content != original.content + assert len(sent1) == len(sent2) == 1 + assert sent2[0]["depth_b"] == "high" and "depth_a" not in sent2[0] + assert (await old.chat(_MSGS, reasoning_effort=Effort.HIGH)).content == original.content + finally: + await t1.aclose() + await t2.aclose() + + @pytest.mark.parametrize("isolation", ["namespace", "salt"]) + async def test_legacy_raw_override_requires_explicit_identity(self, isolation): + from polygateway.client import build_model_fingerprint + from polygateway.errors import RequestRejectedError + from tests.unit.test_client import _source + + source = _source( + provider="openai", reasoning_effort="high", extra_body={"reasoning_effort": "low"} + ) + cache = InMemoryCache() + key = build_cache_key(build_model_fingerprint([source]), _MSGS, "tenant-a", None) + legacy = dataclasses.asdict(_resp(content="legacy-raw-low", applied_effort=Effort.HIGH)) + legacy.pop("structured_data", None) + await cache.set(key, json.dumps(legacy), 60) + client, transport, sent = self._client(cache, source) + identity = ( + {"cache_namespace": "tenant-a:migrated"} + if isolation == "namespace" + else {"cache_salt": "migrated"} + ) + try: + assert (await client.chat(_MSGS)).content == "legacy-raw-low" + with pytest.raises(RequestRejectedError, match="冲突"): + await client.chat(_MSGS, **identity) + assert sent == [] + assert await cache.get(key) is not None + finally: + await transport.aclose() + + async def test_per_call_namespace_survives_a_changed_factory_default(self): + from polygateway import GatewayClient, GatewaySettings + from tests.unit.test_client import _ENV, _source + + cache = InMemoryCache() + source = _source() + old, transport, sent = self._client(cache, source) + try: + await old.chat(_MSGS, cache_namespace="tenant-a") + # 工厂路径和全量注入配置同模型身份;只改默认不能改变显式租户覆盖。 + settings = GatewaySettings.from_env( + env={ + **_ENV, + "PGW_CACHE_BACKEND": "memory", + "PGW_CACHE_NAMESPACE": "changed-default", + "PGW_CACHE_TTL_S": "60", + } + ) + new = GatewayClient.from_settings(settings, cache=cache) + try: + assert (await new.chat(_MSGS, cache_namespace="tenant-a")).cache_hit + assert len(sent) == 1 + finally: + await new.aclose() + finally: + await transport.aclose() + + async def test_shared_source_pool_migration_preserves_tenant_boundaries(self): + import httpx + + from polygateway.errors import RequestRejectedError + from polygateway.thinking import ThinkingCapability + from polygateway.transports.openai_compat import OpenAICompatTransport + from tests.unit.test_client import _client, _source, _sse + + sources = [ + _source(name=name, provider="openai", model="shared-model") for name in ("a", "b") + ] + cache = InMemoryCache() + sent = [] + + def handler(request): + sent.append(request) + return _sse() + + transports = [ + OpenAICompatTransport( + client_factory=lambda src: httpx.AsyncClient( + transport=httpx.MockTransport(handler) + ), + capabilities={"shared-model": ThinkingCapability(choices, "本地声明")}, + ) + for choices in ((Effort.AUTO, Effort.HIGH), (Effort.HIGH,)) + ] + clients = [ + _client( + sources=sources, transport=t, cache=cache, cache_namespace="default", cache_ttl_s=60 + ) + for t in transports + ] + try: + for tenant in ("tenant-a", "tenant-b"): + await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant) + assert ( + await clients[1].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant) + ).cache_hit + with pytest.raises(RequestRejectedError): + await clients[1].chat( + _MSGS, reasoning_effort="auto", cache_namespace=tenant + ":new" + ) + assert len(sent) == 2 + for tenant in ("tenant-a", "tenant-b"): + assert ( + await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant) + ).cache_hit + finally: + for transport in transports: + await transport.aclose() From 47488ee4fd6496ebc73608c4cba97243484994f7 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:34:36 -0400 Subject: [PATCH 08/17] test: guard reasoning-free telemetry through real client paths --- tests/unit/test_embedding.py | 67 +++++++++++++++++++++++++++ tests/unit/test_monkey_ocr.py | 28 +++++++++++ tests/unit/test_ocr_client.py | 51 ++++++++++++++++++++ tests/unit/test_telemetry.py | 87 +++++++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+) diff --git a/tests/unit/test_embedding.py b/tests/unit/test_embedding.py index 1880f4d..94b2c6c 100644 --- a/tests/unit/test_embedding.py +++ b/tests/unit/test_embedding.py @@ -533,3 +533,70 @@ class TestEmbeddingSettings: s = EmbeddingSettings.from_env("EMBED", env=self._ENV) client = EmbeddingClient.from_settings(s) assert isinstance(client, EmbeddingClient) + + +class TestReasonlessTelemetryContract: + """从真实客户端到落库,误配推理配置也不能产生推理档。""" + + @pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}]) + @pytest.mark.parametrize("backend", ["memory", "sqlite"]) + async def test_failed_then_successful_attempts_have_null_effort( + self, config, backend, tmp_path + ): + import sqlite3 + + from polygateway.telemetry.sqlite import SQLiteRecorder + + path = tmp_path / "embed.sqlite" + recorder = ( + _MemoryRecorder() if backend == "memory" else SQLiteRecorder(path, auto_migrate=True) + ) + client, _ = _embed_client( + [_src(**config)], [TransientError("retry"), "ok"], telemetry=recorder + ) + try: + await client.embed(["text"], session_id="run", parent_call_id="embed") + if backend == "memory": + rows = [(r["error"], r["reasoning_effort"]) for r in recorder.rows] + else: + with sqlite3.connect(path) as db: + rows = db.execute("SELECT error, reasoning_effort FROM llm_calls").fetchall() + assert len(rows) == 2 + assert sum(bool(error) for error, _ in rows) == 1 + assert [tier for _, tier in rows] == [None, None] + finally: + await client.aclose() + if backend == "sqlite": + recorder.close() + + @pytest.mark.parametrize("exhausted", [False, True]) + async def test_failed_attempts_still_have_null_effort(self, exhausted): + from polygateway.errors import AllSourcesExhausted + + script = ( + [TransientError("retry")] * 3 if exhausted else [RequestRejectedError("bad request")] + ) + recorder = _MemoryRecorder() + client, _ = _embed_client([_src(enable_thinking=True)], script, telemetry=recorder) + with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError): + await client.embed(["text"]) + assert len(recorder.rows) == len(script) + assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows) + + async def test_embedding_wire_ignores_reasoning_configuration(self): + seen = [] + + def handler(request): + seen.append(json.loads(request.content)) + return httpx.Response(200, json=_ok_body([[1.0]], usage={"prompt_tokens": 1})) + + transport = _transport_with(handler) + try: + await transport.embed( + texts=["text"], + source=_src(enable_thinking=True, reasoning_effort="high"), + call_id="wire", + ) + assert seen == [{"model": "embed-1", "input": ["text"]}] + finally: + await transport.aclose() diff --git a/tests/unit/test_monkey_ocr.py b/tests/unit/test_monkey_ocr.py index 77c4461..f0c100e 100644 --- a/tests/unit/test_monkey_ocr.py +++ b/tests/unit/test_monkey_ocr.py @@ -405,3 +405,31 @@ class TestLifecycle: await t.check_health(source=_source()) await t.aclose() await t.aclose() + + +@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"]) +async def test_ocr_wire_does_not_send_reasoning_configuration(method): + """真实 multipart 与 ZIP 下载两段均不发送源级推理配置。""" + sent = [] + + def handler(request): + sent.append(request) + if request.method == "GET": + return httpx.Response(200, content=_zip_bytes()) + return httpx.Response( + 200, json=_text_body() if method == "recognize_text" else _parse_body() + ) + + transport = _transport_for(handler) + try: + await getattr(transport, method)( + image=b"image", + source=_source(enable_thinking=True, reasoning_effort="high"), + call_id="wire", + ) + assert len(sent) == (1 if method == "recognize_text" else 2) + for request in sent: + for key in (b"reasoning_effort", b"enable_thinking", b"thinking_budget"): + assert key not in request.content + finally: + await transport.aclose() diff --git a/tests/unit/test_ocr_client.py b/tests/unit/test_ocr_client.py index 85e875b..00e435b 100644 --- a/tests/unit/test_ocr_client.py +++ b/tests/unit/test_ocr_client.py @@ -564,3 +564,54 @@ class TestAssembly: client = OcrClient.from_env("OCR", env=dict(self._ENV)) await client.aclose() await client.aclose() + + +class TestReasonlessTelemetryContract: + """text/layout 两个入口分别验证错误行不受源级推理配置污染。""" + + @pytest.mark.parametrize( + "method,action", [("recognize_text", "text"), ("parse_layout", "layout")] + ) + @pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}]) + @pytest.mark.parametrize("backend", ["memory", "sqlite"]) + async def test_failed_then_successful_attempts_have_null_effort( + self, method, action, config, backend, tmp_path + ): + import sqlite3 + + from polygateway.telemetry.sqlite import SQLiteRecorder + + path = tmp_path / "ocr.sqlite" + recorder = ( + _MemoryRecorder() if backend == "memory" else SQLiteRecorder(path, auto_migrate=True) + ) + client, _, _ = _client( + [_src(**config)], [TransientError("retry"), action], telemetry=recorder + ) + try: + await getattr(client, method)(b"image", session_id="run", parent_call_id=method) + if backend == "memory": + rows = [(r["error"], r["reasoning_effort"]) for r in recorder.rows] + else: + with sqlite3.connect(path) as db: + rows = db.execute("SELECT error, reasoning_effort FROM llm_calls").fetchall() + assert len(rows) == 2 + assert sum(bool(error) for error, _ in rows) == 1 + assert [tier for _, tier in rows] == [None, None] + finally: + await client.aclose() + if backend == "sqlite": + recorder.close() + + @pytest.mark.parametrize("method", ["recognize_text", "parse_layout"]) + @pytest.mark.parametrize("exhausted", [False, True]) + async def test_failed_attempts_still_have_null_effort(self, method, exhausted): + script = ( + [TransientError("retry")] * 3 if exhausted else [RequestRejectedError("bad request")] + ) + recorder = _MemoryRecorder() + client, _, _ = _client([_src(enable_thinking=True)], script, telemetry=recorder) + with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError): + await getattr(client, method)(b"image") + assert len(recorder.rows) == len(script) + assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows) diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index 693db6f..49aacca 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -2839,3 +2839,90 @@ class TestPostgresFailureClassification: assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")] assert recorder.telemetry_status.degraded is False + + +class TestConcurrentReasoningPathContracts: + """真实治理链路并发时,按逻辑调用归组且 attempts 不串档。""" + + async def test_chat_and_reasonless_clients_keep_distinct_rows(self): + from polygateway.errors import TransientError + from tests.unit.test_client import _client as chat_client + from tests.unit.test_client import _source, _sse + from tests.unit.test_embedding import _embed_client + from tests.unit.test_embedding import _src as embed_source + from tests.unit.test_ocr_client import _client as ocr_client + from tests.unit.test_ocr_client import _src as ocr_source + + recorder = _MemoryRecorder() + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + if calls == 1: + import httpx + + return httpx.Response(503, json={"error": {"message": "retry"}}) + return _sse() + + chat = chat_client( + sources=[_source(provider="zhipu", model="glm-5.3", effort_fallback="nearest")], + handler=handler, + telemetry=recorder, + retry=RetryPolicy(3, 0.001, 0.01), + ) + embed, _ = _embed_client( + [embed_source(enable_thinking=True)], + [TransientError("retry"), "ok"], + telemetry=recorder, + ) + ocr, _, _ = ocr_client( + [ocr_source(enable_thinking=True)], + [TransientError("retry"), "text"], + telemetry=recorder, + ) + try: + await asyncio.gather( + chat.chat([], reasoning_effort="medium", session_id="run", parent_call_id="chat"), + embed.embed(["text"], session_id="run", parent_call_id="embed"), + ocr.recognize_text(b"image", session_id="run", parent_call_id="ocr"), + ) + groups = { + name: [r for r in recorder.rows if r["parent_call_id"] == name] + for name in ("chat", "embed", "ocr") + } + assert len(recorder.rows) == 6 + assert len({r["call_id"] for r in recorder.rows}) == 6 + assert all(r["session_id"] == "run" for r in recorder.rows) + assert [r["reasoning_effort"] for r in groups["chat"]] == ["medium", "low"] + for name in ("embed", "ocr"): + assert len(groups[name]) == 2 + assert [r["reasoning_effort"] for r in groups[name]] == [None, None] + finally: + await chat._transport.aclose() + await chat.aclose() + await embed.aclose() + await ocr.aclose() + + async def test_chat_sugar_failure_records_auto_through_retry(self): + import httpx + + from polygateway.errors import AllSourcesExhausted + from tests.unit.test_client import _client, _source + + recorder = _MemoryRecorder() + client = _client( + sources=[_source(model="qwen3.7-plus", enable_thinking=True)], + handler=lambda request: httpx.Response(503), + telemetry=recorder, + retry=RetryPolicy(1, 0.001, 0.01), + ) + try: + with pytest.raises(AllSourcesExhausted): + await client.chat([]) + attempts = [r for r in recorder.rows if r["source_name"]] + assert len(attempts) == 1 + assert attempts[0]["reasoning_effort"] == "auto" + assert attempts[0]["error"] + finally: + await client._transport.aclose() From a0a33c0c015675af1d44ee1d98f7da8bb7d07b66 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:35:14 -0400 Subject: [PATCH 09/17] test: guard custom reasoning roots at the transport boundary --- tests/unit/test_openai_compat.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 497b062..f54f796 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -1115,3 +1115,31 @@ class TestDefaultClientFactory: assert client.trust_env is trust_env finally: await client.aclose() + + +@pytest.mark.parametrize("key", ["depth.key", "off_control", "switch"]) +async def test_custom_profile_raw_roots_cannot_override_managed_intent(key): + registry = { + "custom": ProviderProfile( + name="custom", + thinking=ThinkingWire( + off={"off_control": False}, on_base={"switch": True}, effort_key="depth.key" + ), + strip_think_tags=False, + ) + } + sent = [] + transport = _transport_for( + lambda request: sent.append(request) or _sse_stream(_chunk(content="x")), registry=registry + ) + try: + with pytest.raises(RequestRejectedError, match="冲突"): + await _complete( + transport, + _source(provider="custom"), + reasoning_effort=Effort.HIGH, + overlay={key: True}, + ) + assert sent == [] + finally: + await transport.aclose() From c710c3a7ece6fce2add1a8200a7cf8ee8869ad5c Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:37:07 -0400 Subject: [PATCH 10/17] fix: explain explicit auto migration and verify probe cleanup --- src/polygateway/thinking.py | 7 +++++++ tests/unit/test_client.py | 24 ++++++++++++++++++++++++ tests/unit/test_thinking.py | 11 +++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index da8af1f..55f3837 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -696,6 +696,13 @@ def _tier_unsupported( if fallback == "nearest" or effort is Effort.AUTO else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest" ) + if effort is Effort.AUTO: + example = capability.cheapest_effort or Effort.NONE + hint = ( + ";请显式选择清单中的档位,例如 " + f"{{SCOPE}}__{{PROVIDER}}__{{N}}__REASONING_EFFORT={example.value}," + f"或调用时传 reasoning_effort=Effort.{example.name};库不会自动应用此选择" + ) return f"{head}{body}{hint}" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index af51c6b..e08e8b8 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1316,3 +1316,27 @@ class TestManagedReasoningAdmission: assert (await client._limiter_backend.source_stats(source.name)).inflight == 0 finally: await client._transport.aclose() + + +async def test_managed_conflict_returns_half_open_probe_and_permit(): + """本地拒绝没有上游响应,不计故障且必须归还半开探针。""" + from tests.contracts.conftest import FakeClock + + clock = FakeClock() + source = _source( + provider="openai", reasoning_effort="high", extra_body={"reasoning_effort": "high"} + ) + gate = InMemoryGate(config=BreakerConfig(1, 1, 10), now=clock) + entry = await gate.try_enter(source.name, "setup") + await gate.record_failure(entry, "source_dead", True) + clock.advance(2) + client = _client(sources=[source], breaker=gate) + try: + with pytest.raises(RequestRejectedError, match="冲突"): + await client.chat([]) + assert (await client._limiter_backend.source_stats(source.name)).inflight == 0 + next_entry = await gate.try_enter(source.name, "next") + assert next_entry.allowed and next_entry.is_probe + await gate.release_probe(next_entry) + finally: + await client._transport.aclose() diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index e3bb6bc..00c00cc 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -911,3 +911,14 @@ class TestThinkingRawOwnership: wire = ThinkingWire(off=None, on_base={"output_config": {"enabled": True}}, effort_key=None) with pytest.raises(ThinkingUnsupportedError): validate_thinking_raw(raw, effort=Effort.AUTO, wire=wire, origin="test") + + +def test_auto_rejection_explains_how_to_choose_explicitly(): + """可执行配置是指路,不由库自动应用其建议。""" + with pytest.raises(ThinkingUnsupportedError) as error: + resolve_thinking( + get_provider("minimax"), get_capability("MiniMax-M3"), Effort.AUTO, model="MiniMax-M3" + ) + assert "REASONING_EFFORT=" in str(error.value) + assert "reasoning_effort=Effort." in str(error.value) + assert "EFFORT_FALLBACK=nearest" not in str(error.value) From 16fa0ca4741014fc173518d66617b805fec79444 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 01:39:13 -0400 Subject: [PATCH 11/17] docs: record deterministic reasoning contract validation --- ...09-09-134-thinking-contracts-validation.md | 61 +++++++++++++++++++ research-wiki/graph/edges.json | 12 ++++ research-wiki/index.md | 5 +- research-wiki/log.md | 2 + .../2026-09-09-134-thinking-contracts.md | 16 +++-- 5 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md diff --git a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md new file mode 100644 index 0000000..ff73b9b --- /dev/null +++ b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md @@ -0,0 +1,61 @@ +--- +type: finding +node_id: finding:2026-09-09-134-thinking-contracts-validation +title: "1.3.4 T0–T4 与 T7 确定性验证" +date: 2026-09-09 +--- + +# 1.3.4 T0–T4/T7 实施验证 + +> 状态:本轮限定的生产契约与确定性测试已实现;不是整个版本验收。T5/T6/T8/T9 尚未执行。所有原始输出在 `tests/outputs/134/`,不提交。 + +## 基线与修改边界 + +| 项目 | 实际证据 | +| --- | --- | +| 起点 | `6a09054`,保留既有两个本地测试提交,工作区仅原 `.pi/` 与待提交设计/计划 | +| T0 静态 | `t0-check.log`/`.exit`:make check,退出 0,import-linter 1 kept | +| T0 指定测试 | `t0-baseline.log`/`.exit`:660 passed,退出 0 | +| 生产范围 | 只修改 thinking/providers/client/openai_compat 四文件;端口、类型、缓存指纹、遥测 schema、embedding/OCR 循环未改 | +| 文档回滚 | `2553fc7`/`dda5556`;wiki 工具 add_entity 会覆盖无 frontmatter 的同名文件,故先补原文 frontmatter,再以节点存在性保护调用工具,显式登记图节点/implements 边 | + +## 红绿证据 + +| 任务 | 红证据 | 绿证据 | +| --- | --- | --- | +| T1 AUTO 成员/MiniMax/未知告警 | `t1-red.log`:9 failed,103 passed;都是未拒绝/旧 medium/缺不保证文案 | `t1-green.log`:112 passed | +| T1 可执行迁移文案 | `t1-guidance-red.log`:1 failed,旧错误无配置例 | `t13-followup-green.log`:220 passed(含探针收尾) | +| T2 on_base 不偷带档 | `t2-wire-red.log`:12 failed,旧解析接受非法开启片段 | `t2-green.log`:137 passed | +| T2 raw 纯守卫 | 隔离 `raw-guard` 变异:28 failed,含嵌套根与所有档位 | 还原退出 0;详见 mutation-summary.json | +| T3 标准 raw 实际 HTTP | `t3-raw-red-valid.log`:21 failed,旧 transport 发出了冲突请求 | `t3-green.log`:526 passed(工厂/配置/transport/retry/纯解析) | +| T3 前置/准入 | `t3-entry-red.log`:4 failed,旧工厂构造后端/请求进入洋葱/冲突未拒绝 | 同上;额外半开探针测试证明可再次取得探针且 inflight=0 | +| T3 自定义根 | 隔离 `custom-guard` 变异:3 failed,丢 wire 后私有根绕过 | `t3-custom-green.log`:3 passed | +| T4 迁移 | `cache-isolation` 变异:去掉显式身份后能力/fallback/wire 各节点红 | `t4-final-green.log`:190 passed(缓存与遥测);新旧身份回滚、租户、多源和 per-call 覆盖均有断言 | +| T4 命中遥测 | `cache-row` 变异:1 failed,历史 low 不应替代本次 medium | 还原退出 0 | +| T7 客户端 NULL | embed False→True:6 failed;OCR False→True:12 failed(text/layout 独立红) | `t7-final-green.log`:328 passed | +| T7 阳性/emitter | chat True→False:并发实际档断言失败;去 applies 短路:6 failed | 各还原退出 0;chat 糖失败 auto、nearest 失败 medium/成功 low 另有真实链路断言 | + +隔离副本来源由 `mutation-import.log` 验证,路径为 `/tmp/pgw134-mutation-*`;无 `.env`、reference、`.pi/`。脚本 `mutate.py`、汇总 `mutation-summary.json`、逐例 `mutation-*-red.log/.exit` 和 `mutation-*-restored.log/.exit` 均留存。11 个变异全部退出 1,逐例还原全部退出 0,恢复后校验文件散列。factory 三个变异分别抓到 Authorization 缺失、timeout 退回 5 秒、trust_env 写死 True。 + +## 调试记录(不把无效红当证据) + +| 现象 | 根因与处理 | +| --- | --- | +| 初始 raw 红测试反而 21 passed | 测试选 qwen 纯开关形态却请求 HIGH,旧实现先因形态拒绝;改用可表达 HIGH 的 openai 后全部因目标未拒绝而红。`t3-raw-red.log` 不计红证据,使用 `t3-raw-red-valid.log` | +| 默认 HTTP factory 新测试 5 failed | 开发机 `socks://` 代理被 httpx 构造拒绝;测试 autouse 删除代理环境,仅隔离外部条件,不改生产 factory,仍分别验证 trust_env True/False。`t3-isolated-green.log`:207 passed | +| factory 失败随 T3 提交进入历史 | `8e61a66` 当时附带上述未隔离测试;随后 `71f1bdf` 独立修复。最终工作区全绿;不声称每个历史提交均全绿 | +| per-call 迁移工厂测试缺缓存参数 | 合成 env 仍为 cache_backend=none,loader 正确清空 namespace/ttl;改为 memory+显式 TTL,新测试 9 passed,不改生产配置默认 | +| conda run 默认捕获模式下 stdin 脚本未执行 | T0 第一次文档提交只有原始两文件;通过 --no-capture-output 重跑安全登记并单独提交,未将第一次零输出当登记成功 | +| pi-lens LSP 报缺 pytest/loguru、旧 StrEnum Literal 噪音、Python 3.12 语法不支持 | 非 conda 解释器限制;父监督明确批准记录并继续既定 conda pytest/ruff/import-linter,不改枚举/不加 ignore。后续异步 stale 测试报告已标 superseded,最终实际全量单测输出为准 | + +## 当前检查与后续门 + +| 检查 | 结果 | +| --- | --- | +| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | `last-unit.log/.exit`:**1241 passed,3.95 秒,退出 0** | +| `make check` | `last-check.log/.exit`:格式/ruff/import-linter通过,退出 0 | +| `git diff --check` | 通过 | +| 本轮网络/slow | 未执行;所有 HTTP 为 MockTransport,SQLite 为临时文件,无付费调用 | +| 独立 verifier/集成/slow/下游迁移 | 由父会话后续执行,本轮不声明通过;设计所列真实缺测和下游缺失仍有效 | + +日志方案沿已批设计:未知能力沿既有 loguru warning,实际调用仍经 TelemetryEmitter 单点出口,四类行来源和 NULL 契约用既有 schema 验证,不新增运行时数据面。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 0038b39..f509924 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -220,6 +220,11 @@ "id": "plan:2026-09-09-134-thinking-contracts", "label": "1.3.4 推理契约实施计划", "type": "plan" + }, + { + "id": "finding:2026-09-09-134-thinking-contracts-validation", + "label": "1.3.4 T0–T4 与 T7 确定性验证", + "type": "finding" } ], "links": [ @@ -446,6 +451,13 @@ "relation": "implements", "evidence": "已批准设计;T0基线660 passed、make check通过", "added": "2026-09-09T04:48:57.560089+00:00" + }, + { + "source": "plan:2026-09-09-134-thinking-contracts", + "target": "finding:2026-09-09-134-thinking-contracts-validation", + "relation": "tested_by", + "evidence": "T0–T4/T7:1241单测与11隔离变异;未覆盖live/集成/发布", + "added": "2026-09-09T05:39:12.170598+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 7a3c4a5..bca4254 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-09-09 04:48 UTC +> 自动生成,更新时间:2026-09-09 05:39 UTC ## design (42) - [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design` @@ -46,7 +46,8 @@ - [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` -## finding (14) +## finding (15) +- [1.3.4 T0–T4 与 T7 确定性验证](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation` - [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload` - [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance` - [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline` diff --git a/research-wiki/log.md b/research-wiki/log.md index 17161eb..ef3fce5 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -152,3 +152,5 @@ - [2026-09-05 04:07 UTC] 重建索引: 95 篇页面 - [2026-09-09 04:48 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --implements--> design:2026-09-09-134-thinking-contracts-design - [2026-09-09 04:48 UTC] 重建索引: 97 篇页面 +- [2026-09-09 05:39 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --tested_by--> finding:2026-09-09-134-thinking-contracts-validation +- [2026-09-09 05:39 UTC] 重建索引: 98 篇页面 diff --git a/research-wiki/plans/2026-09-09-134-thinking-contracts.md b/research-wiki/plans/2026-09-09-134-thinking-contracts.md index 3fae291..d11e32f 100644 --- a/research-wiki/plans/2026-09-09-134-thinking-contracts.md +++ b/research-wiki/plans/2026-09-09-134-thinking-contracts.md @@ -7,7 +7,7 @@ date: 2026-09-09 # 1.3.4 推理契约与测试证据实施计划 -> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),进入执行;尚未编码/执行验收**。 +> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),T0–T4、T7 已实现并通过确定性验证;T5/T6/T8/T9 待执行**。 > 设计:`research-wiki/designs/2026-09-09-134-thinking-contracts-design.md`,用户已正式批准。 > 目标:解决 #21 的受管推理语义漏洞、#25 的测试归因漏洞、#26 的客户端遥测守卫缺口,不扩展生产端口或遥测 schema。 > 方案:在既有推理决策层添加窄校验并接入工厂/默认 transport;测试侧独立保留请求与响应证据,按明确命题判定覆盖。缓存仍由下游显式迁移,生产治理循环不重写。 @@ -184,7 +184,7 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc **验证**:`conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py -q`;新拒绝和 wire 回归先红后绿,其余保留行为通过。若旧下游形态测试依赖 M3 True,需要在 T3 明确改为已批准迁移样本,不能暗改能力表让它绿。 -- [ ] 提交点:`fix: enforce registered auto reasoning capabilities`。 +- [x] 提交点:`fix: enforce registered auto reasoning capabilities`。 ### T2:纯 wire/raw 所有权校验 @@ -202,7 +202,7 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc **验证**:`conda run -n PolyGateway pytest tests/unit/test_thinking.py -q`,每类目标反例有有效红绿,保留行为绿。 -- [ ] 提交点:`fix: validate ownership of managed reasoning parameters`。 +- [x] 提交点:`fix: validate ownership of managed reasoning parameters`。 ### T3:接入工厂、请求入口和默认 transport @@ -225,7 +225,7 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc **验证**:`conda run -n PolyGateway pytest tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -q`,加 T1/T2 的测试一起跑;工厂/请求/全量注入拒绝均有旧实现红、新实现绿。 -- [ ] 提交点:`fix: reject conflicting raw reasoning overrides before sending`。 +- [x] 提交点:`fix: reject conflicting raw reasoning overrides before sending`。 ### T4:显式缓存迁移和四种遥测口径回归 @@ -247,7 +247,7 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc **验证**:`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_client.py tests/unit/test_telemetry.py -q`。两项新增能力/wire 迁移节点均置于 `tests/unit/test_cache.py::TestExplicitCacheMigration`,单跑 `conda run -n PolyGateway pytest tests/unit/test_cache.py::TestExplicitCacheMigration -q`;分别在隔离副本去掉其 namespace/salt 隔离输入,必须因没有新拒绝/新 wire 而红,恢复后绿,不能只以 nearest→error 的测试代替这两类。两客户端的源指纹必须断言相等,生产指纹算法一字不改。 -- [ ] 提交点:`test: pin explicit cache migration and reasoning row semantics`。 +- [x] 提交点:`test: pin explicit cache migration and reasoning row semantics`。 ### T5:有限测试归因和独立取证 @@ -307,7 +307,7 @@ chat 阳性走真实 RetryMW+emitter:True 糖失败 auto、显式请求失 **验证**:`conda run -n PolyGateway pytest tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py tests/unit/test_monkey_ocr.py tests/unit/test_retry.py -q`,再执行上表隔离变异;原实现绿、四类有效红、还原绿。 -- [ ] 提交点:`test: guard reasoning-free telemetry through real client paths`。 +- [x] 提交点:`test: guard reasoning-free telemetry through real client paths`。 ### T8:文档、日志登记与独立验证 @@ -375,3 +375,7 @@ chat 阳性走真实 RetryMW+emitter:True 糖失败 auto、显式请求失 自审已核对:生产守卫所有消费者在 §3 定义;新增测试文件有确定路径;conftest 当前不存在故明确新建;默认工厂不支持 transport 注入故使用已批准全量注入而非偷扩 API;缓存不改指纹;无从公共 model_reported 倒推上游身份;所有命令均在 conda 环境;未执行的测试不写为已通过。 计划审查由父会话组织,完成后直接实施,不新增人类计划审批门。执行中本文件任务勾选与 finding 保持实际状态一致;本次计划编写未运行 pytest、变异或真实模型调用。 + +## 本轮实施证据 + +T0–T4/T7 的命令、实际失败与修复、11 个隔离变异及 1241 项单测通过,见 `findings/2026-09-09-134-thinking-contracts-validation.md`。未执行 live、集成和发布,T5/T6 的测试支持文件未创建。 From 73008ad7d55fbcbd4b53027530fda06eae629d05 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 02:40:13 -0400 Subject: [PATCH 12/17] test: apply evidence-based live checks without hiding regressions --- tests/e2e/conftest.py | 496 +++++++++ tests/e2e/test_compat_projects.py | 155 +-- tests/e2e/test_embed_probe.py | 142 +-- tests/e2e/test_smoke_gateway.py | 173 ++-- tests/e2e/test_thinking_live.py | 1553 ++++++++--------------------- tests/live_evidence.py | 308 ++++++ tests/unit/test_client.py | 22 + tests/unit/test_config.py | 44 + tests/unit/test_live_evidence.py | 816 +++++++++++++++ 9 files changed, 2350 insertions(+), 1359 deletions(-) create mode 100644 tests/e2e/conftest.py create mode 100644 tests/live_evidence.py create mode 100644 tests/unit/test_live_evidence.py diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..eef1582 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,496 @@ +"""测试侧独立 HTTP 取证装配;无环境自读取或成功 SSE 预读。""" + +from collections.abc import AsyncIterator, Iterator, Mapping +from contextlib import AsyncExitStack, asynccontextmanager, contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import httpx +import pytest + +from polygateway import GatewayClient, GatewaySettings +from polygateway.client import ( + _aclose_component, + _build_breaker, + _build_limiter, + _build_selector, + _build_structured, +) +from polygateway.providers import get_provider +from polygateway.transports.openai_compat import OpenAICompatTransport +from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult +from tests.live_evidence import ( + AttemptEvidence, + HttpEvidence, + LiveVerdict, + assess_model_identity, + classify_live_failure, + messages_digest, + request_is_valid, + safe_attempts, + strict_json, + write_live_round, +) + + +@dataclass +class _Exchange: + """仅在 attempt 生命周期持有原始响应引用。""" + + request: httpx.Request + checks: tuple[tuple[str, bool], ...] + response: httpx.Response | None = None + + +@dataclass +class _Attempt: + """任务内可变收集器,结束时转换为冻结快照。""" + + call_id: str + exchanges: list[_Exchange] = field(default_factory=list) + + +class LiveCapture: + """矩阵显式预期与按逻辑轮次关联的独立证据。""" + + def __init__(self, *, expectations: Mapping[str, Mapping[str, Any]]) -> None: + """预期缺项即配置错误,不从实发 payload 补齐。""" + for expected in expectations.values(): + required = {"model", "origin", "path", "control", "messages_digest"} + if not required <= expected.keys() or ("stream" in expected) == ( + "input_shape" in expected + ): + raise ValueError("取证矩阵缺少必需预期或混用 chat/embed") + if not isinstance(expected["control"], dict): + raise ValueError("control 必须是显式对象") + self._expectations = {name: dict(value) for name, value in expectations.items()} + self._round: ContextVar[tuple[str, str]] = ContextVar("live_round") + self._attempt: ContextVar[_Attempt] = ContextVar("live_attempt") + self._records: dict[tuple[str, str], list[AttemptEvidence]] = {} + self._owners: dict[str, tuple[str, str]] = {} + self._notes: dict[tuple[str, str], list[str]] = {} + + @contextmanager + def round_context(self, *, session_id: str, parent_call_id: str) -> Iterator[None]: + """外围逻辑轮次绑定,异常和取消均复位。""" + key = session_id, parent_call_id + token = self._round.set(key) + self._records.setdefault(key, []) + self._notes.setdefault(key, []) + try: + yield + finally: + self._round.reset(token) + + @contextmanager + def attempt_context(self, call_id: str) -> Iterator[None]: + """零 HTTP 尝试也有快照;重复/跨轮 UUID 是契约错误。""" + key = self._round.get() + if call_id in self._owners: + raise ValueError("重复或跨轮 call_id") + self._owners[call_id] = key + attempt = _Attempt(call_id) + token = self._attempt.set(attempt) + error = None + try: + yield + except Exception as exc: + error = exc + raise + finally: + try: + events = tuple( + self._snapshot(exchange, call_id, key) for exchange in attempt.exchanges + ) + self._records[key].append(AttemptEvidence(call_id, events, error)) + finally: + self._attempt.reset(token) + + def _snapshot(self, exchange: _Exchange, call_id: str, key: tuple[str, str]) -> HttpEvidence: + """complete 结束后只读已缓冲内容,拒绝截断和歧义身份。""" + response = exchange.response + identity: tuple[bool, str | None] = (False, None) + body = None + status = response.status_code if response is not None else 0 + if response is None: + self._notes[key].append("无可配对响应") + else: + try: + content = response.content + except httpx.ResponseNotRead: + self._notes[key].append("响应未缓冲,证据不足") + else: + if status >= 400: + if len(content) <= 65536: + body = content + else: + self._notes[key].append("错误体超过 64 KiB,不接受截断证据") + elif 200 <= status < 300 and dict(exchange.checks).get("stream") is not None: + try: + payload = strict_json(exchange.request.content) + except (ValueError, UnicodeError): + payload = {} + if isinstance(payload, dict) and payload.get("stream") is False: + try: + data = strict_json(content) + if not isinstance(data, dict): + raise ValueError("原始 JSON 非对象") + model = data.get("model") + if model is not None and not isinstance(model, str): + raise ValueError("原始 model 类型非法") + identity = (True, model) + except (ValueError, UnicodeError): + self._notes[key].append("原始 JSON 身份无法独立解析") + return HttpEvidence(call_id, exchange.checks, status, body, identity) + + def client_factory(self, source: SourceConfig) -> httpx.AsyncClient: + """鉴权仅内存比较;沿已校验源 timeout/trust_env。""" + expected = self._expectations[source.name] + + async def request_hook(request: httpx.Request) -> None: + """校验实发请求而不修正它。""" + attempt = self._attempt.get() + try: + payload = strict_json(request.content) + except (ValueError, UnicodeError): + payload = {} + if not isinstance(payload, dict): + payload = {} + url = request.url + origin = str( + url.copy_with(path="", query=None, fragment=None, username=None, password=None) + ).rstrip("/") + # control 是本轮完整附加字段;基础键以外均比较,漏/多键都失败。 + basic = {"model", "messages", "stream", "stream_options", "input"} + control = {k: v for k, v in payload.items() if k not in basic} + checks = { + "method": request.method == "POST", + "origin": origin == expected["origin"] + and not url.username + and not url.password + and not url.query, + "path": url.path == expected["path"], + "model": payload.get("model") == expected["model"], + "authorization": request.headers.get("Authorization") == f"Bearer {source.api_key}", + "control": control == expected["control"], + "messages_digest": messages_digest(payload.get("messages", payload.get("input"))) + == expected["messages_digest"], + } + if "stream" in expected: + checks["stream"] = payload.get("stream") is expected["stream"] and ( + payload.get("stream_options") == {"include_usage": True} + if expected["stream"] + else "stream_options" not in payload + ) + else: + texts = payload.get("input") + checks["input_shape"] = ( + isinstance(texts, list) + and all(isinstance(text, str) for text in texts) + and len(texts) == expected["input_shape"] + ) + attempt.exchanges.append(_Exchange(request, tuple(checks.items()))) + + async def response_hook(response: httpx.Response) -> None: + """只持有引用,绝不提前读取成功 SSE。""" + attempt = self._attempt.get() + matching = [event for event in attempt.exchanges if event.request is response.request] + if len(matching) != 1 or matching[0].response is not None: + raise ValueError("响应无法唯一配对") + matching[0].response = response + + return httpx.AsyncClient( + headers={"Authorization": f"Bearer {source.api_key}"}, + timeout=source.timeout_s, + trust_env=source.trust_env, + event_hooks={"request": [request_hook], "response": [response_hook]}, + ) + + def attempts(self, *, session_id: str, parent_call_id: str) -> tuple[AttemptEvidence, ...]: + """按逻辑轮次返回不可变快照。""" + return tuple(self._records.get((session_id, parent_call_id), ())) + + def notes(self, *, session_id: str, parent_call_id: str) -> tuple[str, ...]: + """只含固定安全原因,不包含响应正文。""" + return tuple(self._notes.get((session_id, parent_call_id), ())) + + def raw_identity( + self, *, session_id: str, parent_call_id: str, call_id: str + ) -> tuple[bool, str | None]: + """精确取最终成功 attempt,不猜本轮最后一条响应。""" + key = session_id, parent_call_id + if call_id in self._owners and self._owners[call_id] != key: + raise ValueError("跨轮 call_id 身份查询") + matches = [attempt for attempt in self._records.get(key, []) if attempt.call_id == call_id] + if len(matches) > 1: + raise ValueError("重复 call_id 身份查询") + if not matches: + return False, None + events = [event for event in matches[0].http if 200 <= event.status_code < 300] + if len(events) > 1: + raise ValueError("多个成功 HTTP 身份候选") + return events[0].raw_identity if events else (False, None) + + +class ObservedTransport: + """原样委托同一个真实 transport,无重试、payload 修正或异常翻译。""" + + def __init__(self, transport: OpenAICompatTransport, capture: LiveCapture) -> None: + """资源所有权留给装配者。""" + self._transport = transport + self._capture = capture + + async def complete( + self, + *, + messages: list[dict[str, Any]], + source: SourceConfig, + stream: bool, + overlay: dict[str, Any], + call_id: str, + reasoning_effort: Effort | None, + ) -> TransportResult: + """与生产端口逐参数同签名。""" + with self._capture.attempt_context(call_id): + return await self._transport.complete( + messages=messages, + source=source, + stream=stream, + overlay=overlay, + call_id=call_id, + reasoning_effort=reasoning_effort, + ) + + async def embed( + self, *, texts: list[str], source: SourceConfig, call_id: str + ) -> EmbeddingTransportResult: + """embedding 使用同一取证关联,不套 chat 推理判据。""" + with self._capture.attempt_context(call_id): + return await self._transport.embed(texts=texts, source=source, call_id=call_id) + + +@asynccontextmanager +async def observed_client( + settings: GatewaySettings, capture: LiveCapture, *, capabilities=None +) -> AsyncIterator[GatewayClient]: + """全量注入复用生产装配函数;自建组件显式关闭,不启用响应缓存。""" + async with AsyncExitStack() as stack: + sources = list(settings.sources) + limiter = _build_limiter(settings, sources) + stack.push_async_callback(_aclose_component, limiter) + breaker = _build_breaker(settings) + stack.push_async_callback(_aclose_component, breaker) + real = OpenAICompatTransport( + client_factory=capture.client_factory, capabilities=capabilities + ) + stack.push_async_callback(real.aclose) + strategy, escalation = _build_structured( + [get_provider(source.provider) for source in sources] + ) + client = GatewayClient( + scope=settings.scope, + sources=sources, + selector=_build_selector(settings.selector), + limiter=limiter, + breaker=breaker, + transport=ObservedTransport(real, capture), + retry=settings.retry, + backpressure=settings.backpressure, + quota_full=settings.quota_full, + circuit_open=settings.circuit_open, + structured_strategy=strategy, + structured_escalation=escalation, + structured_max_retries=settings.structured_max_retries, + ) + stack.push_async_callback(client.aclose) + yield client + + +def chat_expectations( + settings: GatewaySettings, + *, + messages: list[dict[str, Any]], + stream: bool, + controls: Mapping[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + """URL 从源配置声明,控制片段必须由矩阵独立给出。""" + result = {} + for source in settings.sources: + url = httpx.URL(source.base_url) + result[source.name] = { + "model": source.model, + "origin": str( + url.copy_with(path="", query=None, fragment=None, username=None, password=None) + ).rstrip("/"), + "path": url.path.rstrip("/") + "/chat/completions", + "stream": stream, + "control": controls[source.name], + "messages_digest": messages_digest(messages), + } + return result + + +def enforce_verdict(verdict: LiveVerdict) -> None: + """仅在报告已写入后调用;默认失败,不打印上游异常正文。""" + if verdict.status == "UNCOVERED": + pytest.skip(verdict.reason) + assert verdict.status == "PASS", verdict.reason + + +async def captured_chat_round( + client: GatewayClient, + capture: LiveCapture, + *, + run_id: str, + matrix_id: str, + round_index: int, + output_dir: Path, + messages: list[dict[str, Any]], + models: Mapping[str, str], + aliases: Mapping[str, frozenset[str]], + validate=None, + providers: Mapping[str, str] | None = None, + source_efforts: Mapping[str, Effort | None] | None = None, + **kwargs: Any, +) -> tuple[Any, LiveVerdict]: + """请求、身份与行为断言均先记逐轮证据;异常不漏轮。""" + parent = uuid4().hex + response = None + error = None + verdict = LiveVerdict("FAIL", "轮次未完成") + with capture.round_context(session_id=run_id, parent_call_id=parent): + try: + response = await client.chat( + messages, session_id=run_id, parent_call_id=parent, **kwargs + ) + attempts = capture.attempts(session_id=run_id, parent_call_id=parent) + events = [event for attempt in attempts for event in attempt.http] + successful = [attempt for attempt in attempts if attempt.call_id == response.call_id] + success_paired = ( + len(successful) == 1 + and successful[0].error is None + and len(successful[0].http) == 1 + and 200 <= successful[0].http[0].status_code < 300 + ) + verdict = assess_model_identity( + requested=models[response.source_name], + aliases=aliases.get(models[response.source_name], frozenset()), + reported=response.model_reported, + raw_identity=capture.raw_identity( + session_id=run_id, parent_call_id=parent, call_id=response.call_id + ), + request_valid=success_paired + and bool(events) + and all(request_is_valid(event) for event in events), + ) + if verdict.status == "PASS" and validate is not None: + validate(response) + except Exception as exc: + error = exc + verdict = classify_live_failure( + exc, capture.attempts(session_id=run_id, parent_call_id=parent) + ) + finally: + attempts = capture.attempts(session_id=run_id, parent_call_id=parent) + # 上游 model 可能回显提示词;仅输出允许集合内的名字,其他统一省略。 + allowed = set(models.values()) | { + alias for values in aliases.values() for alias in values + } + write_live_round( + output_dir, + run_id=run_id, + matrix_id=matrix_id, + round_index=round_index, + safe_fields={ + "session_id": run_id, + "parent_call_id": parent, + "requested_model": list(models.values()), + "provider": list(providers.values()) if providers is not None else None, + "attempts": safe_attempts(attempts), + "status": verdict.status, + "reason": verdict.reason, + "stream": kwargs.get("stream", True), + "requested_effort": kwargs.get("reasoning_effort") + or (list(source_efforts.values()) if source_efforts is not None else None), + "completed_rounds": 1, + "prompt_tokens": response.prompt_tokens if response else None, + "completion_tokens": response.completion_tokens if response else None, + "reasoning_tokens": response.reasoning_tokens if response else None, + "thinking_chars": len(response.thinking) if response else None, + "evidence_notes": ( + "原始异常/正文摘要省略以避免回显泄露", + *capture.notes(session_id=run_id, parent_call_id=parent), + ), + "reported_model": response.model_reported + if response and response.model_reported in allowed + else None, + "applied_effort": response.applied_effort if response else None, + "thinking_observation": response.thinking_observation if response else None, + "error_type": type(error).__name__ if error else None, + "error_status": getattr(error, "status_code", None), + }, + ) + return response, verdict + + +def declared_control(provider: str, effort: Effort | None) -> dict[str, Any]: + """测试矩阵的独立 wire 声明;不调用 resolver 或生产 payload 构造器。""" + if effort is None: + return {} + if provider == "qwen": + if effort not in (Effort.AUTO, Effort.NONE): + raise ValueError("测试矩阵未声明 qwen 强度映射") + return {"enable_thinking": effort is not Effort.NONE} + if provider in {"deepseek", "zhipu", "moonshot"}: + result: dict[str, Any] = { + "thinking": {"type": "disabled" if effort is Effort.NONE else "enabled"} + } + if effort not in (Effort.AUTO, Effort.NONE): + result["reasoning_effort"] = effort.value + return result + if provider not in {"minimax", "openai", "anthropic", "google"}: + raise ValueError("测试矩阵没有该 provider 的控制声明") + return {} if effort is Effort.AUTO else {"reasoning_effort": effort.value} + + +def source_controls(settings: GatewaySettings) -> dict[str, dict[str, Any]]: + """源级矩阵预期独立表达;受管 raw 冲突由生产路径拒绝。""" + result = {} + for source in settings.sources: + effort = source.reasoning_effort + if effort is None and source.enable_thinking is not None: + effort = Effort.AUTO if source.enable_thinking else Effort.NONE + control = declared_control(source.provider, effort) + if effort is None: + control.update(source.extra_body) + else: + # 不用 update 覆盖控制声明,否则会掩盖所有权回归。 + for key, value in source.extra_body.items(): + if key in control: + raise ValueError("取证矩阵有双来源控制") + control[key] = value + result[source.name] = control + return result + + +# pytest 用例终态补充网络前缺配置、装配失败及未完成轮次;不代替逐轮报告。 +@pytest.hookimpl(wrapper=True) +def pytest_runtest_makereport(item, call): + """仅记录安全矩阵标识和阶段结果,不序列化 pytest 异常长文本。""" + report = yield + if report.skipped or report.failed: + write_live_round( + Path("tests/outputs/134/live"), + run_id=uuid4().hex, + matrix_id="pytest-" + messages_digest(item.nodeid)[:16], + round_index=0, + safe_fields={ + "status": "UNCOVERED" if report.skipped else "FAIL", + "reason": "用例阶段未覆盖或失败;详情按逐轮安全证据核验,不能视为能力通过", + "evidence_notes": [report.when], + }, + ) + return report diff --git a/tests/e2e/test_compat_projects.py b/tests/e2e/test_compat_projects.py index 234d6e6..af31bc7 100644 --- a/tests/e2e/test_compat_projects.py +++ b/tests/e2e/test_compat_projects.py @@ -1,98 +1,119 @@ -"""GovDoc 与 Video-Tree 最小接入冒烟(2026-07-20 拍板: 两个项目都做)。 - -复刻两项目的真实调用点形态,对真实网关跑一次治理调用,证明"调用点零改动 -迁移"成立;并验证 VT 现有平铺键名(LLM_TIMEOUT 等)可直接装配。 -reference/ 只读——本文件只 import 其 Protocol,绝不修改。 -""" +"""历史接入调用形态的真实冒烟;不能替代缺失下游的现行配置验收。""" import os import sys from pathlib import Path +from uuid import uuid4 import pytest from dotenv import dotenv_values -from polygateway import GatewayClient +from polygateway import Effort, GatewayClient, GatewaySettings +from tests.e2e.conftest import ( + LiveCapture, + captured_chat_round, + chat_expectations, + enforce_verdict, + observed_client, + source_controls, +) +from tests.live_evidence import write_live_round _REPO = Path(__file__).resolve().parents[2] _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) - -# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除, -# 显式 `pytest -m slow` 运行)。理由是这些用例的成败取决于网关此刻快不快,而 -# pre-commit 关卡跑全套件——网关一抖就挡住与之无关的提交,久了会把"测试红了 -# 先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉。发版清单负责让它们真跑。 +_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV) pytestmark = [ pytest.mark.slow, - pytest.mark.skipif( - not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*" - ), + pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"), ] +_OUT = Path("tests/outputs/134/live") -@pytest.fixture -async def client(): - c = GatewayClient.from_env("LLM", env=_ENV) - yield c - await c.aclose() +async def _call_shape(matrix, **kwargs): + """session/parent 总由逐轮 UUID 传入;保留 cache_salt 调用形态。""" + settings = GatewaySettings.from_env("LLM", env=_ENV) + messages = [{"role": "user", "content": "Reply with exactly: compatibility-ok"}] + capture = LiveCapture( + expectations=chat_expectations( + settings, messages=messages, stream=True, controls=source_controls(settings) + ) + ) + + def validate(response): + assert response.content.strip() and response.call_id + + async with observed_client(settings, capture) as client: + _, verdict = await captured_chat_round( + client, + capture, + run_id=uuid4().hex, + matrix_id=matrix, + round_index=1, + output_dir=_OUT, + messages=messages, + models={s.name: s.model for s in settings.sources}, + providers={s.name: s.provider for s in settings.sources}, + source_efforts={ + s.name: s.reasoning_effort + if s.reasoning_effort is not None + else (Effort.AUTO if s.enable_thinking else Effort.NONE) + if s.enable_thinking is not None + else None + for s in settings.sources + }, + aliases={}, + validate=validate, + **kwargs, + ) + enforce_verdict(verdict) class TestGovDocOnboarding: - """GovDoc agent/loop.py:377 调用形态: session_id + parent_call_id。""" + """历史 session_id+parent_call_id 调用点契约。""" - async def test_call_site_shape_runs_governed(self, client): - response = await client.chat( - [{"role": "user", "content": "Reply with exactly: govdoc-ok"}], - session_id="govdoc-e2e", - parent_call_id="step-1", - ) - assert response.content.strip() - assert response.call_id # GovernedLLMClient 契约字段全在 + async def test_call_site_shape_runs_governed(self): + await _call_shape("compat-parent") - async def test_structural_protocol_match(self, client): + async def test_structural_protocol_match(self): + """外部 Protocol 缺包单列未覆盖;合成契约另在 unit 跑。""" + run_id = uuid4().hex sys.path.insert(0, str(_REPO / "reference/GovDoc-SaaS/packages/docagent-core/src")) try: from docagent_core.protocols import LLMProvider except ImportError: - pytest.skip("GovDoc protocols 依赖不可导入(结构断言已由单测兜底覆盖)") + write_live_round( + _OUT, + run_id=run_id, + matrix_id="external-protocol", + round_index=0, + safe_fields={ + "status": "UNCOVERED", + "reason": "外部 Protocol 包缺失;未验证真实下游", + }, + ) + pytest.skip("外部 Protocol 包缺失,未覆盖") finally: sys.path.pop(0) - assert isinstance(client, LLMProvider) + status = "FAIL" + client = None + try: + client = GatewayClient.from_env("LLM", env=_ENV) + assert isinstance(client, LLMProvider) + status = "PASS" + finally: + if client is not None: + await client.aclose() + write_live_round( + _OUT, + run_id=run_id, + matrix_id="external-protocol", + round_index=0, + safe_fields={"status": status, "reason": "外部 Protocol 结构契约,不是模型能力"}, + ) class TestVideoTreeOnboarding: - """VT loop.py:336 调用形态: session_id + cache_salt(跨 epoch 重采样)。""" + """历史 cache_salt 调用点契约,平铺键装配已移至 unit。""" - async def test_call_site_shape_with_cache_salt(self, client): - response = await client.chat( - [{"role": "user", "content": "Reply with exactly: vt-ok"}], - session_id="vt-e2e", - cache_salt="epoch-1", - ) - assert response.content.strip() - - async def test_flat_legacy_keys_assemble(self): - """VT 现有键名(LLM_TIMEOUT/LLM_MAX_RETRIES 等)零改名装配成功。""" - source_keys = {k: v for k, v in _ENV.items() if k.split("__")[0] == "LLM" and "__" in k} - flat_env = { - **source_keys, - # 与 .env 的 LLM__MINIMAX__1__TIMEOUT_S 同值。取 120(VT 旧值)会让本用例的 - # 超时比生产配置还紧一半,在慢网关上必然间歇红——而本用例断言的是平铺 - # 键名能否解析成 SourceConfig.timeout_s,超时取值本身不是被测对象 - "LLM_TIMEOUT": "300", - "LLM_MAX_RETRIES": "3", - "LLM_RETRY_BASE_DELAY": "2.0", - "LLM_RETRY_MAX_DELAY": "30.0", - "LLM_CIRCUIT_BREAKER_THRESHOLD": "5", - "LLM_CIRCUIT_BREAKER_COOLDOWN": "60", - "LLM_TTFT_TIMEOUT": "30", - "LLM_INTER_TOKEN_TIMEOUT": "15", - "PGW_CACHE_BACKEND": "none", - "PGW_TELEMETRY_BACKEND": "none", - } - client = GatewayClient.from_env("LLM", env=flat_env) - try: - resp = await client.chat([{"role": "user", "content": "Reply: flat-ok"}]) - assert resp.content.strip() - finally: - await client.aclose() + async def test_call_site_shape_with_cache_salt(self): + await _call_shape("compat-salt", cache_salt="epoch-1") diff --git a/tests/e2e/test_embed_probe.py b/tests/e2e/test_embed_probe.py index 90d018c..7f8662b 100644 --- a/tests/e2e/test_embed_probe.py +++ b/tests/e2e/test_embed_probe.py @@ -1,89 +1,91 @@ -"""真实网关 /embeddings 端点探测(M2 设计 §11.6;人类默认口径: 实现时探测)。 - -对 .env 的 LLM 源网关发一次真实 embeddings 请求: 支持则记录向量证据, -不支持(404/翻译为领域错误)则 skip 并把响应记录进 tests/outputs/ -(降级证据)。无 EMBED scope 配置时复用 LLM 源的 base_url/api_key。 -""" - -from __future__ import annotations +"""真实 embedding 探测;404 仅证明请求型号不可用,不外推端点能力。""" import dataclasses import os -from datetime import datetime from pathlib import Path +from uuid import uuid4 +import httpx import pytest from dotenv import dotenv_values -from polygateway.errors import PolyGatewayError +from polygateway import GatewaySettings from polygateway.transports.openai_compat import OpenAICompatTransport -from polygateway.types import SourceConfig +from tests.e2e.conftest import LiveCapture, ObservedTransport, enforce_verdict +from tests.live_evidence import ( + LiveVerdict, + classify_live_failure, + messages_digest, + request_is_valid, + safe_attempts, + write_live_round, +) _ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} - -# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除, -# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。 pytestmark = [ pytest.mark.slow, - pytest.mark.skipif( - "LLM__MINIMAX__1__BASE_URL" not in _ENV, - reason="缺真实网关配置(.env)", - ), + pytest.mark.skipif("LLM__MINIMAX__1__BASE_URL" not in _ENV, reason="缺少矩阵必需配置,未覆盖"), ] -_OUT = Path("tests/outputs/embedding") - - -def _record(name: str, lines: list[str]) -> Path: - _OUT.mkdir(parents=True, exist_ok=True) - path = _OUT / f"{name}_{datetime.now():%Y%m%d_%H%M%S}.md" - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - return path - async def test_probe_real_gateway_embeddings(): - source = SourceConfig( - name="probe_1", - provider="minimax", - base_url=_ENV["LLM__MINIMAX__1__BASE_URL"], - api_key=_ENV["LLM__MINIMAX__1__API_KEY"], - model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1"), - timeout_s=30.0, - est_tokens=8, + """沿已校验源的 timeout/trust_env,所有路径 finally 关闭。""" + settings = GatewaySettings.from_env("LLM", env=_ENV) + configured = next(s for s in settings.sources if s.name == "minimax_1") + source = dataclasses.replace( + configured, model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1") ) - transport = OpenAICompatTransport() + texts = ["polygateway embedding probe"] + url = httpx.URL(source.base_url) + capture = LiveCapture( + expectations={ + source.name: { + "model": source.model, + "origin": str(url.copy_with(path="", query=None)).rstrip("/"), + "path": url.path.rstrip("/") + "/embeddings", + "input_shape": 1, + "control": {}, + "messages_digest": messages_digest(texts), + } + } + ) + real = OpenAICompatTransport(client_factory=capture.client_factory) + transport = ObservedTransport(real, capture) + run_id, parent, call_id = uuid4().hex, uuid4().hex, uuid4().hex + verdict = LiveVerdict("FAIL", "轮次未完成") try: - result = await transport.embed( - texts=["polygateway embedding probe"], source=source, call_id="probe" - ) - except PolyGatewayError as exc: - path = _record( - "probe_unsupported", - [ - "# Embedding 端点探测: 网关不支持", - f"- base_url: {source.base_url}", - f"- model: {source.model}", - f"- 错误分类: {type(exc).__name__}", - f"- status_code: {exc.status_code}", - f"- 详情: {exc}", - "", - "结论: e2e 按设计 §11.6 降级,embedding 行为由 unit 全覆盖。", - ], - ) - await transport.aclose() - pytest.skip(f"网关不支持 embeddings({type(exc).__name__}),证据: {path}") - else: - await transport.aclose() - assert result.dim > 0 and len(result.vectors) == 1 - _record( - "probe_supported", - [ - "# Embedding 端点探测: 网关支持", - f"- base_url: {source.base_url}", - f"- model: {source.model}", - f"- dim: {result.dim}", - f"- usage: {result.prompt_tokens}({result.usage_source})", - f"- 向量前 5 维: {result.vectors[0][:5]}", - f"- raw: {dataclasses.asdict(result)['raw']}", - ], - ) + with capture.round_context(session_id=run_id, parent_call_id=parent): + try: + result = await transport.embed(texts=texts, source=source, call_id=call_id) + events = [ + e + for a in capture.attempts(session_id=run_id, parent_call_id=parent) + for e in a.http + ] + assert len(events) == 1 and request_is_valid(events[0]) + assert result.dim > 0 and len(result.vectors) == 1 + verdict = LiveVerdict("PASS", "向量形状与实发请求合格") + except Exception as error: + verdict = classify_live_failure( + error, capture.attempts(session_id=run_id, parent_call_id=parent) + ) + finally: + write_live_round( + Path("tests/outputs/134/live"), + run_id=run_id, + matrix_id="embedding", + round_index=1, + safe_fields={ + "status": verdict.status, + "reason": verdict.reason, + "session_id": run_id, + "parent_call_id": parent, + "attempts": safe_attempts( + capture.attempts(session_id=run_id, parent_call_id=parent) + ), + "evidence_notes": capture.notes(session_id=run_id, parent_call_id=parent), + }, + ) + finally: + await real.aclose() + enforce_verdict(verdict) diff --git a/tests/e2e/test_smoke_gateway.py b/tests/e2e/test_smoke_gateway.py index e318c85..208ad20 100644 --- a/tests/e2e/test_smoke_gateway.py +++ b/tests/e2e/test_smoke_gateway.py @@ -1,123 +1,110 @@ -"""真实网关端到端冒烟(M1 验收第 7 步)。 +"""真实网关冒烟:逐轮独立取证,行为断言失败也必须留档。""" -前置: `.env` 配置至少一个 `LLM__{PROVIDER}__1__*` 真实源 + 韧性键。 -缺配置时 skip(验收前必须真跑)。输出结构化 Markdown 落 -`tests/outputs/e2e/`(CLAUDE.md §4.6,不提交 git)。 -""" - -import json import os -from datetime import datetime from pathlib import Path +from uuid import uuid4 import pytest from dotenv import dotenv_values from pydantic import BaseModel -from polygateway import GatewayClient +from polygateway import Effort, GatewaySettings +from tests.e2e.conftest import ( + LiveCapture, + captured_chat_round, + chat_expectations, + enforce_verdict, + observed_client, + source_controls, +) _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) - -# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除, -# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。 +_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV) pytestmark = [ pytest.mark.slow, - pytest.mark.skipif( - not _HAS_SOURCE, - reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)", - ), + pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"), ] -_OUT_DIR = Path("tests/outputs/e2e") - class MiniAnswer(BaseModel): + """最小结构化响应契约。""" + answer: int reason: str -def _report(name: str, sections: list[tuple[str, str]]) -> Path: - _OUT_DIR.mkdir(parents=True, exist_ok=True) - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - path = _OUT_DIR / f"{name}_{ts}.md" - body = [f"# e2e 冒烟: {name}", ""] - for title, content in sections: - body += [f"## {title}", "", "```", content, "```", ""] - path.write_text("\n".join(body), encoding="utf-8") - return path - - -@pytest.fixture -async def client(): - c = GatewayClient.from_env("LLM", env=_ENV) - yield c - await c.aclose() +async def _smoke(matrix, prompt, validate, *, stream=True, structured=None): + """全量注入仅替换取证装配,仍调用生产结构化策略。""" + settings = GatewaySettings.from_env("LLM", env=_ENV) + messages = [{"role": "user", "content": prompt}] + controls = source_controls(settings) + capture = LiveCapture( + expectations=chat_expectations( + settings, messages=messages, stream=stream, controls=controls + ) + ) + async with observed_client(settings, capture) as client: + _, verdict = await captured_chat_round( + client, + capture, + run_id=uuid4().hex, + matrix_id=matrix, + round_index=1, + output_dir=Path("tests/outputs/134/live"), + messages=messages, + models={s.name: s.model for s in settings.sources}, + providers={s.name: s.provider for s in settings.sources}, + source_efforts={ + s.name: s.reasoning_effort + if s.reasoning_effort is not None + else (Effort.AUTO if s.enable_thinking else Effort.NONE) + if s.enable_thinking is not None + else None + for s in settings.sources + }, + aliases={}, + validate=validate, + stream=stream, + structured=structured, + ) + enforce_verdict(verdict) class TestRealGatewaySmoke: - async def test_stream_chat(self, client): - resp = await client.chat( - [{"role": "user", "content": "Reply with exactly: pong"}], session_id="e2e-smoke" - ) - path = _report( - "stream_chat", - [ - ("响应", resp.content), - ( - "元数据", - json.dumps( - { - "model": resp.model, - "source": resp.source_name, - "usage_source": resp.usage_source, - "prompt_tokens": resp.prompt_tokens, - "completion_tokens": resp.completion_tokens, - "latency_ms": resp.latency_ms, - "ttft_ms": resp.ttft_ms, - }, - ensure_ascii=False, - indent=2, - ), - ), - ], - ) - assert resp.content.strip() - assert resp.ttft_ms is not None and resp.latency_ms > 0 - print(f"输出: {path}") + """保留流/非流和结构化真实行为断言。""" - async def test_non_stream_fast_path(self, client): - resp = await client.chat( - [{"role": "user", "content": "Reply with exactly: pong"}], stream=False - ) - _report("non_stream", [("响应", resp.content)]) - assert resp.content.strip() and resp.ttft_ms is None + async def test_stream_chat(self): + def validate(response): + assert response.content.strip() + assert response.ttft_ms is not None and response.latency_ms > 0 - async def test_structured_json_tier(self, client): - resp = await client.chat( - [{"role": "user", "content": 'Reply ONLY with JSON: {"ok": true}'}], + await _smoke("smoke-stream", "Reply with exactly: pong", validate) + + async def test_non_stream_fast_path(self): + def validate(response): + assert response.content.strip() and response.ttft_ms is None + + await _smoke("smoke-json", "Reply with exactly: pong", validate, stream=False) + + async def test_structured_json_tier(self): + def validate(response): + assert isinstance(response.structured_data, dict | list) + + await _smoke( + "smoke-structured-json", + 'Reply ONLY with JSON: {"ok": true}', + validate, structured="json", ) - _report("structured_json", [("解析产物", repr(resp.structured_data))]) - assert isinstance(resp.structured_data, dict | list) - async def test_structured_model_ladder(self, client): - resp = await client.chat( - [ - { - "role": "user", - "content": "What is 2+3? Reply ONLY with JSON matching " - '{"answer": , "reason": }', - } - ], + async def test_structured_model_ladder(self): + def validate(response): + assert isinstance(response.structured_data, MiniAnswer) + assert response.structured_data.answer == 5 + + await _smoke( + "smoke-structured-model", + 'What is 2+3? Reply ONLY with JSON matching {"answer": , "reason": }', + validate, structured=MiniAnswer, ) - _report( - "structured_model", - [ - ("原始响应", resp.content), - ("校验产物", resp.structured_data.model_dump_json()), - ], - ) - assert isinstance(resp.structured_data, MiniAnswer) - assert resp.structured_data.answer == 5 diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py index 054e7a6..6a24c42 100644 --- a/tests/e2e/test_thinking_live.py +++ b/tests/e2e/test_thinking_live.py @@ -1,83 +1,65 @@ -"""真实 API 验证推理开关与推理可观测性(issue #5 + #6;判据于 #16/#17 重建)。 +"""真实推理矩阵:独立请求/身份资格,逐轮留证,UNKNOWN 不证明关闭。 -本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径, -验证不了"这个参数在这个模型上到底关没关掉推理"。 - -三条判据纪律(第 1、2 条来自 findings §4c,第 1 条的推翻与第 3 条来自 -`findings/2026-08-25-thinking-observability-regression.md`): - -1. **判别量是库裁定的三态 `thinking_observation`,既不是 `reasoning_tokens` - 也不是 `completion_tokens`。** 长度判据早已排除: 两档的输出长度分布**是 - 重叠的**(实测关闭档最高 46 token、开启档最低 13 token),按阈值判两边都会 - 误判。而 `reasoning_tokens` 这个曾经"干净分开"的判据也已失效——MiniMax - 这一路上游不再返回 `usage.completion_tokens_details`,该字段恒 `None`;同一 - 次调用里库明明拿得到 185 字符推理正文,单看 token 计数却把"推理正常"读成 - "没推理"(2026-08-25 findings §3.4/结论③,四条用例因此假红)。三态裁定同时 - 看正文与计数: **正文是事实本身,token 计数只是对事实的转述**。 -2. **另配一个不含魔数的确定性锚点**(见 L2b、L5): 同一模型上,关闭档的 - `prompt_tokens` 严格小于开启档——供应商在开启时注入了推理指令,输入侧 - token 数随之变大。这是相对比较,不硬编码任何具体数值;且它不依赖上游是否 - 回传推理正文,所以在"观测不到推理"的非流式路径上依然作数。 -3. **`UNKNOWN` 不等于"没推理",不能拿它判红。** 关闭方向要求每轮"未观测到 - 推理"(`UNKNOWN` 计入满足——它没有证伪力),其证伪力来自: 模型若偷偷推理了, - 可观测路径会翻成 `OBSERVED`。开启方向只要求多数轮 `OBSERVED`;M3 非流式 - 路径整片观测不到,该档由 L5 用另一套断言覆盖。 - -源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。 +不新增成功 SSE 捕获器,不把整类异常跳过;历史长度/prompt 锚点仅作 +指定样本的形态回归,不提升能力覆盖。T10 不可关闭与预期拒绝是独立命题。 """ 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 +from uuid import uuid4 import pytest from dotenv import dotenv_values -from polygateway import GatewayClient, GatewaySettings, ThinkingObservation -from polygateway.errors import ( - AllSourcesExhausted, - GatewayUnavailableError, - RequestRejectedError, - SourceDeadError, - TransientError, -) -from polygateway.providers import ProviderProfile, ThinkingWire, register_provider -from polygateway.thinking import DEFAULT_CAPABILITIES, ThinkingCapability, get_capability +from polygateway import GatewaySettings, ThinkingObservation +from polygateway.thinking import DEFAULT_CAPABILITIES, ThinkingCapability from polygateway.types import EFFORT_ORDER, Effort +from tests.e2e.conftest import ( + LiveCapture, + captured_chat_round, + chat_expectations, + declared_control, + enforce_verdict, + observed_client, + source_controls, +) +from tests.live_evidence import ( + LiveVerdict, + assess_thinking_coverage, + combine_live_verdicts, + qualify_live_rounds, + summarize_verdicts, + write_live_round, +) _ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} -_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV) - -# slow: 本组 92 次真实调用、约 4 分半(2026-08-26 判据换三态后实测;此前记的 -# "137 次、约 7 分钟"已被证伪,别照旧值估 CI 预算),且判据是统计性的——网络抖动 -# 会让它偶发失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` -# 会把测试变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` -# 显式真跑并存档报告。"不自动门控"不等于"可跳过"。 +_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV) pytestmark = [ pytest.mark.slow, - pytest.mark.skipif( - not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)" - ), + pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"), ] - -_OUT_DIR = Path("tests/outputs/e2e") +_OUT_DIR = Path("tests/outputs/134/live") _ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10")) - -# 需要一点推理才能答对,但答案极短: 关掉推理时 completion 稳定在个位数, -# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里 +_TIER_ROUNDS = int(os.environ.get("PGW_E2E_TIER_ROUNDS", "5")) +_TIER_LONG_ROUNDS = int(os.environ.get("PGW_E2E_TIER_LONG_ROUNDS", "3")) +_TIER_CONCURRENCY = int(os.environ.get("PGW_E2E_TIER_CONCURRENCY", "3")) _PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。" +_TIER_PROMPT = "23 乘以 47 等于多少?只回答一个数字,不要解释。" +_TIER_LONG_PROMPT = ( + "\n".join( + f"{i:04d}. 这是一段与题目无关的填充文字,仅用于把上下文撑到数千 token," + "以复核短提示词下得到的关闭结论在长上下文下是否依然成立。" + for i in range(120) + ) + + "\n\n" + + _TIER_PROMPT +) +_ALL_EFFORTS = (*EFFORT_ORDER, Effort.AUTO) -_ROWS: list[dict] = [] - -# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。 -# 漏登记会被 test_every_capability_has_a_provider_mapping 当场抓住,而不是 -# 在 L8 里被"源不可用"这个假理由吞掉 _MODEL_PROVIDER = { "MiniMax-M3": "minimax", "MiniMax-M2.7": "minimax", @@ -107,768 +89,6 @@ _MODEL_PROVIDER = { "gemini-3-flash": "google", } - -def _base_settings() -> GatewaySettings: - # 强制关缓存: 多轮测量要求每一轮都真的打到供应商,命中缓存会把后续轮次 - # 变成对第一轮的回放,整组判据随之失效 - return GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"}) - - -def _settings(**source_overrides) -> GatewaySettings: - base = _base_settings() - source = dataclasses.replace(base.sources[0], **source_overrides) - return dataclasses.replace(base, sources=(source,)) - - -async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -> list[dict]: - """跑 N 轮真实调用,返回逐轮观测;任一轮抛错即向上冒泡由用例决定处置。""" - client = GatewayClient.from_settings(_settings(**source_overrides)) - observations = [] - try: - for i in range(rounds): - resp = await client.chat( - [{"role": "user", "content": _PROMPT}], - stream=stream, - # 每轮独立 salt: 即便某层缓存意外开着也不会回放 - cache_salt=f"thinking-live-{i}", - ) - observations.append( - { - "round": i + 1, - "prompt_tokens": resp.prompt_tokens, - "completion_tokens": resp.completion_tokens, - "reasoning_tokens": resp.reasoning_tokens, - # 结论与证据一起入报告: 只记 observation 会让"为什么这么判" - # 不可复核,而 thinking_chars 正是本次改判的直接证据 - "thinking_observation": resp.thinking_observation, - "thinking_chars": len(resp.thinking), - # 核对模型身份: 结论依赖"这组数说的是哪个模型"时(L8 的能力表 - # 对账),渠道串台会把渠道的路由问题记成库的漂移(issue #20) - "model_reported": resp.model_reported, - "content": resp.content[:60], - } - ) - finally: - await client.aclose() - return observations - - -def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -> None: - _ROWS.append( - { - "matrix": matrix_id, - "desc": desc, - "status": status, - "detail": detail, - "observations": observations or [], - } - ) - - -def _reasoning_off(obs: dict) -> bool: - """关闭方向: 只要没观测到推理即算满足。 - - `UNKNOWN` 计入满足是有意的: 它没有证伪力(本次无任何信号,判不出来),拿它 - 判红等于每次关闭调用都喊一遍。本判据真正的证伪力在于——模型若偷偷推理了, - 可观测路径会把裁定翻成 `OBSERVED`。 - - **刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做 - "只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会 - 把这种正常波动误判成"没关掉"。 - """ - return obs["thinking_observation"] != ThinkingObservation.OBSERVED - - -def _reasoning_on(obs: dict) -> bool: - """开启方向: 观测到推理即为真。 - - 判据从 `reasoning_tokens` 换成库的三态裁定,因为 MiniMax 这一路已不再上报 - `completion_tokens_details`(2026-08-25 findings 结论②),该字段恒 `None`; - 而库在同一次调用里拿得到 185 字符推理正文(findings §3.4)——旧判据看不见 - 它,L2/L3b/L4/L5 四条因此假红。 - - 也不能退回 completion_tokens 当判据: medium 档的推理量方差极大(实测 15 轮 - 跨 7-170 token),两档分布还与关闭档重叠,按长度阈值判会把"推理了但想得少" - 误判成没推理。 - """ - return obs["thinking_observation"] == ThinkingObservation.OBSERVED - - -def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str): - """源不可用(渠道下线/模型未开通)→ 跳过并记为未覆盖,不伪装成通过。""" - _record(matrix_id, desc, "SKIP(源不可用)", str(exc)[:200]) - pytest.skip(f"{matrix_id} 源不可用,已记为未覆盖: {str(exc)[:120]}") - - -def _is_model_not_found(status_code: int | None, body_text: str) -> bool: - """上游说的是**该渠道根本没有这个型号**(404 `model_not_found`),不是"这次请求有问题"。 - - 2026-09-05 实测: kimi-for-coding 上午 09:44 四项全 PASS 且 `model_reported` 正确, - 15:00 起变成 `404 | {"error":{...,"type":"model_not_found"}}` —— 渠道把它从账号组里 - 摘掉了。它与"上游拒绝这一档"(400 invalid tier)完全不是一件事: 后者是**关于档位/ - 能力的结论**,前者对它们一无所知,只说明源当下不可用。混为一谈会让一次渠道调整变成 - "能力表漂移"的假红,严重时反过来把能力表改错。 - - 判据取 `status_code` 与响应体里的 `type` 字段(机器可判的那格),不做整条 message - 的模糊匹配 —— message 里还拼着源名与库自己的话,匹配它等于赌文案不变。 - """ - return status_code == 404 and "model_not_found" in (body_text or "") - - -async def _rounds_or_skip(matrix_id: str, desc: str, rounds: int, **source_overrides) -> list[dict]: - """`_run_rounds` 加上"源不可用即记为未覆盖"的兜底(本模块 docstring 的纪律)。 - - 直接调 `_run_rounds` 的代价有两层,2026-09-05 那次 `-m slow` 两样都踩到了: - 其一外部抖动会以 FAIL 的形态冒出来,与"库真的坏了"无法区分;其二异常发生在 - `_record()` **之前**,报告里连一行「未覆盖」都不会留下——事后翻报告只看到该 - 矩阵行凭空消失,判断不出当时到底发生了什么。 - - 只吞网关/网络三类,外加"该渠道没有这个型号"这**一种**被拒(见 - `_is_model_not_found`)。**其余 `ValueError` / `RequestRejectedError` 照旧冒泡**: - 前者是装配守卫,后者是"请求本身被拒",两者都是本组要抓的真失败,吞掉即成静默。 - """ - try: - return await _run_rounds(rounds, **source_overrides) - except (AllSourcesExhausted, SourceDeadError, TransientError) as exc: - # `_skip_if_unreachable` 内部 `pytest.skip` 必抛,此处不会落到函数末尾 - _skip_if_unreachable(exc, matrix_id, desc) - raise # pragma: no cover —— 只为让静态读者看清控制流不会往下走 - except RequestRejectedError as exc: - if not _is_model_not_found(exc.status_code, exc.body_text): - raise - _skip_if_unreachable(exc, matrix_id, desc) - raise # pragma: no cover - - -@pytest.fixture(scope="module", autouse=True) -def _write_report(): - yield - _OUT_DIR.mkdir(parents=True, exist_ok=True) - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - path = _OUT_DIR / f"test_thinking_live_{ts}.md" - lines = [ - "# 推理开关与推理可观测性真实 API 验证", - "", - f"- 时间: {ts}", - f"- 每档轮数: {_ROUNDS}", - "- 判别量: 库裁定的三态 `thinking_observation`(OBSERVED/ABSENT/UNKNOWN)," - "由推理正文与 reasoning_tokens 共同裁定 —— 正文是事实,token 计数只是转述", - "- 关闭判据: **每轮** observation != OBSERVED(UNKNOWN 计入满足,它没有证伪力);" - "刻意不设输出长度上限(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)", - "- 开启判据: **多数轮** observation == OBSERVED", - "- 确定性锚点(L2b、L5): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数", - "- L5(非流式): M3 该路径推理已计费却不回传正文,故不断言「观测到推理」," - "改断锚点可分 + 开启档不被误判为 ABSENT", - "", - "## 矩阵结论", - "", - "| 矩阵 | 场景 | 结论 | 说明 |", - "|---|---|---|---|", - ] - total_calls = 0 - for row in _ROWS: - detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:160] - lines.append(f"| {row['matrix']} | {row['desc']} | {row['status']} | {detail} |") - total_calls += len(row["observations"]) - lines += ["", f"**总真实调用次数: {total_calls}**", "", "## 逐轮原始观测", ""] - for row in _ROWS: - if not row["observations"]: - continue - lines += [f"### {row['matrix']} — {row['desc']}", "", "```json"] - lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2)) - lines += ["```", ""] - uncovered = [r["matrix"] for r in _ROWS if r["status"].startswith("SKIP")] - if uncovered: - lines += ["## 未覆盖", "", f"以下矩阵行未跑到: {', '.join(uncovered)}", ""] - path.write_text("\n".join(lines), encoding="utf-8") - print(f"\n[e2e 报告] {path}") - - -class TestMiniMaxM3: - """M3 是唯一实测可关闭推理的 MiniMax 模型,修复的地基压在它身上。""" - - async def test_l1_disable_actually_disables(self): - desc = "enable_thinking=False(流式)" - obs = await _rounds_or_skip("L1", desc, _ROUNDS, model="MiniMax-M3", enable_thinking=False) - offs = [o for o in obs if _reasoning_off(o)] - _record( - "L1", - desc, - "PASS" if len(offs) == len(obs) else "FAIL", - f"{len(offs)}/{len(obs)} 轮未观测到推理", - obs, - ) - assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}" - - async def test_l2_enable_actually_enables(self): - desc = "enable_thinking=True(流式,注入 medium)" - obs = await _rounds_or_skip("L2", desc, _ROUNDS, model="MiniMax-M3", enable_thinking=True) - ons = [o for o in obs if _reasoning_on(o)] - _record( - "L2", - desc, - "PASS" if len(ons) * 2 > len(obs) else "FAIL", - f"{len(ons)}/{len(obs)} 轮观察到推理", - obs, - ) - assert len(ons) * 2 > len(obs), f"开启方向要求多数轮满足: {obs}" - - async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self): - """确定性锚点: 开启档的 prompt_tokens 严格大于关闭档。 - - 供应商在开启推理时会向模板注入推理指令,输入侧 token 数随之变大。这是 - 本组唯一不依赖输出侧噪声的证据,且是相对比较——不硬编码任何具体数值, - 供应商改模板也不会让它假红。 - """ - rounds = max(3, _ROUNDS // 3) - desc = "关闭/开启的 prompt_tokens 可分" - off = await _rounds_or_skip("L2b", desc, rounds, model="MiniMax-M3", enable_thinking=False) - on = await _rounds_or_skip("L2b", desc, rounds, model="MiniMax-M3", enable_thinking=True) - off_max = max(o["prompt_tokens"] for o in off) - on_min = min(o["prompt_tokens"] for o in on) - _record( - "L2b", - desc, - "PASS" if off_max < on_min else "FAIL", - f"关闭档最大 {off_max} < 开启档最小 {on_min}", - off + on, - ) - assert off_max < on_min, ( - f"两档的 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): 注入可能没到达模型" - ) - - async def test_l3_no_opinion_is_the_model_default(self): - desc = "enable_thinking=None(不干预,基线)" - obs = await _rounds_or_skip("L3", desc, _ROUNDS, model="MiniMax-M3", enable_thinking=None) - # M3 的默认档实测就是不推理(findings §2.1),所以不干预时也应观测不到推理。 - # 注意这**不能**反过来证明关闭方向生效 —— L1 与本行同分布,区分二者的是 - # L2b 的 prompt_tokens 与 L3b 的乱码值反证 - quiet = [o for o in obs if _reasoning_off(o)] - _record( - "L3", - desc, - "PASS" if len(quiet) == len(obs) else "FAIL", - f"{len(quiet)}/{len(obs)} 轮未观测到推理(M3 默认档本就不推理)", - obs, - ) - assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}" - - async def test_l3b_none_is_recognised_not_silently_dropped(self): - """反证: 关闭方向的观测必须排除"参数被静默丢弃"这一伪解释。 - - L1(关闭)与 L3(不干预)在 M3 上**同分布**——因为 M3 默认档本就不推理。 - 所以 L1 单独看不能区分"`none` 真的被消费"与"`none` 被中转吞了",而后者 - 正是 issue #5 的原始故障形态(`enable_thinking` 就是这么被吞的)。 - - 判别方法: 发一个**非法值**。若未知值会被静默丢弃,它的表现应与"不注入" - 一致(不推理);实测它反而开启了推理,说明网关认这个键、只是不认这个值。 - 既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值。 - - **该手法不可移植,只对"认这个键但不校验值"的 provider 成立**: minimax 对 - 非法 `reasoning_effort` 返回 200 且照常推理(2026-08-25 findings §5: - prompt 207,介于基线 194 与 medium 216 之间,走了第三条模板路径);而 qwen - 对同样的值直接返回 **HTTP 400**。把本用例套到 qwen 那类会校验值的 provider - 上,拿到的会是异常而非"不推理",是假红。 - """ - rounds = max(3, _ROUNDS // 3) - desc = "非法值反证 none 被识别" - bogus = await _rounds_or_skip( - "L3b", - desc, - rounds, - model="MiniMax-M3", - enable_thinking=None, - extra_body={"reasoning_effort": "definitely-not-a-real-level"}, - ) - off = await _rounds_or_skip("L3b", desc, rounds, model="MiniMax-M3", enable_thinking=False) - bogus_on = [o for o in bogus if _reasoning_on(o)] - off_quiet = [o for o in off if _reasoning_off(o)] - ok = len(bogus_on) * 2 > len(bogus) and len(off_quiet) == len(off) - _record( - "L3b", - desc, - "PASS" if ok else "FAIL", - f"非法值 {len(bogus_on)}/{len(bogus)} 轮推理,none {len(off_quiet)}/{len(off)} 轮不推理" - "(两者表现不同 ⇒ none 非被丢弃)", - bogus + off, - ) - assert len(bogus_on) * 2 > len(bogus), ( - f"非法值未开启推理,无法排除'未知值被静默丢弃'这一伪解释: {bogus}" - ) - assert len(off_quiet) == len(off), f"none 未关闭推理: {off}" - - async def test_l4_extra_body_overrides_the_profile(self): - """profile 注入 none,extra_body 要求 high —— 后者必须赢(优先级不可调换)。 - - 判据是行为而非报文: 若 extra_body 没赢,拿到的就是 none 的结果(不推理)。 - """ - rounds = max(3, _ROUNDS // 2) - desc = "extra_body 覆盖 profile 注入" - obs = await _rounds_or_skip( - "L4", - desc, - rounds, - model="MiniMax-M3", - enable_thinking=False, - extra_body={"reasoning_effort": "high"}, - ) - ons = [o for o in obs if _reasoning_on(o)] - _record( - "L4", - desc, - "PASS" if len(ons) * 2 > len(obs) else "FAIL", - f"{len(ons)}/{len(obs)} 轮观察到推理(证明 high 生效而非 none)", - obs, - ) - assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}" - - async def test_l5_non_stream_path_is_distinguishable_and_honestly_unknown(self): - """非流式快路径: 参数确实到达了模型,而推理信号被如实标成"观测不到"。 - - **本用例不能断言"非流式开启档观测到推理"——那永远不成立**: M3 在非流式 - 路径下推理段确实产生并计费(2026-08-25 findings §3.4: 开启档 completion 53 - vs 关闭档 3),但 `message` 里没有 `reasoning_content`、`usage` 里也没有 - `completion_tokens_details`,推理内容整体不回传。**这是上游行为,库修不了; - 库能做也必须做的是让它可见**——下游在为看不见的东西付费,不该由库替它 - 沉默。 - - 故改断两件在非流式下真实成立的事: - 其一 `prompt_tokens` 锚点仍把两档分开(判据形态照抄 L2b,证明注入到达了模型, - 排除"非流式路径把参数弄丢了"这一伪解释); - 其二开启档的裁定**不是 `ABSENT`**——`ABSENT` 的语义是"上游明确上报未推理", - 而实情是"判不出来"(`UNKNOWN`),库若把后者伪装成前者,正是 issue #16/#17 里 - 那个静默错觉。这里断 `!= ABSENT` 而非 `== UNKNOWN`,是为了留出上游哪天开始 - 回传正文的余地: 那时裁定会翻成 `OBSERVED`,是好事,不该让它把测试判红。 - """ - rounds = max(3, _ROUNDS // 2) - desc = "非流式: prompt 锚点可分 + 开启档如实标 UNKNOWN 而非 ABSENT" - off = await _rounds_or_skip( - "L5", desc, rounds, stream=False, model="MiniMax-M3", enable_thinking=False - ) - on = await _rounds_or_skip( - "L5", desc, rounds, stream=False, model="MiniMax-M3", enable_thinking=True - ) - offs = [o for o in off if _reasoning_off(o)] - off_max = max(o["prompt_tokens"] for o in off) - on_min = min(o["prompt_tokens"] for o in on) - not_absent = [o for o in on if o["thinking_observation"] != ThinkingObservation.ABSENT] - on_states = Counter(str(o["thinking_observation"]) for o in on) - ok = len(offs) == len(off) and off_max < on_min and len(not_absent) == len(on) - _record( - "L5", - desc, - "PASS" if ok else "FAIL", - f"关闭 {len(offs)}/{len(off)} 轮未观测到推理;" - f"关闭档 prompt 最大 {off_max} < 开启档最小 {on_min};" - f"开启档裁定分布 {dict(on_states)}", - off + on, - ) - assert len(offs) == len(off), f"非流式关闭方向未满足: {off}" - assert off_max < on_min, ( - f"非流式两档 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): " - f"开启参数可能没到达模型" - ) - assert len(not_absent) == len(on), ( - f"非流式开启档被裁成 ABSENT(声称上游明确上报未推理),而实情是观测不到: {on}" - ) - - -class TestOtherProviders: - """qwen / deepseek 的 profile 是既有实现,本组防的是"改 minimax 时误伤它们"。""" - - @pytest.mark.parametrize( - ("matrix", "provider", "model"), - [("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")], - ) - async def test_existing_profiles_still_disable(self, matrix, provider, model): - desc = f"{provider} enable_thinking=False" - try: - obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=False) - except (AllSourcesExhausted, SourceDeadError, TransientError) as exc: - # 只吞网关/网络类失败。**不吞 ValueError / RequestRejected** —— - # 那两类正是本次改动最可能的误伤方向,吞掉就成了纪律(c)要防的静默 - _skip_if_unreachable(exc, matrix, desc) - offs = [o for o in obs if _reasoning_off(o)] - _record( - matrix, - desc, - "PASS" if len(offs) == len(obs) else "FAIL", - f"{len(offs)}/{len(obs)} 轮未观测到推理", - obs, - ) - assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}" - - async def test_qwen_enabled_is_observed(self): - """设计 §14 验收: qwen 开启档必须裁定为 `OBSERVED`,不是 `UNKNOWN`。 - - 本条是三态裁定的**跨供应商对照组**: MiniMax 这一路两个信号都可能缺失 - (非流式档整片 `UNKNOWN`),若只按它调判据,很容易把"观测不到"当成常态; - qwen 在同一网关同一 key 上照常返回推理信号(findings 2026-08-25 §2), - 故这里能且必须要求正面结论——它一旦掉成 `UNKNOWN`,说明的是库的组装路径 - 丢了信号,而不是上游行为变了。 - """ - matrix, provider, model = "L6b", "qwen", "qwen3.7-plus" - desc = f"{provider} enable_thinking=True" - try: - obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=True) - except (AllSourcesExhausted, SourceDeadError, TransientError) as exc: - _skip_if_unreachable(exc, matrix, desc) - ons = [o for o in obs if _reasoning_on(o)] - _record( - matrix, - desc, - "PASS" if len(ons) * 2 > len(obs) else "FAIL", - f"{len(ons)}/{len(obs)} 轮观测到推理(OBSERVED)", - obs, - ) - assert len(ons) * 2 > len(obs), f"{provider} 开启方向要求多数轮 OBSERVED: {obs}" - - -class TestCapabilityDrift: - """L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。""" - - def test_every_capability_has_a_provider_mapping(self): - """能力表新增条目必须同步本测试的映射,否则该行会被静默跳过。""" - missing = sorted(set(DEFAULT_CAPABILITIES) - set(_MODEL_PROVIDER)) - assert not missing, f"这些模型缺 provider 映射,L8 会漏测: {missing}" - - @pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES)) - async def test_declared_capability_matches_reality(self, model): - """声明 can_disable 的模型必须真的关得掉,否则能力表已漂移。 - - **结论依赖模型身份,故先过身份关**: 2026-09-05 实测该渠道对 glm-5 / glm-5.1 / - glm-5.2 三个型号的请求全部回报 `model=glm-5.3`(issue #20 的路由问题仍在)。 - 照单全收的话,glm-5.3 那一轮碰巧推理了就会被记成"glm-5 的能力表漂移"——把 - 渠道串台记成库的缺陷,而库这边已经喊了对账告警,行为是对的。 - 身份不符一律 SKIP 记为未覆盖: 那是外部渠道问题,不是能力表的证据。 - **三个型号一视同仁**,不能只挡报错的那两个: glm-5.2 这次侥幸 PASS(被路由到的 - glm-5.3 那几轮恰好没推理),而侥幸绿的数据与红的数据一样不可信。 - - 另一种同类外部状况走 `_rounds_or_skip`: 渠道把型号从账号组里摘了(404 - `model_not_found`),同样是源不可用而非能力表漂移(2026-09-05 kimi-for-coding - 当天从 PASS 变 404,旧版把它记成了一条 FAIL)。 - """ - cap = get_capability(model) - provider = _MODEL_PROVIDER[model] - rounds = max(3, _ROUNDS // 2) - desc = f"{model} 声明 can_disable={cap.can_disable}" - if not cap.can_disable: - # 声明关不掉: 装配期就该炸,炸了即与声明一致(不必真调用) - with pytest.raises(ValueError, match=model): - GatewayClient.from_settings( - _settings(provider=provider, model=model, enable_thinking=False) - ) - _record("L8", desc, "PASS", "装配期按声明拒绝,与实测一致") - return - obs = await _rounds_or_skip( - "L8", desc, rounds, provider=provider, model=model, enable_thinking=False - ) - strangers = _identity_mismatch(model, obs) - if strangers: - _record( - "L8", - desc, - "SKIP(身份不符,数据不可信)", - f"该渠道把请求回报成 {strangers},本次观测说的不是这个模型", - obs, - ) - pytest.skip(f"{model} 被该渠道路由到 {strangers},本次观测说的不是这个模型") - offs = [o for o in obs if _reasoning_off(o)] - verdict = Counter(_reasoning_off(o) for o in obs) - _record( - "L8", - desc, - "PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)", - f"实测未观测到推理 {dict(verdict)}(True=满足);声明 can_disable=True 要求每轮满足", - obs, - ) - assert len(offs) == len(obs), ( - f"能力表漂移: {model} 声明可关闭推理,实测未关掉 —— 请复测后更新 DEFAULT_CAPABILITIES" - ) - - -_MYSTERY_PROFILE = ProviderProfile( - name="mystery", - thinking=ThinkingWire(off=None, on_base=None, effort_key=None), - strip_think_tags=False, -) -"""形态完全未知的 provider(issue #5 守卫的对象),与单元测试 `_MYSTERY` 同款。 - -**为什么不再借用默认表里的某一段**: L9 原先拿 `openai` 段当"形态未知"的样本,而 -1.3.3 起该段已按 OpenAI 标准形态登记(`off={"reasoning_effort":"none"}`、 -`on_base={}`、`effort_key="reasoning_effort"`),前提消失,用例随之 DID NOT RAISE。 -守的不变量一天没变,变的只是"哪个段当时恰好没形态"——所以样本改为显式构造, -让本条测的是**机制**而不是默认表某一格的当下取值。""" - - -class TestAssemblyGuardAgainstRealConfig: - """L9: 纯本地,但用的是 .env 里的真实配置形态,防"守卫只在合成配置上生效"。""" - - def test_l9_m27_rejected_at_assembly(self): - with pytest.raises(ValueError, match="MiniMax-M2.7"): - GatewayClient.from_settings( - _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False) - ) - _record("L9", "M2.7 + enable_thinking=False", "PASS", "装配期报错,未发出任何请求") - - def test_l9_unknown_shape_rejected_at_assembly(self): - """形态未知的 provider 配了推理开关 → 装配期报错并指路 `register_provider`。 - - 样本经 `register_provider` 挂进注册表再用,而不是拿默认表里"当时恰好没形态" - 的那一段——后者的前提会随默认表增补而失效(见 `_MYSTERY_PROFILE`)。 - """ - registry = register_provider(_MYSTERY_PROFILE) - with pytest.raises(ValueError, match="register_provider"): - GatewayClient.from_settings( - _settings(provider="mystery", model="kimi-k3", enable_thinking=False), - registry=registry, - ) - _record("L9", "形态未知的 provider(构造)", "PASS", "装配期报错并指路") - - async def test_transport_layer_rejects_when_guard_is_bypassed(self): - """构造函数全量注入这条路绕过装配守卫,transport 必须兜住并归四分类。""" - settings = _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False) - client = GatewayClient.from_settings( - dataclasses.replace( - settings, sources=(dataclasses.replace(settings.sources[0], enable_thinking=None),) - ) - ) - try: - # 装配用 None 绕过守卫,再把源换成 False 直接喂给 transport - bad = dataclasses.replace(settings.sources[0], enable_thinking=False) - with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"): - await client._terminal._transport.complete( - messages=[{"role": "user", "content": _PROMPT}], - source=bad, - stream=True, - overlay={}, - call_id="e2e-guard", - 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]}", - # 另存机器可判的两格: 「上游拒绝这一档」与「该渠道没有这个型号」 - # 都是 `RequestRejectedError`,`_probe_rejected` 要靠状态码与 - # 响应体里的 `type` 把它们分开,而不是去模糊匹配整条 message - "error_status": exc.status_code, - "error_body": exc.body_text, - } - 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: - """这一轮拿到了真实观测。 - - 用 `.get` 而非下标: `_identity_mismatch` 被 L8 复用,而 `_run_rounds` 产出的 - 逐轮字典里根本没有 `error` 键(那条路径上失败是冒泡的,不会留下失败轮)。 - """ - return obs.get("error") is None - - -def _model_missing(obs: dict) -> bool: - """`_is_model_not_found` 的逐轮字典适配:这一轮是"该渠道没有这个型号"而失败的。 - - 判据本身与 L1-L9 共用一份(理由见 `_is_model_not_found`),此处只负责从失败轮 - 里取出那两格 —— 两处各写一份迟早只改一处。 - """ - return _is_model_not_found(obs.get("error_status"), obs.get("error_body") or "") - - -def _probe_rejected(obs: dict) -> bool: - """上游明确拒绝**这一档**(400 / Unsupported value)⇒ 结论: 该档不受支持。 - - 显式排除 `_model_missing`: 型号不存在时上游没有对档位表过任何态。 - """ - return (obs.get("error") or "").startswith("RequestRejected") and not _model_missing(obs) - - -def _unreachable_verdict(observations: list[dict]) -> str: - """源不可用的两种成因在报告里必须分得开: 渠道摘了型号 vs 渠道当下抖动。""" - broken = [o for o in observations if o.get("error")] - if broken and all(_model_missing(o) for o in broken): - return "SKIP(源不可用: 该渠道未提供此型号)" - return "SKIP(源不可用)" - - -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"}), @@ -879,352 +99,427 @@ _MODEL_REPORTED_ALIASES: Mapping[str, frozenset[str]] = MappingProxyType( ) -def _identity_mismatch(model: str, observations: list[dict]) -> list[str]: - """响应体里的 `model` 与请求的模型对不上 → 本次数据说的不是这个模型。 +def _settings(**source_overrides): + """强制关闭缓存,清除 inherited 受管意图后应用本矩阵配置。""" + base = GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"}) + source = dataclasses.replace( + base.sources[0], enable_thinking=None, reasoning_effort=None, extra_body={} + ) + source = dataclasses.replace(source, **source_overrides) + return dataclasses.replace(base, sources=(source,)) - issue #20 就栽在这里: 该渠道对 `glm-5.2` 的请求 6/6 回报 `model=glm-5.3`, - 照单全收的话,能力表里 glm-5.2 那一行记的其实是 glm-5.3 的行为。凡结论依赖 - 模型身份的,对不上就必须当场作废,而不是打个折扣继续用。 - `None`(上游未上报)不算不符: 那是"没说",不是"说了别的"。 +def _tier_settings(model): + """保留既有一次 retry 探测预算;不压缩生产 stall 或单次 timeout。""" + base = GatewaySettings.from_env( + "LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none", "LLM_MAX_RETRIES": "1"} + ) + source = dataclasses.replace( + base.sources[0], + model=model, + provider=_MODEL_PROVIDER[model], + enable_thinking=None, + reasoning_effort=None, + extra_body={}, + ) + return dataclasses.replace(base, sources=(source,)) - 定义在 T10 段内但**不专属于它**: L8 的能力表对账同样以模型身份为前提, - 2026-09-05 那次假红就是它缺了这道关(见该用例 docstring)。 - """ - 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 _collect_rounds( + settings, *, rounds, stream, prompt, matrix_id, effort=None, capabilities=None, concurrency=1 +): + """保留所有失败轮,不把可用轮集合偷偷当新分母。""" + if rounds < 1 or concurrency < 1: + raise ValueError("轮次/并发必须为正数") + messages = [{"role": "user", "content": prompt}] + controls = ( + source_controls(settings) + if effort is None + else {s.name: declared_control(s.provider, effort) for s in settings.sources} + ) + capture = LiveCapture( + expectations=chat_expectations( + settings, messages=messages, stream=stream, controls=controls + ) + ) + run_id = uuid4().hex + semaphore = asyncio.Semaphore(concurrency) + async with observed_client(settings, capture, capabilities=capabilities) as client: + + async def one(index): + """每轮已落盘后才回到汇总,断言失败也有记录。""" + + def validate(response): + assert response.content.strip() + if effort is not None: + assert response.applied_effort is effort + + async with semaphore: + response, verdict = await captured_chat_round( + client, + capture, + run_id=run_id, + matrix_id=matrix_id, + round_index=index + 1, + output_dir=_OUT_DIR, + messages=messages, + models={s.name: s.model for s in settings.sources}, + providers={s.name: s.provider for s in settings.sources}, + source_efforts={ + s.name: s.reasoning_effort + if s.reasoning_effort is not None + else (Effort.AUTO if s.enable_thinking else Effort.NONE) + if s.enable_thinking is not None + else None + for s in settings.sources + }, + aliases=_MODEL_REPORTED_ALIASES, + stream=stream, + reasoning_effort=effort, + cache_salt=f"{run_id}-{index}", + validate=validate, + ) + return {"round": index + 1, "verdict": verdict, "response": response} + + # return_exceptions 保证一个取证写失败不使其他任务越过资源关闭边界。 + results = await asyncio.gather(*(one(i) for i in range(rounds)), return_exceptions=True) + values = [] + for value in results: + if isinstance(value, BaseException): + raise value + values.append(value) + counts = summarize_verdicts([value["verdict"] for value in values], planned_rounds=rounds) + write_live_round( + _OUT_DIR, + run_id=run_id, + matrix_id=matrix_id + "-rounds", + round_index=0, + safe_fields={"counts": counts, "completed_rounds": len(values), "planned_rounds": rounds}, + ) + return values + + +async def _run_rounds(rounds, *, stream=True, matrix_id="thinking", **source_overrides): + """L1–L8 的资格证据出口,不作整类 skip。""" + return await _collect_rounds( + _settings(**source_overrides), + rounds=rounds, + stream=stream, + prompt=_PROMPT, + matrix_id=matrix_id, ) -async def _anchor_off_against_on( - model: str, off_observations: list[dict] -) -> tuple[list[dict], Effort | None, bool]: - """判据④: 拿"开启档的 completion 明显更大"给关闭结论补一个正面证据。 +def _qualified(rows, *, planned_rounds): + """汇总资格先失败后未覆盖;部分失败不能被成功轮掩盖。""" + return qualify_live_rounds([row["verdict"] for row in rows], planned_rounds=planned_rounds) - 需要它是因为 `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})", +def _coverage(rows, *, planned_rounds, proposition): + """只有全轮资格通过才进入推理观测命题。""" + verdict = _qualified(rows, planned_rounds=planned_rounds) + if verdict.status == "PASS": + verdict = assess_thinking_coverage( + [row["response"].thinking_observation for row in rows], + planned_rounds=planned_rounds, + proposition=proposition, ) - 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 + return verdict -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, - } +def _conclude(matrix, verdict, *, proposition=None): + """命题汇总先落盘再交给 pytest,不覆盖逐轮原件。""" + write_live_round( + _OUT_DIR, + run_id=uuid4().hex, + matrix_id=matrix, + round_index=0, + safe_fields={ + "status": verdict.status, + "reason": verdict.reason, + "proposition": proposition, + }, ) + enforce_verdict(verdict) -@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} |" +class TestMiniMaxM3: + """AUTO 拒绝已移至离线契约;真实开启明确请求 medium。""" + + async def test_l1_disable_actually_disables(self): + rows = await _run_rounds( + _ROUNDS, matrix_id="L1", provider="minimax", model="MiniMax-M3", enable_thinking=False ) - 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}") + _conclude( + "L1", + _coverage(rows, planned_rounds=_ROUNDS, proposition="disabled"), + proposition="disabled", + ) + + async def test_l2_enable_actually_enables(self): + rows = await _run_rounds( + _ROUNDS, + matrix_id="L2", + provider="minimax", + model="MiniMax-M3", + reasoning_effort=Effort.MEDIUM, + ) + _conclude( + "L2", + _coverage(rows, planned_rounds=_ROUNDS, proposition="enabled"), + proposition="enabled", + ) + + async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self): + """指定历史 prompt 锚点回归,不宣称关闭能力已覆盖。""" + rounds = max(3, _ROUNDS // 3) + off = await _run_rounds( + rounds, + matrix_id="L2b-off", + provider="minimax", + model="MiniMax-M3", + enable_thinking=False, + ) + on = await _run_rounds( + rounds, + matrix_id="L2b-on", + provider="minimax", + model="MiniMax-M3", + reasoning_effort=Effort.MEDIUM, + ) + verdict = _qualified(off + on, planned_rounds=rounds * 2) + if verdict.status == "PASS": + distinct = max(r["response"].prompt_tokens for r in off) < min( + r["response"].prompt_tokens for r in on + ) + verdict = LiveVerdict( + "PASS" if distinct else "FAIL", "指定历史 prompt 锚点比较;不是关闭证明" + ) + _conclude("L2b", verdict, proposition="historical-prompt-anchor") + + async def test_l3_no_opinion_is_the_model_default(self): + rows = await _run_rounds(_ROUNDS, matrix_id="L3", provider="minimax", model="MiniMax-M3") + verdict = _qualified(rows, planned_rounds=_ROUNDS) + if verdict.status == "PASS" and any( + row["response"].applied_effort is not None for row in rows + ): + verdict = LiveVerdict("FAIL", "不表态路径擅自记录档位") + _conclude("L3", verdict, proposition="no-opinion-not-capability") + + async def test_l3b_none_is_recognised_not_silently_dropped(self): + """保留原非法 raw 值对照预算,但不提升 UNKNOWN。""" + rounds = max(3, _ROUNDS // 3) + bogus = await _run_rounds( + rounds, + matrix_id="L3b-bogus", + provider="minimax", + model="MiniMax-M3", + extra_body={"reasoning_effort": "definitely-not-a-real-level"}, + ) + off = await _run_rounds( + rounds, + matrix_id="L3b-off", + provider="minimax", + model="MiniMax-M3", + enable_thinking=False, + ) + verdicts = [ + _coverage(bogus, planned_rounds=rounds, proposition="enabled"), + _coverage(off, planned_rounds=rounds, proposition="disabled"), + ] + _conclude("L3b", _combine(verdicts), proposition="raw-counterexample") + + async def test_l4_raw_only_explicit_high(self): + """退出受管意图后才保留 raw high;双来源拒绝在 unit 守卫。""" + rounds = max(3, _ROUNDS // 2) + rows = await _run_rounds( + rounds, + matrix_id="L4", + provider="minimax", + model="MiniMax-M3", + extra_body={"reasoning_effort": "high"}, + ) + _conclude( + "L4", + _coverage(rows, planned_rounds=rounds, proposition="enabled"), + proposition="enabled", + ) + + async def test_l5_non_stream_path_is_distinguishable_and_honestly_unknown(self): + """保留流/非流预算;UNKNOWN 是明确未覆盖而非长度锚点成功。""" + rounds = max(3, _ROUNDS // 2) + off = await _run_rounds( + rounds, + matrix_id="L5-off", + stream=False, + provider="minimax", + model="MiniMax-M3", + enable_thinking=False, + ) + on = await _run_rounds( + rounds, + matrix_id="L5-on", + stream=False, + provider="minimax", + model="MiniMax-M3", + reasoning_effort=Effort.MEDIUM, + ) + _conclude( + "L5", + _combine( + [ + _coverage(off, planned_rounds=rounds, proposition="disabled"), + _coverage(on, planned_rounds=rounds, proposition="enabled"), + ] + ), + proposition="nonstream-enabled-disabled", + ) + + +def _combine(verdicts): + """任一失败优先,部分未覆盖不得汇总全 PASS。""" + return combine_live_verdicts(verdicts) + + +class TestOtherProviders: + """既有供应商开启/关闭真实矩阵。""" + + @pytest.mark.parametrize( + ("matrix", "provider", "model"), + [("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")], + ) + async def test_existing_profiles_still_disable(self, matrix, provider, model): + rows = await _run_rounds( + _ROUNDS, matrix_id=matrix, provider=provider, model=model, enable_thinking=False + ) + _conclude( + matrix, + _coverage(rows, planned_rounds=_ROUNDS, proposition="disabled"), + proposition="disabled", + ) + + async def test_qwen_enabled_is_observed(self): + rows = await _run_rounds( + _ROUNDS, matrix_id="L6b", provider="qwen", model="qwen3.7-plus", enable_thinking=True + ) + _conclude( + "L6b", + _coverage(rows, planned_rounds=_ROUNDS, proposition="enabled"), + proposition="enabled", + ) + + +class TestCapabilityDrift: + """只运行可关闭声明的真实验证;不可关闭装配拒绝另在 unit。""" + + @pytest.mark.parametrize( + "model", + sorted( + model for model, capability in DEFAULT_CAPABILITIES.items() if capability.can_disable + ), + ) + async def test_declared_capability_matches_reality(self, model): + rounds = max(3, _ROUNDS // 2) + rows = await _run_rounds( + rounds, + matrix_id="L8", + provider=_MODEL_PROVIDER[model], + model=model, + enable_thinking=False, + ) + _conclude( + "L8", + _coverage(rows, planned_rounds=rounds, proposition="disabled"), + proposition="disabled", + ) + + +async def _probe_effort(model, effort, *, rounds, prompt, prompt_kind): + """临时全档表仅用于 T10 探测,不写回 DEFAULT,也不生成预期 wire。""" + return await _collect_rounds( + _tier_settings(model), + rounds=rounds, + stream=True, + prompt=prompt, + matrix_id="T10-" + prompt_kind, + effort=effort, + capabilities={model: ThinkingCapability(_ALL_EFFORTS, evidence="T10 临时探测声明")}, + concurrency=_TIER_CONCURRENCY, + ) class TestTierProbe: - """能力表实测。可只跑单个模型: `-k "test_t10 and glm-5.3"`。""" + """逐型号能力命题,不把拒绝、不可关闭和 UNKNOWN 混在一起。""" @pytest.mark.parametrize("model", sorted(_MODEL_PROVIDER)) async def test_t10_none_direction_matches_declaration(self, model): - """「这个模型到底关不关得掉」——能力表里唯一会**报错**的那条声明。 - - 它是本节最要紧的一条: `Effort.NONE` 在不在清单里,决定 Phase 4 是放行还是 - 当场报错。声明错了,两个方向的代价都很实在——多写了 `none` 会让下游以为 - 关掉了(issue #20 的静默失效),漏写了会把一条本来可用的路堵死。 - """ + capability = DEFAULT_CAPABILITIES.get(model) + proposition = "disabled" if capability and capability.can_disable else "cannot_disable" short = await _probe_effort( - model, Effort.NONE, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="short" + model, Effort.NONE, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="none-short" ) - # 上游拒绝这一档(400)是**结论**而非故障: 它等价于"关不掉"; - # 其余失败(渠道下线/型号被摘/超时)才是源不可用,按既有纪律记为未覆盖。 - # 404 `model_not_found` 因此不算 rejected —— 它会自然落进下面那条源不可用分支 - rejected = [o for o in short if _probe_rejected(o)] - 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 方向", _unreachable_verdict(short), _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( + verdict = _coverage(short, planned_rounds=_TIER_ROUNDS, proposition=proposition) + # 沿既有矩阵:短档没有 OBSERVED 才做长上下文复核;不新增锚点调用。 + if _qualified(short, planned_rounds=_TIER_ROUNDS).status == "PASS" and not any( + r["response"].thinking_observation is ThinkingObservation.OBSERVED for r in short + ): + long_rows = await _probe_effort( model, Effort.NONE, rounds=_TIER_LONG_ROUNDS, prompt=_TIER_LONG_PROMPT, - prompt_kind="long", + prompt_kind="none-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}" - ) + verdict = _coverage( + short + long_rows, + planned_rounds=_TIER_ROUNDS + _TIER_LONG_ROUNDS, + proposition=proposition, + ) + if capability is None and verdict.status != "FAIL": + verdict = LiveVerdict("UNCOVERED", "未登记候选只保留观测,不自动登记能力") + _conclude("T10-none", verdict, proposition=proposition) @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 = [] + tiers = [e for e in DEFAULT_CAPABILITIES[model].supported_efforts if e is not Effort.NONE] verdicts = [] for tier in tiers: - observations = await _probe_effort( - model, tier, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="short" + rows = await _probe_effort( + model, + tier, + rounds=_TIER_ROUNDS, + prompt=_TIER_PROMPT, + prompt_kind="tier-" + tier.value, ) - # 同 none 方向: 只有"拒绝这一档"才是关于档位的结论,404 型号不存在 - # 说明的是源不可用,记成 FAIL 会把渠道摘型号读成"登记了个上游不认的档" - rejected = [o for o in observations if _probe_rejected(o)] - 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 = _unreachable_verdict(observations), None - elif len(observed) * 2 > len(usable): - verdict, problem = "PASS", None - else: - verdict, problem = "FAIL(该档未推理)", f"{tier.value}: 多数轮未观测到推理" - if problem: - failures.append(problem) + verdict = _coverage(rows, planned_rounds=_TIER_ROUNDS, proposition="enabled") verdicts.append(verdict) - _probe_record( - model, f"档位 {tier.value}", verdict, _rt_summary(observations), observations + write_live_round( + _OUT_DIR, + run_id=uuid4().hex, + matrix_id="T10-tier", + round_index=0, + safe_fields={ + "requested_model": model, + "requested_effort": tier.value, + "status": verdict.status, + "reason": verdict.reason, + }, ) - assert not failures, f"{model} 登记的档位与实测不符: {failures}" - if all(v.startswith("SKIP") for v in verdicts): - # 一档都没跑通却判绿,就是本模块 docstring 明令禁止的"静默计入通过": - # 绿色在这里会被读成"登记的档位都验过了",而实情是一条都没验 - pytest.skip(f"{model} 各档均源不可用,已记为未覆盖: {verdicts}") + _conclude("T10-tiers", _combine(verdicts), proposition="enabled-all-declared-tiers") @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) + rows = await _collect_rounds( + _tier_settings(model), + rounds=_TIER_ROUNDS, + stream=True, + prompt=_TIER_PROMPT, + matrix_id="T10-default", + capabilities={model: ThinkingCapability(_ALL_EFFORTS, evidence="T10 临时探测声明")}, ) - 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]}", - # 与 `_probe_effort` 的失败轮同形: 少这两格, - # `_unreachable_verdict` 会把"型号被摘"读成普通抖动 - "error_status": exc.status_code, - "error_body": exc.body_text, - } - ) - 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, - "默认档基线(不表态)", - _unreachable_verdict(observations), - _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}" + verdict = _qualified(rows, planned_rounds=_TIER_ROUNDS) + if verdict.status == "PASS" and any( + row["response"].applied_effort is not None for row in rows + ): + verdict = LiveVerdict("FAIL", "默认基线擅自推定档位") + _conclude("T10-default", verdict, proposition="no-opinion-not-capability") diff --git a/tests/live_evidence.py b/tests/live_evidence.py new file mode 100644 index 0000000..e870d7c --- /dev/null +++ b/tests/live_evidence.py @@ -0,0 +1,308 @@ +"""真实测试的有限证据判定;不读取环境、不请求网络、不记录原始正文。""" + +import hashlib +import json +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal +from uuid import uuid4 + +from polygateway.errors import RequestRejectedError +from polygateway.types import ThinkingObservation + + +@dataclass(frozen=True) +class HttpEvidence: + """一次 HTTP 的内存证据,禁止直接序列化。""" + + call_id: str + request_checks: tuple[tuple[str, bool], ...] + status_code: int + error_body: bytes | None + raw_identity: tuple[bool, str | None] + + +@dataclass(frozen=True) +class AttemptEvidence: + """一次 transport 尝试,可以没有 HTTP。""" + + call_id: str + http: tuple[HttpEvidence, ...] + error: Exception | None + + +@dataclass(frozen=True) +class LiveVerdict: + """测试命题的结论,不等同于 pytest 退出码。""" + + status: Literal["PASS", "FAIL", "UNCOVERED"] + reason: str + + +def strict_json(body: bytes) -> Any: + """独立解析完整 UTF-8 JSON,拒绝重复键及非标准常量。""" + + def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]: + """重复键不允许被后值掩盖。""" + result = {} + for key, value in items: + if key in result: + raise ValueError("重复 JSON 键") + result[key] = value + return result + + def invalid(value: str) -> None: + """拒绝非标准数值常量。""" + raise ValueError("非法 JSON 常量") + + return json.loads(body.decode("utf-8"), object_pairs_hook=pairs, parse_constant=invalid) + + +def messages_digest(messages: Any) -> str: + """只在内存比较提示词摘要,不写提示词。""" + return hashlib.sha256( + json.dumps(messages, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +def request_is_valid(event: HttpEvidence) -> bool: + """完整且无重复的显式检查才能作为请求资格。""" + checks = dict(event.request_checks) + common = {"method", "origin", "path", "model", "authorization", "control", "messages_digest"} + return ( + len(checks) == len(event.request_checks) + and set(checks) in (common | {"stream"}, common | {"input_shape"}) + and all(value is True for value in checks.values()) + ) + + +def _single_error(error: Exception, attempts: Sequence[AttemptEvidence]) -> HttpEvidence | None: + """仅接受可与最终异常精确配对的独立单次错误。""" + if not isinstance(error, RequestRejectedError) or len(attempts) != 1: + return None + attempt = attempts[0] + if attempt.error is not error or len(attempt.http) != 1: + return None + event = attempt.http[0] + if event.call_id != attempt.call_id or not request_is_valid(event): + return None + if event.status_code != error.status_code: + return None + return event + + +def error_machine_type(event: HttpEvidence) -> str | None: + """完整错误体中的唯一机器字段;不检查正文子串。""" + body = event.error_body + if body is None or not 0 < len(body) <= 65536: + return None + try: + data = strict_json(body) + except (ValueError, UnicodeError): + return None + if not isinstance(data, dict) or not isinstance(data.get("error"), dict): + return None + value = data["error"].get("type") + return value if isinstance(value, str) else None + + +def classify_live_failure(error: Exception, attempts: Sequence[AttemptEvidence]) -> LiveVerdict: + """默认失败;唯一自动外因是完整证据支持的 404 model_not_found。""" + event = _single_error(error, attempts) + if ( + event is not None + and event.status_code == 404 + and error_machine_type(event) == "model_not_found" + ): + return LiveVerdict("UNCOVERED", "端点回报该请求型号不可用") + return LiveVerdict("FAIL", "无满足窄外因契约的完整独立证据") + + +def assess_expected_rejection( + error: Exception, attempts: Sequence[AttemptEvidence], *, status_code: int, machine_type: str +) -> LiveVerdict: + """预先声明的拒绝命题,不把一般 400 当不支持档位。""" + event = _single_error(error, attempts) + if ( + event is not None + and event.status_code == status_code + and error_machine_type(event) == machine_type + ): + return LiveVerdict("PASS", "符合预声明的拒绝类型、状态和机器字段") + return LiveVerdict("FAIL", "不符合预声明拒绝证据") + + +def assess_model_identity( + *, + requested: str, + aliases: frozenset[str], + reported: str | None, + raw_identity: tuple[bool, str | None], + request_valid: bool, +) -> LiveVerdict: + """公共身份异常只有原始独立证据才能归因上游。""" + if not request_valid: + return LiveVerdict("FAIL", "实发请求校验不完整或不符") + allowed = {requested, *aliases} + captured, raw = raw_identity + if captured and raw != reported: + return LiveVerdict("FAIL", "公共身份与独立原始身份不一致") + if reported in allowed: + return LiveVerdict("PASS", "身份合格") + if not captured: + return LiveVerdict("FAIL", "身份来源无法区分") + return LiveVerdict("UNCOVERED", "独立原始响应身份缺失或不属于显式别名") + + +def assess_thinking_coverage( + observations: Sequence[ThinkingObservation], + *, + planned_rounds: int, + proposition: Literal["enabled", "disabled", "cannot_disable"], +) -> LiveVerdict: + """按独立命题裁定;缺轮不减分母,UNKNOWN 不证明关闭。""" + if planned_rounds < 1 or len(observations) != planned_rounds: + return LiveVerdict("FAIL", "计划轮次不完整") + if any(not isinstance(item, ThinkingObservation) for item in observations): + return LiveVerdict("FAIL", "观测类型不符") + observed = observations.count(ThinkingObservation.OBSERVED) + unknown = observations.count(ThinkingObservation.UNKNOWN) + if proposition == "enabled": + if observed > planned_rounds / 2: + return LiveVerdict("PASS", "完整轮次多数观测到推理") + return LiveVerdict("UNCOVERED" if unknown else "FAIL", "开启证据未达多数") + if proposition == "disabled": + if observed: + return LiveVerdict("FAIL", "观测到推理,证伪关闭声明") + return LiveVerdict( + "UNCOVERED" if unknown else "PASS", + "UNKNOWN 不证明关闭" if unknown else "每轮明确 ABSENT", + ) + if proposition == "cannot_disable": + if observed: + return LiveVerdict("PASS", "本条件下仍推理;不外推所有私有参数") + return LiveVerdict("UNCOVERED" if unknown else "FAIL", "缺少仍推理的证据") + raise ValueError("未知测试命题") + + +def summarize_verdicts(verdicts: Sequence[LiveVerdict], *, planned_rounds: int) -> dict[str, int]: + """保留失败、未覆盖与缺轮的独立计数。""" + if planned_rounds < len(verdicts): + raise ValueError("实际轮次超出计划") + return { + **{ + status: sum(v.status == status for v in verdicts) + for status in ("PASS", "FAIL", "UNCOVERED") + }, + "missing": planned_rounds - len(verdicts), + } + + +_SAFE_FIELDS = frozenset( + { + "provider", + "requested_model", + "reported_model", + "stream", + "requested_effort", + "applied_effort", + "session_id", + "parent_call_id", + "attempts", + "status", + "reason", + "completed_rounds", + "planned_rounds", + "thinking_observation", + "prompt_tokens", + "completion_tokens", + "reasoning_tokens", + "thinking_chars", + "counts", + "proposition", + "error_type", + "error_status", + "evidence_notes", + } +) +_ATTEMPT_FIELDS = frozenset({"call_id", "error_type", "http"}) +_HTTP_FIELDS = frozenset( + {"status_code", "request_checks", "identity_captured", "error_body_complete"} +) + + +def _validate_safe(fields: Mapping[str, Any]) -> None: + """拒绝原始异常和证据对象,嵌套字段也有白名单。""" + if set(fields) - _SAFE_FIELDS: + raise ValueError("报告含非白名单字段") + for attempt in fields.get("attempts", []): + if not isinstance(attempt, dict) or set(attempt) != _ATTEMPT_FIELDS: + raise ValueError("非法 attempt 报告") + for event in attempt["http"]: + if not isinstance(event, dict) or set(event) != _HTTP_FIELDS: + raise ValueError("非法 HTTP 报告") + if not isinstance(event["request_checks"], dict) or any( + type(v) is not bool for v in event["request_checks"].values() + ): + raise ValueError("请求校验报告只允许布尔值") + # 不提供 default=str:原始异常、bytes、dataclass 均必须失败。 + json.dumps(fields, ensure_ascii=False, allow_nan=False) + + +def write_live_round( + output_dir: Path, + *, + run_id: str, + matrix_id: str, + round_index: int, + safe_fields: Mapping[str, Any], +) -> Path: + """仅接收已脱敏字段;独占文件写入失败必须冒泡。""" + _validate_safe(safe_fields) + if round_index < 0 or not re.fullmatch(r"[a-zA-Z0-9_-]+", run_id + matrix_id): + raise ValueError("报告路径标识非法") + directory = output_dir / run_id + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{matrix_id}-{round_index}-{uuid4().hex}.md" + text = json.dumps(dict(safe_fields), ensure_ascii=False, indent=2, allow_nan=False) + with path.open("x", encoding="utf-8") as handle: + handle.write(f"# {matrix_id} · 轮次 {round_index}\n\n```json\n{text}\n```\n") + return path + + +def safe_attempts(attempts: Sequence[AttemptEvidence]) -> list[dict[str, Any]]: + """只导出事实布尔值、异常类和状态;不落盘任何上游正文。""" + return [ + { + "call_id": attempt.call_id, + "error_type": type(attempt.error).__name__ if attempt.error else None, + "http": [ + { + "status_code": event.status_code, + "request_checks": dict(event.request_checks), + "identity_captured": event.raw_identity[0], + "error_body_complete": event.error_body is not None, + } + for event in attempt.http + ], + } + for attempt in attempts + ] + + +def combine_live_verdicts(verdicts: Sequence[LiveVerdict]) -> LiveVerdict: + """部分失败优先于未覆盖;部分未覆盖不得汇总全 PASS。""" + if any(verdict.status == "FAIL" for verdict in verdicts): + return LiveVerdict("FAIL", "至少一个必需命题失败") + if not verdicts or any(verdict.status == "UNCOVERED" for verdict in verdicts): + return LiveVerdict("UNCOVERED", "至少一个必需命题未覆盖") + return LiveVerdict("PASS", "所有必需命题通过") + + +def qualify_live_rounds(verdicts: Sequence[LiveVerdict], *, planned_rounds: int) -> LiveVerdict: + """汇总请求/身份资格,缺轮绝不缩小分母。""" + if planned_rounds < 1 or len(verdicts) != planned_rounds: + return LiveVerdict("FAIL", "计划轮次缺失") + return combine_live_verdicts(verdicts) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e08e8b8..2f4d8e7 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1340,3 +1340,25 @@ async def test_managed_conflict_returns_half_open_probe_and_permit(): await gate.release_probe(next_entry) finally: await client._transport.aclose() + + +async def test_synthetic_runtime_protocol_and_legacy_call_signatures(): + """合成 Protocol 仅证明本库兼容契约,不冒充缺失下游实际验收。""" + import inspect + from typing import Protocol, runtime_checkable + + @runtime_checkable + class Caller(Protocol): + """旧调用点只依赖 chat 协议。""" + + async def chat(self, messages, **kwargs): ... + + client = GatewayClient.from_env("LLM", env=_ENV) + try: + assert isinstance(client, Caller) + signature = inspect.signature(client.chat) + signature.bind([], session_id="session", parent_call_id="step") + signature.bind([], session_id="session", cache_salt="epoch-1") + assert client._transport._clients == {} + finally: + await client.aclose() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 61af01c..e853783 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -997,3 +997,47 @@ class TestCrossFieldInvariants: ) client = GatewayClient.from_settings(settings) assert client is not None + + +async def test_flat_legacy_keys_assemble_without_source_timeout_or_network(): + """完整合成 env 验证平铺回落,不借真实配置或 Redis/PG。""" + env = dict(_BASE_ENV) + del env["LLM__QWEN__1__TIMEOUT_S"] + env.update({"LLM_TIMEOUT": "317", "LLM_TTFT_TIMEOUT": "41", "LLM_INTER_TOKEN_TIMEOUT": "19"}) + settings = GatewaySettings.from_env("LLM", env=env) + assert settings.sources[0].timeout_s == 317 + assert settings.sources[0].ttft_timeout_s == 41 + assert settings.sources[0].inter_token_timeout_s == 19 + assert settings.retry.max_attempts == 3 + client = GatewayClient.from_settings(settings) + try: + assert client._transport._clients == {} + finally: + await client.aclose() + + +@pytest.mark.parametrize("model", ["MiniMax-M2.7", "MiniMax-M3"]) +def test_live_assembly_rejection_is_local_only(model): + """M2.7 NONE 与 M3 AUTO 的旧 live 装配断言离线执行。""" + env = _env(**{"LLM__QWEN__1__MODEL": model}) + settings = GatewaySettings.from_env("LLM", env=env) + source = dataclasses.replace( + settings.sources[0], provider="minimax", enable_thinking=model == "MiniMax-M3" + ) + with pytest.raises(ValueError, match=model): + GatewayClient.from_settings(dataclasses.replace(settings, sources=(source,))) + + +def test_live_unknown_wire_assembly_is_local_only(): + """L9 明确全 None profile,不从当前默认注册表猜未知形态。""" + mystery = ProviderProfile( + name="mystery", + thinking=ThinkingWire(off=None, on_base=None, effort_key=None), + strip_think_tags=False, + ) + settings = GatewaySettings.from_env("LLM", env=_BASE_ENV) + source = dataclasses.replace(settings.sources[0], provider="mystery", enable_thinking=False) + with pytest.raises(ValueError, match="register_provider"): + GatewayClient.from_settings( + dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery) + ) diff --git a/tests/unit/test_live_evidence.py b/tests/unit/test_live_evidence.py new file mode 100644 index 0000000..7a4d86c --- /dev/null +++ b/tests/unit/test_live_evidence.py @@ -0,0 +1,816 @@ +"""live_evidence 与测试侧取证装配的日常离线反例。""" + +import asyncio +import inspect +import json +from dataclasses import replace + +import httpx +import pytest + +from polygateway.errors import RequestRejectedError, SourceDeadError, TransientError +from polygateway.ports import EmbeddingTransport, Transport +from polygateway.transports.openai_compat import OpenAICompatTransport +from polygateway.types import SourceConfig +from polygateway.types import ThinkingObservation as O +from tests.e2e.conftest import LiveCapture, ObservedTransport +from tests.live_evidence import ( + AttemptEvidence, + HttpEvidence, + LiveVerdict, + assess_expected_rejection, + assess_model_identity, + assess_thinking_coverage, + classify_live_failure, + messages_digest, + request_is_valid, + safe_attempts, + summarize_verdicts, + write_live_round, +) + +_CHECKS = tuple( + (key, True) + for key in ( + "method", + "origin", + "path", + "model", + "stream", + "authorization", + "control", + "messages_digest", + ) +) +_BODY = b'{"error":{"type":"model_not_found"}}' +_SECRET = "fake-unique-credential-sentinel" +_PROMPT = "fake-private-prompt-sentinel" +_MESSAGES = [{"role": "user", "content": _PROMPT}] + + +def _failure(*, body=_BODY, status=404, checks=_CHECKS): + """完整的唯一错误证据,供逐维破坏。""" + error = RequestRejectedError("safe", status_code=status) + event = HttpEvidence("a", checks, status, body, (False, None)) + return error, (AttemptEvidence("a", (event,), error),) + + +def test_exact_model_not_found_is_uncovered(): + error, attempts = _failure() + assert classify_live_failure(error, attempts).status == "UNCOVERED" + + +@pytest.mark.parametrize( + "body", + [ + None, + b"", + b"{", + b"[]", + b'{"error":[]}', + b'{"error":{"type":7}}', + b'{"error":{"message":"model_not_found"}}', + b'{"error":{"type":"MODEL_NOT_FOUND"}}', + b'{"error":{"type":"wrong","type":"model_not_found"}}', + b'{"error":{},"error":{"type":"model_not_found"}}', + b"\xff", + _BODY + b" " * 65536, + ], +) +def test_incomplete_or_ambiguous_error_body_fails(body): + assert classify_live_failure(*_failure(body=body)).status == "FAIL" + + +@pytest.mark.parametrize("status", [400, 401, 403, 429, 500, 503]) +def test_status_classes_never_automatically_skip(status): + assert classify_live_failure(*_failure(status=status)).status == "FAIL" + + +@pytest.mark.parametrize( + "error", + [TransientError("safe"), SourceDeadError("safe"), ValueError("safe"), AssertionError("safe")], +) +def test_whole_exception_class_skip_is_forbidden(error): + assert classify_live_failure(error, ()).status == "FAIL" + + +@pytest.mark.parametrize("key", [key for key, _ in _CHECKS]) +@pytest.mark.parametrize("mode", ["missing", "false", "duplicate"]) +def test_all_request_checks_are_required(key, mode): + checks = tuple( + (k, False if k == key and mode == "false" else v) + for k, v in _CHECKS + if mode != "missing" or k != key + ) + if mode == "duplicate": + checks += ((key, True),) + assert classify_live_failure(*_failure(checks=checks)).status == "FAIL" + + +@pytest.mark.parametrize( + "mode", + ["empty", "second_attempt", "second_http", "wrong_id", "different_error", "status_mismatch"], +) +def test_attempt_pairing_cannot_be_guessed(mode): + error, attempts = _failure() + first = attempts[0] + if mode == "empty": + attempts = (replace(first, http=()),) + elif mode == "second_attempt": + attempts += (AttemptEvidence("b", (), TransientError("safe")),) + elif mode == "second_http": + attempts = (replace(first, http=first.http * 2),) + elif mode == "wrong_id": + attempts = (replace(first, call_id="other"),) + elif mode == "different_error": + attempts = (replace(first, error=RequestRejectedError("safe", status_code=404)),) + else: + attempts = (replace(first, http=(replace(first.http[0], status_code=400),)),) + assert classify_live_failure(error, attempts).status == "FAIL" + + +@pytest.mark.parametrize( + ("reported", "raw", "expected"), + [ + ("m", (False, None), "PASS"), + ("alias", (True, "alias"), "PASS"), + (None, (False, None), "FAIL"), + ("other", (False, None), "FAIL"), + (None, (True, None), "UNCOVERED"), + ("other", (True, "other"), "UNCOVERED"), + (None, (True, "m"), "FAIL"), + ("wrong", (True, "m"), "FAIL"), + ("m", (True, None), "FAIL"), + ], +) +def test_identity_requires_independent_raw_evidence(reported, raw, expected): + assert ( + assess_model_identity( + requested="m", + aliases=frozenset({"alias"}), + reported=reported, + raw_identity=raw, + request_valid=True, + ).status + == expected + ) + assert ( + assess_model_identity( + requested="m", + aliases=frozenset(), + reported=reported, + raw_identity=raw, + request_valid=False, + ).status + == "FAIL" + ) + + +@pytest.mark.parametrize( + ("proposition", "observations", "expected"), + [ + ("enabled", [O.OBSERVED, O.OBSERVED, O.UNKNOWN], "PASS"), + ("enabled", [O.UNKNOWN] * 3, "UNCOVERED"), + ("enabled", [O.ABSENT] * 3, "FAIL"), + ("disabled", [O.ABSENT] * 3, "PASS"), + ("disabled", [O.ABSENT, O.UNKNOWN, O.ABSENT], "UNCOVERED"), + ("disabled", [O.OBSERVED, O.UNKNOWN, O.ABSENT], "FAIL"), + ("cannot_disable", [O.OBSERVED, O.UNKNOWN, O.ABSENT], "PASS"), + ("cannot_disable", [O.ABSENT] * 3, "FAIL"), + ("cannot_disable", [O.UNKNOWN] * 3, "UNCOVERED"), + ], +) +def test_coverage_is_proposition_specific(proposition, observations, expected): + assert ( + assess_thinking_coverage(observations, planned_rounds=3, proposition=proposition).status + == expected + ) + + +def test_missing_round_never_reduces_denominator(): + assert ( + assess_thinking_coverage([O.OBSERVED] * 2, planned_rounds=3, proposition="enabled").status + == "FAIL" + ) + assert summarize_verdicts( + [LiveVerdict("PASS", "ok"), LiveVerdict("FAIL", "bad")], planned_rounds=3 + ) == {"PASS": 1, "FAIL": 1, "UNCOVERED": 0, "missing": 1} + + +def test_expected_400_is_a_separate_negative_proposition(): + error, attempts = _failure(status=400, body=b'{"error":{"type":"unsupported_value"}}') + assert classify_live_failure(error, attempts).status == "FAIL" + assert ( + assess_expected_rejection( + error, attempts, status_code=400, machine_type="unsupported_value" + ).status + == "PASS" + ) + assert ( + assess_expected_rejection(error, attempts, status_code=400, machine_type="other").status + == "FAIL" + ) + + +def _source(): + """假凭据与非默认超时,不接真实网络。""" + return SourceConfig( + name="source", + provider="openai", + model="gpt-5.5", + base_url="https://example.test/v1", + api_key=_SECRET, + timeout_s=137, + trust_env=False, + ) + + +def _capture(*, stream=False, embedding=False, **changes): + """预期独立于生产 payload。""" + expected = { + "model": "gpt-5.5", + "origin": "https://example.test", + "path": "/v1/embeddings" if embedding else "/v1/chat/completions", + "control": {}, + "messages_digest": messages_digest([_PROMPT] if embedding else _MESSAGES), + } + expected.update({"input_shape": 1} if embedding else {"stream": stream}) + expected.update(changes) + return LiveCapture(expectations={"source": expected}) + + +def _response(model="gpt-5.5"): + """OpenAI 非流式完整响应形态。""" + result = { + "id": "test", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3}, + } + if model != "missing": + result["model"] = model + return result + + +def _real_transport(capture, handler, *, tamper=None): + """仅替换 HTTP 边界,保留真实 hooks 与 production transport。""" + + def factory(source): + """在网络边界换成 MockTransport。""" + client = capture.client_factory(source) + client._transport = httpx.MockTransport(handler) + if tamper is not None: + client.event_hooks["request"].insert(0, tamper) + return client + + return OpenAICompatTransport(client_factory=factory) + + +async def _complete(observed, *, call_id="a", stream=False): + """沿原参数调用薄委托器。""" + return await observed.complete( + messages=_MESSAGES, + source=_source(), + stream=stream, + overlay={}, + call_id=call_id, + reasoning_effort=None, + ) + + +@pytest.mark.parametrize("model", ["gpt-5.5", "missing", None]) +async def test_raw_identity_snapshot_reaches_round_consumer(model): + capture = _capture() + real = _real_transport(capture, lambda request: httpx.Response(200, json=_response(model))) + try: + with capture.round_context(session_id="s", parent_call_id="p"): + result = await _complete(ObservedTransport(real, capture)) + raw = capture.raw_identity(session_id="s", parent_call_id="p", call_id="a") + assert raw == (True, None if model == "missing" else model) + assert request_is_valid(capture.attempts(session_id="s", parent_call_id="p")[0].http[0]) + verdict = assess_model_identity( + requested="gpt-5.5", + aliases=frozenset(), + reported=result.model_reported, + raw_identity=raw, + request_valid=True, + ) + assert verdict.status == ("PASS" if model == "gpt-5.5" else "UNCOVERED") + if model == "gpt-5.5": + assert ( + assess_model_identity( + requested="gpt-5.5", + aliases=frozenset(), + reported=None, + raw_identity=raw, + request_valid=True, + ).status + == "FAIL" + ) + finally: + await real.aclose() + + +@pytest.mark.parametrize( + "body", [b"[]", b"{", b'{"model":"a","model":"b"}', b'{"model":7}', b"\xff"] +) +async def test_invalid_raw_json_is_not_missing_identity(body): + capture = _capture() + real = _real_transport(capture, lambda request: httpx.Response(200, content=body)) + try: + with ( + capture.round_context(session_id="s", parent_call_id="p"), + pytest.raises((ValueError, AttributeError, KeyError, TransientError)), + ): + await _complete(ObservedTransport(real, capture)) + assert capture.raw_identity(session_id="s", parent_call_id="p", call_id="a") == ( + False, + None, + ) + assert capture.notes(session_id="s", parent_call_id="p") + finally: + await real.aclose() + + +@pytest.mark.parametrize( + "field", ["model", "authorization", "path", "stream", "control", "messages_digest"] +) +async def test_hooks_detect_corrupted_request_without_repair(field): + capture = _capture() + + async def tamper(request): + """故意改错实发请求,而不是改预期。""" + if field == "authorization": + request.headers["Authorization"] = "Bearer wrong" + elif field == "path": + request.url = request.url.copy_with(path="/wrong") + else: + body = json.loads(request.content) + key, value = { + "model": ("model", "wrong"), + "stream": ("stream", True), + "control": ("reasoning_effort", "high"), + "messages_digest": ("messages", []), + }[field] + body[key] = value + request._content = json.dumps(body).encode() + + real = _real_transport( + capture, lambda request: httpx.Response(404, content=_BODY), tamper=tamper + ) + try: + with ( + capture.round_context(session_id="s", parent_call_id="p"), + pytest.raises(RequestRejectedError) as caught, + ): + await _complete(ObservedTransport(real, capture)) + attempts = capture.attempts(session_id="s", parent_call_id="p") + assert dict(attempts[0].http[0].request_checks)[field] is False + assert classify_live_failure(caught.value, attempts).status == "FAIL" + finally: + await real.aclose() + + +async def test_embed_uses_same_capture_and_source_factory_contract(): + capture = _capture(embedding=True) + real = _real_transport( + capture, + lambda request: httpx.Response( + 200, + json={"data": [{"index": 0, "embedding": [0.1, 0.2]}], "usage": {"prompt_tokens": 1}}, + ), + ) + try: + with capture.round_context(session_id="s", parent_call_id="p"): + result = await ObservedTransport(real, capture).embed( + texts=[_PROMPT], source=_source(), call_id="e" + ) + assert result.dim == 2 + assert request_is_valid(capture.attempts(session_id="s", parent_call_id="p")[0].http[0]) + client = real._clients["source"] + assert client.timeout.read == 137 and client.trust_env is False + assert client.headers["Authorization"] == f"Bearer {_SECRET}" + finally: + await real.aclose() + assert real._clients == {} + + +class _SSE(httpx.AsyncByteStream): + """可检查是否被 hook 提前消费的 SSE。""" + + def __init__(self): + self.reads = 0 + self.closed = False + + async def __aiter__(self): + self.reads += 1 + yield b'data: {"model":"gpt-5.5","choices":[{"delta":{"content":"ok"},"finish_reason":null}]}\n\n' + yield b'data: {"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}\n\ndata: [DONE]\n\n' + + async def aclose(self): + self.closed = True + + +async def test_success_sse_is_not_preconsumed_and_has_no_raw_snapshot(): + capture = _capture(stream=True) + stream = _SSE() + + def handler(request): + return httpx.Response(200, stream=stream) + + def factory(source): + client = capture.client_factory(source) + + async def after_hook(response): + assert stream.reads == 0 + + client.event_hooks["response"].append(after_hook) + client._transport = httpx.MockTransport(handler) + return client + + real = OpenAICompatTransport(client_factory=factory) + try: + with capture.round_context(session_id="s", parent_call_id="p"): + result = await _complete(ObservedTransport(real, capture), stream=True) + assert result.content == "ok" and stream.reads == 1 and stream.closed + raw = capture.raw_identity(session_id="s", parent_call_id="p", call_id="a") + assert raw == (False, None) + assert ( + assess_model_identity( + requested="gpt-5.5", + aliases=frozenset(), + reported=None, + raw_identity=raw, + request_valid=True, + ).status + == "FAIL" + ) + finally: + await real.aclose() + + +async def test_concurrent_rounds_retry_and_cancellation_reset_context(): + capture = _capture() + reached = asyncio.Event() + + async def handler(request): + await asyncio.sleep(0) + return httpx.Response(200, json=_response()) + + real = _real_transport(capture, handler) + observed = ObservedTransport(real, capture) + + async def one(parent): + with capture.round_context(session_id="s", parent_call_id=parent): + # 零 HTTP 的前次失败与成功 call_id 不得混淆。 + with pytest.raises(ValueError), capture.attempt_context(parent + "-failed"): + raise ValueError("safe") + await _complete(observed, call_id=parent + "-ok") + return capture.raw_identity(session_id="s", parent_call_id=parent, call_id=parent + "-ok") + + async def cancelled(): + with ( + capture.round_context(session_id="s", parent_call_id="cancel"), + capture.attempt_context("cancelled"), + ): + reached.set() + await asyncio.Future() + + try: + assert await asyncio.gather(one("p1"), one("p2")) == [(True, "gpt-5.5")] * 2 + for parent in ("p1", "p2"): + attempts = capture.attempts(session_id="s", parent_call_id=parent) + assert len(attempts) == 2 and len(attempts[0].http) == 0 and len(attempts[1].http) == 1 + with pytest.raises(ValueError, match="跨轮"): + capture.raw_identity(session_id="s", parent_call_id="p1", call_id="p2-ok") + task = asyncio.create_task(cancelled()) + await reached.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert len(capture.attempts(session_id="s", parent_call_id="cancel")) == 1 + with pytest.raises(LookupError): + capture._round.get() + with pytest.raises(LookupError): + capture._attempt.get() + with ( + capture.round_context(session_id="s", parent_call_id="new"), + pytest.raises(ValueError, match="重复"), + capture.attempt_context("p1-ok"), + ): + pytest.fail("不应进入") + finally: + await real.aclose() + + +def test_delegator_signatures_match_ports(): + assert inspect.signature(ObservedTransport.complete) == inspect.signature(Transport.complete) + assert inspect.signature(ObservedTransport.embed) == inspect.signature(EmbeddingTransport.embed) + + +def test_missing_expectations_fail_before_network(): + with pytest.raises(ValueError): + LiveCapture(expectations={"s": {"model": "m"}}) + + +def test_round_reports_preserve_prior_round_and_redact_sentinels(tmp_path): + error, attempts = _failure( + body=(f'{{"error":{{"type":"model_not_found","message":"{_SECRET} {_PROMPT}"}}}}').encode() + ) + error.args = (f"{_SECRET} {_PROMPT}",) + first = write_live_round( + tmp_path, + run_id="run", + matrix_id="matrix", + round_index=1, + safe_fields={"status": "PASS", "attempts": safe_attempts(attempts)}, + ) + second = write_live_round( + tmp_path, + run_id="run", + matrix_id="matrix", + round_index=2, + safe_fields={"status": "FAIL", "attempts": safe_attempts(attempts)}, + ) + assert first != second and first.exists() + for path in tmp_path.rglob("*.md"): + text = path.read_text() + assert _SECRET not in text and _PROMPT not in text + assert '"status": "PASS"' in first.read_text() + assert '"status": "FAIL"' in second.read_text() + + +@pytest.mark.parametrize( + "fields", + [ + {"raw": _BODY}, + {"error_type": ValueError("secret")}, + {"attempts": [AttemptEvidence("a", (), None)]}, + {"attempts": [{"call_id": "a", "error_type": None, "http": [{"Authorization": "secret"}]}]}, + ], +) +def test_report_refuses_raw_objects_and_unknown_fields(tmp_path, fields): + with pytest.raises((ValueError, TypeError)): + write_live_round( + tmp_path, run_id="run", matrix_id="matrix", round_index=1, safe_fields=fields + ) + + +def test_report_write_failure_is_not_skip(tmp_path): + blocked = tmp_path / "file" + blocked.write_text("not a directory") + with pytest.raises(OSError): + write_live_round( + blocked, + run_id="run", + matrix_id="matrix", + round_index=1, + safe_fields={"status": "UNCOVERED"}, + ) + + +@pytest.mark.parametrize( + "body", [b"", _BODY, _BODY + b" " * (65536 - len(_BODY)), _BODY + b" " * 65536] +) +async def test_error_snapshot_keeps_only_complete_bounded_body(body): + """真实 hook 的边界,不把生产摘要当完整错误 JSON。""" + capture = _capture() + real = _real_transport(capture, lambda request: httpx.Response(404, content=body)) + try: + with ( + capture.round_context(session_id="s", parent_call_id="p"), + pytest.raises(RequestRejectedError) as caught, + ): + await _complete(ObservedTransport(real, capture)) + attempts = capture.attempts(session_id="s", parent_call_id="p") + assert attempts[0].http[0].error_body == (body if len(body) <= 65536 else None) + expected = "UNCOVERED" if 0 < len(body) <= 65536 else "FAIL" + assert classify_live_failure(caught.value, attempts).status == expected + finally: + await real.aclose() + + +async def test_multiple_success_http_candidates_fail_identity_accessor(): + """一个 attempt 多 HTTP 不能猜最后一条。""" + capture = _capture() + client = capture.client_factory(_source()) + client._transport = httpx.MockTransport(lambda request: httpx.Response(200, json=_response())) + try: + with ( + capture.round_context(session_id="s", parent_call_id="p"), + capture.attempt_context("a"), + ): + for _ in range(2): + await client.post( + "https://example.test/v1/chat/completions", + json={"model": "gpt-5.5", "stream": False, "messages": _MESSAGES}, + ) + with pytest.raises(ValueError, match="多个成功"): + capture.raw_identity(session_id="s", parent_call_id="p", call_id="a") + finally: + await client.aclose() + + +@pytest.mark.parametrize( + "body", [b"data: not-json\n\n", b'data: {"choices":[]}\n\ndata: [DONE]\n\n'] +) +async def test_sse_parser_failure_is_not_external_uncovered(body): + capture = _capture(stream=True) + real = _real_transport(capture, lambda request: httpx.Response(200, content=body)) + try: + with ( + capture.round_context(session_id="s", parent_call_id="p"), + pytest.raises(TransientError) as caught, + ): + await _complete(ObservedTransport(real, capture), stream=True) + assert ( + classify_live_failure( + caught.value, capture.attempts(session_id="s", parent_call_id="p") + ).status + == "FAIL" + ) + finally: + await real.aclose() + + +async def test_round_consumer_keeps_first_success_when_second_assertion_fails(tmp_path): + """真实 GatewayClient 与报告出口连通;断言异常不得漏报告。""" + from polygateway import GatewaySettings + from tests.e2e.conftest import captured_chat_round, observed_client + from tests.unit.test_config import _BASE_ENV + + settings = GatewaySettings.from_env("LLM", env=_BASE_ENV) + settings = replace(settings, sources=(_source(),)) + capture = _capture() + original_factory = capture.client_factory + clients = [] + + def factory(source): + client = original_factory(source) + client._transport = httpx.MockTransport( + lambda request: httpx.Response(200, json=_response()) + ) + clients.append(client) + return client + + capture.client_factory = factory + + def fail(response): + raise AssertionError(_SECRET + _PROMPT) + + async with observed_client(settings, capture) as client: + for index, validate in ((1, None), (2, fail)): + _, verdict = await captured_chat_round( + client, + capture, + run_id="run", + matrix_id="matrix", + round_index=index, + output_dir=tmp_path, + messages=_MESSAGES, + models={"source": "gpt-5.5"}, + aliases={}, + stream=False, + validate=validate, + ) + assert verdict.status == ("PASS" if index == 1 else "FAIL") + paths = list(tmp_path.rglob("*.md")) + assert len(paths) == 2 + text = "".join(path.read_text() for path in paths) + assert '"status": "PASS"' in text and '"status": "FAIL"' in text + assert _SECRET not in text and _PROMPT not in text + assert clients and all(client.is_closed for client in clients) + + +def test_partial_uncovered_and_failed_rounds_never_become_model_pass(): + from tests.live_evidence import combine_live_verdicts, qualify_live_rounds + + passed = LiveVerdict("PASS", "safe") + uncovered = LiveVerdict("UNCOVERED", "safe") + failed = LiveVerdict("FAIL", "safe") + assert combine_live_verdicts([passed, uncovered]).status == "UNCOVERED" + assert combine_live_verdicts([passed, uncovered, failed]).status == "FAIL" + assert qualify_live_rounds([passed, passed], planned_rounds=3).status == "FAIL" + assert qualify_live_rounds([passed, failed, passed], planned_rounds=3).status == "FAIL" + + +async def test_real_retry_success_call_id_selects_exact_raw_identity(tmp_path): + """真实 RetryMW 先503再成功,两个并发 parent 的身份不串线。""" + from polygateway import GatewaySettings + from tests.e2e.conftest import captured_chat_round, observed_client + from tests.unit.test_config import _BASE_ENV + + settings = GatewaySettings.from_env("LLM", env=_BASE_ENV) + settings = replace( + settings, + sources=(_source(),), + retry=replace(settings.retry, backoff_base_s=0.001, backoff_max_s=0.002), + ) + capture = _capture() + original_factory = capture.client_factory + counts = {} + + async def handler(request): + key = capture._round.get() + counts[key] = counts.get(key, 0) + 1 + await asyncio.sleep(0) + if counts[key] == 1: + return httpx.Response(503, json={"error": {"type": "temporary"}, "model": "wrong"}) + return httpx.Response(200, json=_response()) + + def factory(source): + client = original_factory(source) + client._transport = httpx.MockTransport(handler) + return client + + capture.client_factory = factory + async with observed_client(settings, capture) as client: + + async def one(index): + return await captured_chat_round( + client, + capture, + run_id="retry", + matrix_id="retry", + round_index=index, + output_dir=tmp_path, + messages=_MESSAGES, + models={"source": "gpt-5.5"}, + aliases={}, + stream=False, + ) + + results = await asyncio.gather(one(1), one(2)) + assert all(verdict.status == "PASS" for _, verdict in results) + assert len(counts) == 2 and set(counts.values()) == {2} + for key in counts: + attempts = capture.attempts(session_id=key[0], parent_call_id=key[1]) + assert len(attempts) == 2 + assert attempts[0].http[0].raw_identity == (False, None) + assert capture.raw_identity( + session_id=key[0], parent_call_id=key[1], call_id=attempts[1].call_id + ) == (True, "gpt-5.5") + + +def test_live_model_provider_mapping_covers_default_capabilities_without_env_import(): + """L8 映射装配守卫离线化,只读取显式字面矩阵,不执行 .env 模块。""" + import ast + from pathlib import Path + + from polygateway.thinking import DEFAULT_CAPABILITIES + + tree = ast.parse((Path(__file__).parents[1] / "e2e/test_thinking_live.py").read_text()) + mapping = next( + ast.literal_eval(node.value) + for node in tree.body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "_MODEL_PROVIDER" + for target in node.targets + ) + ) + assert not set(DEFAULT_CAPABILITIES) - mapping.keys() + + +async def test_unbuffered_error_is_insufficient_evidence(): + """response hook 不预读错误流;未完成缓冲不能借片段归因。""" + capture = _capture() + stream = _SSE() + client = capture.client_factory(_source()) + client._transport = httpx.MockTransport(lambda request: httpx.Response(404, stream=stream)) + error = RequestRejectedError("safe", status_code=404) + try: + with ( + pytest.raises(RequestRejectedError), + capture.round_context(session_id="s", parent_call_id="p"), + capture.attempt_context("a"), + ): + async with client.stream( + "POST", + "https://example.test/v1/chat/completions", + json={"model": "gpt-5.5", "messages": _MESSAGES, "stream": False}, + ): + raise error + attempts = capture.attempts(session_id="s", parent_call_id="p") + assert attempts[0].http[0].error_body is None and stream.reads == 0 + assert classify_live_failure(error, attempts).status == "FAIL" + finally: + await client.aclose() + + +def test_pytest_report_hook_preserves_report_and_uses_safe_fallback(tmp_path, monkeypatch): + """pytest wrapper generator 的 return 值是协议必需,不可按错误 LSP 建议删除。""" + from types import SimpleNamespace + + from tests.e2e.conftest import pytest_runtest_makereport + + monkeypatch.chdir(tmp_path) + report = SimpleNamespace(skipped=False, failed=True, when="call") + hook = pytest_runtest_makereport(SimpleNamespace(nodeid="safe-node"), None) + assert next(hook) is None + with pytest.raises(StopIteration) as finished: + hook.send(report) + assert finished.value.value is report + paths = list(tmp_path.rglob("*.md")) + assert len(paths) == 1 and '"status": "FAIL"' in paths[0].read_text() From d332287b28159e17485a792eefc23537e443d006 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 02:41:25 -0400 Subject: [PATCH 13/17] docs: document reasoning ownership and explicit cache migration --- .env.example | 15 ++-- CHANGELOG.md | 47 +++++++------ README.md | 70 ++++++++++++++----- research-wiki/ARCHITECTURE.md | 66 ++++++++++------- .../2026-09-04-reasoning-effort-design.md | 34 ++++----- ...09-09-134-thinking-contracts-validation.md | 55 ++++++++++++++- research-wiki/graph/edges.json | 7 ++ research-wiki/index.md | 8 ++- research-wiki/log.md | 2 + .../metrics/call-telemetry-coverage.md | 10 +++ .../2026-09-09-134-thinking-contracts.md | 14 ++-- research-wiki/schemas/llm-calls.md | 22 +++--- 12 files changed, 252 insertions(+), 98 deletions(-) diff --git a/.env.example b/.env.example index bde9761..e76b13c 100644 --- a/.env.example +++ b/.env.example @@ -17,11 +17,13 @@ LLM__QWEN__1__TIMEOUT_S=120 # 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=要求关闭 -# 本键是 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 # 本源默认推理档位;缺省=不表态(随模型自己的默认档) +# 本键是语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态。 +# 已登记模型须清单含 AUTO 才接受 true;nearest 不代选强度。 +# 未登记仍尽力+warning,空 wire 可能零推理字节,不保证开启。 +# M3 删除糖并选 medium 等表内档;M2.5/M2.7 AUTO 不再偷带 medium。 +# 完整 M1–M9 与缺测见 README「1.3.4 推理配置迁移」。 +# 有受管意图时 EXTRA_BODY/overlay 推理控制同值也拒绝;raw-only 须退出所有意图。 +# LLM__QWEN__1__REASONING_EFFORT=auto # 本源默认推理档位;缺省=不表态(随模型自己的默认档) # 八档(封闭词汇): none | auto | minimal | low | medium | high | xhigh | max # none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度 # 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low), @@ -114,6 +116,9 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填) # PGW_PRICING_PATH=config/prices.json # 可选: {"": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None # # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价; # # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高 +# 推理语义/fallback/能力表/wire 变化前须换从未使用的新 namespace 或 salt。 +# 保留租户前缀与 epoch;per-call 覆盖也要迁移,只改此处无效。 +# 未迁移仍可回放旧语义并绕过新拒绝;回滚旧身份会重见旧值,库不自动隔离。 # PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化) # PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0 # PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略) diff --git a/CHANGELOG.md b/CHANGELOG.md index f32186b..0aef3d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 未发布(1.3.4) + +- **推理意图**:已登记 AUTO 必须为能力清单成员,True 糖同约束;nearest 不代选强度。MiniMax on_base 改空,M3 要显式选 medium 等登记档;M2.5/M2.7 空 wire 真实复验待完成。未知仍尽力+warning,不保证开启。 +- **所有权**:受管意图下两层 raw 推理控制同值/被遮蔽也拒绝;raw-only 与普通采样浅覆盖保留。自定义 on_base 禁止偷带强度。 +- **缓存迁移前置**:受影响调用更换从未承载旧语义的 namespace/salt;同版本 fallback、能力表、wire 变化亦需迁移。不加自动指纹,未迁移仍可回放旧语义。M1–M9、per-call/多源/回滚示例见 README。 +- **测试证据**:默认 FAIL,不整类 skip;404 仅完整独立证据可未覆盖,公共身份缺失无独立证据 FAIL。逐轮脱敏,UNKNOWN/SKIP/缺轮不算关闭覆盖;不可关闭与预期拒绝独立判定。缺型号级 400 机器字段基线仍 FAIL,不编造白名单。 +- **遥测守卫**:真实客户端/临时 SQLite 的 embedding、OCR 双入口成败 NULL、chat 阳性与四种行来源回归;不新增 schema、生产端口或成功 SSE 捕获器。 + ## 1.3.3(2026-09-05) 推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。 @@ -9,7 +17,7 @@ ### 请先读这一条(一):五处破坏性变更 | # | 位置 | 变更 | 谁会当场断 | -|---|---|---|---| +| --- | --- | --- | --- | | 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 实现 | @@ -19,7 +27,7 @@ 第 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="…")` | @@ -29,7 +37,7 @@ ### 请先读这一条(二):不改一行代码也会变的四条行为 | # | 变更 | 影响 | -|---|---|---| +| --- | --- | --- | | 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 **官方**字段而非厂商方言,经网关的兼容端点不会把它打到不认识它的厂商 | @@ -42,7 +50,7 @@ ### 新增能力 | 新增 | 说明 | -|---|---| +| --- | --- | | 八档 `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` 在部分模型上是数倍账单 | @@ -68,7 +76,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` 已实测且与清单逐字相符,可作旁证但不是本型号的证据 | @@ -95,7 +103,7 @@ 新增可选参数 `--table .llm_calls`:给了它,目标由参数精确解析(`to_regclass` 走引号限定名),绕开 `search_path`。 | 情形 | 行为 | -|---|---| +| --- | --- | | 不给 `--table` | **与 1.3.1 完全一致**,现有 cron 不受影响;但 `--apply` 时会多打印一行,提示目标是推断来的 | | 表名段不是 `llm_calls` | 退出 **1**。本脚本只清理遥测表,不是通用清理器——一次 `--table audit.events` 的手误,会对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE | | 显式指定的表不存在/不可见 | 退出 **2**,消息附一句"PG 中未加引号建的标识符在 catalog 里是小写"(大小写手误是这里的高频原因) | @@ -122,7 +130,7 @@ issue #18 报的是一条 PG 集成测试偶发红。查下来失败的断言并 推理相关的**六个符号**从 `providers.py` 移进新模块 `polygateway.thinking`。`from polygateway.providers import ...` 引用其中任何一个,升级后当场 `ImportError`: | 从 `providers` 断掉的符号 | 改成(**推荐**) | 或 | -|---|---|---| +| --- | --- | --- | | `ThinkingCapability`、`ThinkingUnsupportedError` | `from polygateway import ...` | `from polygateway.thinking import ...` | | `get_capability`、`register_capability`、`resolve_thinking` | `from polygateway import ...` | `from polygateway.thinking import ...` | | `DEFAULT_CAPABILITIES` | `from polygateway.thinking import DEFAULT_CAPABILITIES` | — | @@ -158,7 +166,7 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 `LLMResponse.thinking_observation`(类型 `ThinkingObservation`,`StrEnum`,缺省 `unknown`)由多信号裁定,判据按**证据硬度**排序: | 值 | 判据 | -|---|---| +| --- | --- | | `observed` | 推理正文 `thinking` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) | | `absent` | `reasoning_tokens == 0`——上游明确上报本次未推理,是正面证据 | | `unknown` | 两个信号双缺,判不出来 | @@ -174,7 +182,7 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 本版在 transport 拿到结果处做一次比较,矛盾即 warning(**不抛错**——一次观测不足以否决一次成功的调用,矛盾结果已随响应与遥测落地,处置权归下游): | 请求方向 | 观测 | 告警内容 | -|---|---|---| +| --- | --- | --- | | 关闭 | `observed` | 关闭请求未被满足。能力表已登记则点出 `evidence` 日期并指路复测更新;未登记则说明本次是按 provider 形态尽力注入 | | 开启 | `absent` | 已注入开启参数,上游却明确上报未推理 | | 开启 | `unknown` | 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传 | @@ -196,7 +204,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 - `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。 - 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。 - ## 1.3.0(2026-08-24) 遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。 @@ -204,7 +211,7 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 根因不是"asyncpg 的默认 `min_size=10` 太大"这一条,而是四层叠加,只改默认值会留下三层: | # | 缺陷 | 本版 | -|---|---|---| +| --- | --- | --- | | ① | 库对自己的资源占用从未表态 —— `create_pool(dsn, timeout=10)` 继承第三方默认值,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限":要么一次拿到 10 条,要么建池失败。这是全库唯一一处预占资源的组件 | `min_size=0` + `max_size` 可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4)+ 每次写入硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5.0s) | | ② | 判死判据挂在"**哪一步**失败"(建池失败即永久判死),而那一步里同时藏着 DSN 写错(进程内不可能改变)与 `too many clients`(下一秒可能就好) | 判据改挂"失败是**什么性质**",永久失能收窄到只剩 DSN 不可解析一类,其余一律 60s 冷却后自动重试 | | ③ | 降级不可恢复也不可见 —— 全程只有一条 warning,SQLite 侧连 warning 都没有 | 进入/恢复各一条日志 + 降级期间节流复述 + `client.telemetry_status` 只读快照 | @@ -241,7 +248,7 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 判据两句话:**致命 = 失败原因完全在进程内部且不可变**;**行级 vs 环境级看"失败与这一行的数据有没有关系"**。 | 档 | 覆盖 | 处置 | -|---|---|---| +| --- | --- | --- | | 配置级致命 | DSN 不可解析(`ClientConfigurationError`)、建池参数非法 | 永久 no-op + 一条 **error**(这是人配错了,不是 warning) | | 环境级不可用 | 连接类 `08` / 资源不足 `53`(含 53300 too many connections)/ 管理干预 `57` / 认证 `28` / 库不存在 `3D`,以及 `42501` 无权限、`42P01` 表不存在;网络类异常;**超时类异常仅在准备期路径可达**(写入期的超时先被 `record_llm_call` 的 `except TimeoutError` 接住,按行级丢弃);表确定不存在且建不出来 | **冷却 60s 后自动重试一次**,成功即恢复。DBA 建完表、放开权限、PG 重启完毕,进程都不必重启 | | 行级拒绝 | 其余数据与约束类错误(`22`/`23` 等),外加**唯一具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级 | @@ -251,7 +258,7 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 ### 新增公共 API | 名字 | 内容 | -|---|---| +| --- | --- | | `GatewayClient.telemetry_status` / `EmbeddingClient.telemetry_status` / `OcrClient.telemetry_status` | `TelemetryStatus \| None` 只读属性。`None` = 未启用遥测,或注入的 recorder 不提供状态 | | `polygateway.TelemetryStatus`(顶层导出) | frozen dataclass: `degraded` / `fatal` / `reason` / `degraded_for_s` / `dropped_rows` / `retry_after_s`。下游可据此对账或告警,不必再人工比对行数 | | `ports.TelemetryStatusProvider` | 新增的**独立**可选端口。`TelemetryRecorder` **逐字未变**——它是 `@runtime_checkable`,往里加成员会让所有只实现 `record_llm_call` 的对象当场不再满足协议,下游的同款 `isinstance` 断言升级即断 | @@ -265,7 +272,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 - SQLite 遥测初始化失败后终于有日志了。此前 `sqlite.py` 初始化失败直接 `return`,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版**只做可见性**,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。 - 写入路径不再用 `async with pool.acquire(...)`。`Pool.release()` 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。 - ## 1.2.4(2026-08-20) 熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。 @@ -287,7 +293,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 - `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。 - `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。 - ## 1.2.3(2026-08-19) 遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。 @@ -310,7 +315,7 @@ CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app 照抄过就请现在查这两条: | 查什么 | 中招的样子 | -|---|---| +| --- | --- | | `SELECT count(*) FROM llm_calls;`,且必须用能**绕过 RLS** 的角色(superuser 或带 `BYPASSRLS` 属性的角色)——`FORCE` 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" | 启用 RLS 之后一直是 0,或从某个时刻起不再增长 | | 应用日志里遥测写入的降级告警,前缀 `Postgres 遥测写入失败(丢弃该行):` | 每次调用刷一条,附带的 PG 原话是 `new row violates row-level security policy for table "llm_calls"` | @@ -319,7 +324,7 @@ CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app ### 破坏性变更(五项) | # | 变更 | 影响与应对 | -|---|---|---| +| --- | --- | --- | | ① | **Postgres 侧不再自动补列**(缺省转为 manual 档) | 库升级带来新列时,旧表不会被自动 `ALTER`:库改为发**一条** warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 `INSERT` 继续写入——**缺的那几列静默不落库**,直到有人执行那几条 SQL。要恢复旧行为设 `PGW_TELEMETRY_SCHEMA_MODE=auto`。SQLite 侧缺省不变(仍 auto),理由见下 | | ② | 两个 recorder 新增 **keyword-only 必填**参数 `auto_migrate` | `SQLiteRecorder(db_path, *, auto_migrate)` 与 `PostgresRecorder(dsn, *, pool=None, auto_migrate)`;直接构造 recorder 的调用点必须补这个参数,不传即 `TypeError`。**故意不给默认值**:缺省规则只写在 config 一处,不与类签名漂移 | | ③ | `GatewaySettings` 新增**必填**字段 `telemetry_auto_migrate: bool` | 只影响「构造函数全量注入」这条装配路(测试/高级用法);`from_env()` / `from_settings()` 的用户零改动。`telemetry_backend="none"` 时该字段在 `__post_init__` 归一为 `False` | @@ -335,7 +340,7 @@ CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app issue #12 交付的三样手段列在下表——它们改变的是**能做什么**,不是**默认做什么**: | 手段 | 内容 | -|---|---| +| --- | --- | | **`PGW_TELEMETRY_TEXT_CAP`**(可选正整数键) | 遥测落库正文的字符上限;**不设 = 不截断**(缺省)。作用面正好四处: `messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列;超出部分头部保留、尾部换成 `…(略 N 字)`。**按每条文本切,而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**覆盖面到此为止**: 调用方塞进 `tool_calls.function.arguments`、`name` 等 `content` 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留 | | **`tools/telemetry_retention.py`**(独立运维脚本) | 按 `created_at` 清理过期行。**默认 dry-run**: 先打出将删行数、`created_at` 窗口与按 `tenant_id` 的分布,让运维先判断"要删的是不是我想删的",给了 `--apply` 才真动手。退出码是与调度器(cron/systemd)的契约: `0` 正常(含 dry-run)、`1` 参数错误、`2` 连接/权限/目标表不可用(**含缺 `asyncpg`**——明确报错退出,绝不静默变成"删了 0 行")、`3` 目标是 PostgreSQL 分区表,此时脚本**拒绝 DELETE**,让路给 O(1) 的 `DETACH` + `DROP PARTITION`。请用维护角色跑,不要用应用账号(模板已对它 `REVOKE UPDATE, DELETE`) | | **README 新增「生产部署 DDL 模板(PostgreSQL)」一节** | 三角色、`created_at` RANGE 分区与 `pg_partman` retention、`REVOKE UPDATE, DELETE` 加触发器兜底、RLS、**库自己需要的最小权限**、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 `` 锚点,由 `tests/integration/test_postgres_telemetry.py` 从 README 解析出来在真实 PG 上逐条执行——**模板只有这一份**,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物 | @@ -382,7 +387,7 @@ issue #12 交付的三样手段列在下表——它们改变的是**能做什 - **遥测表 `llm_calls` 新增两列**,排在既有 22 列**末尾**,两端类型按各自后端的原生能力取: | 列 | Postgres | SQLite | -|---|---|---| +| --- | --- | --- | | `tenant_id` | `TEXT NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` | | `meta` | `JSONB NOT NULL DEFAULT '{}'::jsonb` | `TEXT NOT NULL DEFAULT '{}'` | @@ -394,7 +399,7 @@ issue #12 交付的三样手段列在下表——它们改变的是**能做什 校验在四个公共入口收口、进洋葱之前抛裸 `ValueError`,四条链路共用同一份实现: | 项 | 规则 | -|---|---| +| --- | --- | | `tenant_id` | 长度 ≤ **128**;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug | | `meta` 键数 | ≤ **16** | | `meta` 键 | 必须匹配 `[a-z0-9_.]{1,64}`;**`pg_` 前缀保留**给库将来的内建维度(本版库自身不写任何该前缀的键) | @@ -435,7 +440,7 @@ RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文 ### 行为变更 -- **非 2xx 的 message 末尾追加 ` | {响应体摘要}`**,覆盖两个 transport 的**全部**分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 `force_open` 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。 +- **非 2xx 的 message 末尾追加 `| {响应体摘要}`**,覆盖两个 transport 的**全部**分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 `force_open` 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。 - 摘要口径: 先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 **2048 字符**(对齐 Kubernetes client-go 同场景的 `maxUnstructuredResponseTextBytes`)。超长时**保留头 1400 + 尾 600**并记下省略字数——JSON 错误体的 `code` / `request_id` 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。 - 遥测 `error` 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。 diff --git a/README.md b/README.md index 26a83ad..30e7060 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 每个接入大模型的项目都会重写同一批东西:重试循环、429 处理、熔断器、SSE 解析、遥测埋点——写三遍就有三份 bug。本库把这些收敛为一份经过压测验证的实现: | 能力 | 说明 | -|---|---| +| --- | --- | | 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 | | 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) | | 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After | @@ -30,6 +30,42 @@ **降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。 +## 1.3.4 推理配置迁移(未发布) + +**先明确意图,再在首次新语义缓存读写前切换缓存身份。** `auto` 要求开启但不指定强度,不是 `None`(不表态),也不是库代选付费档位。已登记模型只有清单含 AUTO 才接受 True/auto;nearest 不把 AUTO 映射成强度。未知模型仍尽力+warning,空开启片段可能零推理字节,不保证开启。完整型号证据见[批准设计 §4/5](research-wiki/designs/2026-09-09-134-thinking-contracts-design.md)。 + +| 项 | 旧配置/受影响模型 | 用户明确选择的新配置(示例,不是成本推荐) | +| --- | --- | --- | +| M1 | MiniMax-M3 True/auto | 删除糖,`REASONING_EFFORT=medium` 可恢复旧 medium 字节;也可选表内其他档 | +| M2 | deepseek-v4-pro/flash/flash-vision-exp、glm-5.2 True/auto | 删除糖,选 high 或 max;非空开关也不能豁免 AUTO 成员检查 | +| M3 | glm-5.3/5.3-flash、kimi-k3/kimi-for-coding True/auto | 删除糖,选 low/high/max;nearest 不能修复 AUTO | +| M4 | gpt-5.4/5.5、claude-opus-5/sonnet-5、gemini-3.1-pro True/auto | 删除糖,可选表内 medium;不可达不能补 AUTO,也不等于 live 证明 | +| M5 | MiniMax-M2.5/M2.7 True/auto | 仍接受,但 on_base 不再偷带 medium,改为空片段;缓存须迁移,空 wire 真实语义待复验 | +| M6 | qwen 五型、glm-5/5.1/4.6v True/auto | 保留;glm-5/5.1 历史身份不足仍未覆盖,不推及其他型号 | +| M7 | 未登记模型 True/auto | 可保留尽力;确定保证须先独立取证再登记能力 | +| M8 | 受管意图+任一层 raw 推理控制,即使同值/被遮蔽 | 保留受管档并删除源 extra_body、请求 overlay 的控制键;或清空源糖/档和请求意图,仅 raw(applied_effort=NULL) | +| M9 | 如 glm-5.3,请求 medium,nearest 改 error | 同步更换 namespace/salt;旧身份仍可能回放 nearest 成功,不执行新拒绝 | + +M8 包括 reasoning_effort、enable_thinking、thinking、thinking_budget、reasoning、thinkingConfig、output_config.effort 及当前 wire 声明的整个控制根。浅覆盖次序不改,不深合并;自定义 on_base 不能偷带自己的 effort_key 或标准强度字段,点号键仍是顶层字面键。工厂源级拒绝发生在装配期;请求显式档+已知 raw 可前置拒绝;全量注入默认 transport 在 HTTP 前 RequestRejected,但可能已经准入,沿既有 finally 结算。自定义 transport 由实现方履约。 + +### 显式缓存身份切换 + +**不增加**自动 revision、fallback/能力表/wire 版本指纹,不强制所有 chat 冷启动。受影响调用须选从未承载旧语义的 namespace 或 salt;同版本 fallback、能力表或 wire 变化亦须再次迁移。未迁移可能命中旧缓存并绕过新拒绝:这是操作前置,不是自动安全机制。 + +| 路径 | 切换示例/边界 | +| --- | --- | +| 工厂默认 | `PGW_CACHE_NAMESPACE=lab:tenant-a:thinking-134-a`,保留原租户前缀 | +| per-call 覆盖 | `chat(..., cache_namespace="tenant-a:thinking-134-a", cache_salt="epoch-7")`;只改工厂默认无效 | +| 请求级档 | M3 示例:源不表态,`chat(..., reasoning_effort="medium", cache_salt="epoch-7:thinking-134-a")` | +| 全量注入/多源 | 构造参数 cache_namespace 同步切换;共享身份只要一个源受影响,该集合都要隔离或显式拆 scope | +| 并行/回滚 | 新旧客户端不共用新身份;回滚旧 namespace 会重见旧值,旧键未清理;未来变更不能复用此标记包办 | + +### 证据与遥测读法 + +真实成功尝试记 response.applied_effort;失败尝试记 effective 请求意图(可能零 HTTP);缓存命中和 scope 终态只记**本次请求级**档,不借历史 applied 或源级补值。embedding、OCR text/layout 成败行均 NULL。实际档分析须排除缓存命中与错误行,未知 AUTO 不证明上游能力。 + +测试侧默认 FAIL:404 只有请求、唯一尝试、完整无重复键 JSON、error.type=model_not_found 等独立证据全满足才 UNCOVERED;429/5xx/网络/解析错误不整类 skip。成功公共身份缺失无独立证据仍 FAIL;成功 SSE 不新增捕获器。关闭须完整合格轮次全 ABSENT,UNKNOWN 不能靠长度升格成功。不可关闭探测的 OBSERVED 仅支持本条件下未关闭;预期拒绝另按预声明类型、状态、机器字段判定。必需 live 的 SKIP/UNKNOWN/缺轮不因 pytest exit 0 通过发布门。三项目现行配置迁移仍需各负责人取证,合成兼容测试不能代替。 + ## 安装 发布在实验室 Gitea PyPI(公开包,匿名可装): @@ -42,7 +78,7 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py 核心仅依赖 `httpx` + `pydantic`;按需选 extras: | extra | 内容 | 何时需要 | -|---|---|---| +| --- | --- | --- | | `redis` | redis-py | Redis 限流/熔断/缓存后端 | | `postgres` | asyncpg | Postgres 遥测后端 | | `structured` | json-repair | 结构化输出的修复策略 | @@ -138,7 +174,7 @@ resp = await client.chat( `llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发: | 库会发 | 库不发 | -|---|---| +| --- | --- | | 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 | | `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 | | 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 | @@ -146,7 +182,7 @@ resp = await client.chat( ### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE` | 取值 | 含义 | -|---|---| +| --- | --- | | 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres` → **manual** | | `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 | | `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 | @@ -154,7 +190,7 @@ resp = await client.chat( **缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。 | 表状态 | `auto` | `manual` | -|---|---|---| +| --- | --- | --- | | 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) | | 存在、列齐 | 不发任何 DDL | 不发任何 DDL | | 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 | @@ -184,7 +220,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行** 这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束: | 承诺 | 你可以据此做什么 | -|---|---| +| --- | --- | | 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 | | 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 | | `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 | @@ -200,7 +236,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行** 模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。 | # | 锚点 | 做什么 | -|---|---|---| +| --- | --- | --- | | 1 | `roles` | 建三角色并授 schema 级权限 | | 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` | | 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) | @@ -212,7 +248,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行** ### 1. 三角色 | 角色 | 拿到什么 | 谁在用 | -|---|---|---| +| --- | --- | --- | | `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** | | `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 | | `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 | @@ -319,7 +355,7 @@ CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at); 四个陷阱,每一个的失败形态都是**静默的**: | 陷阱 | 后果 | -|---|---| +| --- | --- | | 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 | | `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 | | 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 | @@ -330,7 +366,7 @@ CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at); 按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给: | 库会发的语句 | 需要什么 | -|---|---| +| --- | --- | | 连库 | 数据库 `CONNECT` + schema `USAGE` | | `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) | | `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy | @@ -349,7 +385,7 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全 ``` | 层 | 配置 | -|---|---| +| --- | --- | | 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` | | 保留期 | 上面的分区模板 + `pg_partman` 的 `retention`,过期分区整块 `DROP` | | 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS | @@ -372,7 +408,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应** 一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码: | 分类 | 含义 | 库内行为 | -|---|---|---| +| --- | --- | --- | | `TransientError` | 超时/5xx/网络抖动/截断流 | 换源重试 + 退避 | | `SourceDeadError` | 401/403/欠费(429+insufficient_quota) | 立即熔断该源 + 换源 | | `RequestRejectedError` | 400/内容拒绝/本地格式拒绝 | 不重试不换源,快速失败 | @@ -387,7 +423,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应** 上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明: | 会到达调用方 | 库内吸收(不必 catch) | -|---|---| +| --- | --- | | `GatewayUnavailableError` 族——`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` | `TransientError`(退避后换源重试,耗尽即转为 `AllSourcesExhausted`) | | `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) | | `ResultInvalidError` | | @@ -402,7 +438,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应** 配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.env.example),约定速览: | 键形态 | 作用 | -|---|---| +| --- | --- | | `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/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` 等,兼容旧项目习惯) | @@ -443,7 +479,7 @@ graph LR ``` | 模块 | 职责 | -|---|---| +| --- | --- | | `types.py` / `errors.py` / `ports.py` | 内核:冻结类型、四分类异常、全部 Protocol(最内层,不依赖任何实现) | | `middleware/` | 治理算法(重试/限流/熔断/缓存/遥测),只面向端口 | | `transports/` | 协议细节:OpenAI 兼容 SSE、MonkeyOCR 双端点;错误翻译在此层 | @@ -458,7 +494,7 @@ graph LR 行为不是宣称出来的,是压测出来的(数字见 `research-wiki/findings/`): | 场景 | 结果 | -|---|---| +| --- | --- | | 故障混编 soak(坏 key/黑洞/慢源/限流源混合,8000 调用) | 成功率 98.96%,坏源吸流被压制,真实源零误熔 | | OCR 故障池 soak(1500 调用,redis 双后端跨进程) | 成功率 99.73%,13 项不变量全过(租约归零/探针不悬挂/零取消泄漏等) | | 两项目全量迁移回归 | 原测试全绿 + 真实链路冒烟 + 50 样本批跑 100% 解析 | @@ -480,7 +516,7 @@ make ci # 只读全量验证 ## 文档导航 | 想了解 | 看 | -|---|---| +| --- | --- | | 全部架构决策及理由(单一事实源) | `research-wiki/ARCHITECTURE.md` | | 里程碑与状态 | `research-wiki/ROADMAP.md` | | 项目迁移指南(删除清单/组件映射/行为审计) | `research-wiki/migrations/` | diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index c888a8b..5a733db 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -38,7 +38,7 @@ resp = await client.chat(messages) # resp: LLMResponse ### 1.2 能力对比矩阵 | 能力 | Video-Tree-TRM5 | GovDoc-SaaS | CHSAnalyzer | -|---|---|---|---| +| --- | --- | --- | --- | | 治理网关(重试/退避/超时) | ✅ `GovernedLLMClient` | ✅ 同款移植 | ✅ Invoker/Governance 分层(结构最好) | | 错误分类 | ⚠️ 二分类(瞬时/致命) | ⚠️ 同款 | ✅ 三分类 + Retry-After 解析 + 工件级失败 | | 限流 | ❌ 仅 `asyncio.Semaphore` | ❌ 完全没有 | ✅ Redis+Lua 六道闸(并发/RPM/TPM × 全局/单源) | @@ -65,7 +65,7 @@ resp = await client.chat(messages) # resp: LLMResponse ### 1.4 各项目关键资产索引(移植蓝本) | 资产 | 来源 | 移植去向(§7) | -|---|---|---| +| --- | --- | --- | | 治理网关主循环(参考结构,需重构掉遥测复制) | `Video-Tree/adapters/llm.py`、`GovDoc/packages/docagent-core/src/docagent_core/llm/client.py` | client + middleware | | 三层流式活性看门狗(纯函数,近乎原样复用) | 三项目同款 `streaming.py` | `streaming.py` | | 进程内熔断器(时钟注入、单探针) | `Video-Tree/adapters/breaker.py` | `backends/memory/` | @@ -108,7 +108,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协 ### 2.3 非目标(已确认,含理由) | 不做 | 理由(讨论结论) | 归属 | -|---|---|---| +| --- | --- | --- | | 任务队列(arq)/任务编排 | 队列单位是业务任务,库单位是单次调用,高度不同;强行进库会迫使批处理项目部署队列、并把"任务"业务概念污染进零业务假设的库。Video-Tree 声明了 arq 依赖却从未使用(死依赖)是现实佐证 | 业务侧 | | 视频抽帧(ffmpeg)、图像裁剪/拼接/增强等预处理 | 纯业务先验(超声图表格在左上角、每 5 帧一批等),且会拖入 ffmpeg/PIL/numpy 重依赖;库只收就绪的 content 数组/图像字节 | 业务侧 | | OCR 结果的几何映射(坐标换算/归一化/marker 推算) | 同上,业务先验;库只返回 OCR 服务的原生 bbox + page_size | 业务侧 | @@ -128,6 +128,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协 **决策**: 借鉴 Clean Architecture 的三条原则——依赖规则(核心不依赖具体技术)、端口与适配器(Protocol 定义接缝)、组装点(所有构造集中注入);**不照搬**其面向应用的四层分层(Entities/Use Cases/Interface Adapters/Frameworks)。库内部的组织模式采用**中间件洋葱**(同 ASGI middleware / gRPC interceptor / Rust tower):重试、限流、熔断、缓存、遥测各为一层,层与层正交,顺序与取舍是配置。 **背景与讨论**: 人类提问"是否借鉴《Clean Architecture》,是否有更好的指导思想"。结论:那本书为应用程序而写,库没有"用例层",硬套四层会造出空转抽象。对库更适配的思想来源: + - **Hexagonal / Ports & Adapters**(Cockburn):三项目已在实践的本质。 - **《A Philosophy of Software Design》(Ousterhout)的"深模块、窄接口"**:接口复杂度是用户付的成本。落地为——90% 用户三行起步(`from_env()` → `chat()`),全部可配置性经构造函数暴露给需要的人,但绝不强迫简单用户理解。 - **中间件洋葱**:与治理栈天然同构。反面证据:三项目的 `GovernedLLMClient.chat()` 是约 500 行的方法,五层治理手工内联在一个重试循环里,横切关注点没有被切开,遥测调用因此被迫复制 4 次。洋葱模型下遥测就是一层,只写一次。 @@ -143,7 +144,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协 **背景与讨论**: 人类要求完整阐述官方 SDK 与手写的差异优劣。核心对比: | 维度 | 手写 httpx | 官方 SDK(openai) | -|---|---|---| +| --- | --- | --- | | SSE 协议解析(帧格式、畸形帧、usage 帧、[DONE]) | 自己写自己修(约 200 行),但全可控 | SDK 维护,跟随协议演进 | | 错误分类 | 状态码 + body 字符串匹配,自己写 | 类型化异常层级(RateLimitError 等),映射干净 | | 非标字段(qwen `enable_thinking`、deepseek `reasoning_content`) | 天然支持 | `extra_body` 写入 + `model_extra` 读出,**够用** | @@ -221,6 +222,10 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协 **职责拆分(2026-08-25,issue #16/#17)**: 上面这条决策里的**推理**部分已从 `providers.py` 移出,落进新模块 `thinking.py`。起因是推理这件事从「请求侧注入什么参数」长成了「请求侧注入 + 响应侧裁定 + 两者对账」三件事,留在注册表里会让 `providers.py` 变成「推理的一切」,一句话说不清职责(P3)。拆后 `providers.py` 只回答**provider 是什么**(`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`/`register_provider`),`thinking.py` 承载**推理这件事的全部决策**(`ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`/`register_capability`、`resolve_thinking`、`observe_thinking`、`reconcile_thinking`、`ThinkingUnsupportedError`);纯值类型 `ThinkingObservation` 归最内层 `types.py`(§5.1)。六个公共符号同批提升到包根导出——此前只能深路径 import,而深路径引用正是模块重组会打断下游的原因。 +**1.3.4 受管推理契约(2026-09-09 已批准)**:AUTO=要求开启、不指定强度;空 on_base 仅是协议无需开启字节,不是任意模型默认推理。已登记模型必须含 AUTO 才接受 True/AUTO,nearest 不将 AUTO 代选强度;未知模型按已知 wire 尽力+warning,不保证开启。MiniMax on_base 改空,M3 仍不含 AUTO;M2.5/M2.7 空 wire 真实复验待完成,不新增能力条目。 + +有受管意图(含 NONE/糖/未知模型)时,source.extra_body 或 request.overlay 任一层出现标准控制根或当前 wire 两向控制根/effort_key 均拒绝,同值和后层遮蔽也不豁免;无意图保留 raw-only,不推断 applied。on_base 不得含自己的 effort_key 或标准 reasoning_effort/output_config.effort,点号仍是字面顶层键,不新增私有方言解释器。工厂源级校验在装配期;chat 仅前置校验显式请求档与已知 raw;默认 transport 对选中源完整校验并在 HTTP 前 RequestRejected,可能已经准入,finally 结算不变。自定义 transport 由端口实现方履约,不新增 preflight。细则与 M1–M9 见[批准设计 §4–5](designs/2026-09-09-134-thinking-contracts-design.md)。 + ### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律) **决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。 @@ -307,7 +312,7 @@ flowchart TB > 2026-07-20 修订(CHS 迁移文档缺口 G3): 初版把熔断/限流画在重试循环外,与"每次重试重新过限流闸"的理由自相矛盾,且熔断/限流是 **per-source** 的——源在循环内才被选出,准入只能发生在循环内。修订后与 CHSAnalyzer 实践(`governance.py:120-167` 逐次尝试执行选源→熔断→permit)一致。 | 相对顺序 | 理由 | -|---|---| +| --- | --- | | 遥测最外 | 观测一切,包括缓存命中与各类失败;任何路径都留痕 | | 缓存在重试循环外 | 缓存命中不打网关:不消耗限流配额、不受熔断状态影响 | | 结构化在缓存内、重试外(2026-07-20 M1 设计) | 带反馈重问 = 再次调用内层,天然照过限流/熔断门、逐次遥测;缓存只固化阶梯通过的最终结果 | @@ -332,7 +337,7 @@ flowchart TB 这是**跨子系统的通用纪律**,不是遥测的局部约定。它被写下来的直接原因是: 库对"谁建的、谁负责关"从来没有统一说法,于是同一个根因在三个地方长出三种形态—— | 形态 | 位置(修复前) | 性质 | -|---|---|---| +| --- | --- | --- | | `GatewayClient.aclose()` 无条件关掉**注入的** telemetry,共享 recorder 被第一个关闭的 client 弄死(`embedding.py`/`ocr.py` 各有一份逐字复制) | `client.py:271-273` | 越权 | | `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 | | `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不持有 limiter/breaker 的引用) | `client.py:263-280` | 泄漏 | @@ -341,7 +346,7 @@ flowchart TB 纪律把已有的那个正确先例推广为全库唯一说法,分两层落地: | 层 | 所有权归属 | 落法 | -|---|---|---| +| --- | --- | --- | | 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 | | client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性,`aclose` 只关自建的;三处复制的 `getattr(..., "aclose")` 鸭子探测收敛为一个内部 helper(同时探测 `aclose`/`close`,SQLite recorder 只有同步 `close()`) | @@ -362,7 +367,7 @@ flowchart TB **兼容约束(硬)**: 以下字段为三项目现有消费面,只增不删不改名: | 字段 | 类型 | 说明 | -|---|---|---| +| --- | --- | --- | | `content` | str | 正式输出文本 | | `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) | | `model` / `provider` | str | 溯源 | @@ -377,7 +382,7 @@ flowchart TB **可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**: | 字段 | 含义 | 生产者 | -|---|---|---| +| --- | --- | --- | | `cached_prompt_tokens` | **供应商侧** prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`)。`None` = 该源未上报;`0` = 上报了一次真实零命中——两者对下游处置不同(前者不可做缓存成本校正),故不可混同 | `openai_compat` 两条路径解析后经 `TransportResult` 上浮 | | `model_reported` | API 响应体里的 `model` 字段;`None` = 未上报。与 `model`(`.env` 配置别名)可能分叉——供应商把别名指向新权重时,实验复现必须认这个串 | 流式取首个含 `model` 的 chunk(首次写入即固定),非流式取 body 顶层 | @@ -386,11 +391,13 @@ flowchart TB **推理观测三态 `thinking_observation`(2026-08-25,issue #16/#17)**: 类型 `ThinkingObservation`(`StrEnum`),缺省 `UNKNOWN`。回答的问题是「这次调用到底推理没推理」,由多信号裁定: | 值 | 含义 | 判据(按证据硬度排序) | -|---|---|---| +| --- | --- | --- | | `observed` | 确证本次推理发生 | 推理正文 `thinking.strip()` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) | | `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) | | `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 | +**测试证据边界(1.3.4)**:运行时 UNKNOWN 不告警不等于关闭测试成功。关闭须完整合格轮次全 ABSENT;不可关闭命题在完整合格轮次有 OBSERVED 可支持本条件下未关闭,全 ABSENT 证伪,无 OBSERVED 但 UNKNOWN 仅未覆盖。开启保留完整计划分母与多数 OBSERVED,不丢失败轮。身份缺失只有独立原始 JSON 证据才可归上游;公共身份丢失且无取证 FAIL,成功 SSE 不新增捕获器。默认 FAIL,仅完整请求/唯一尝试/完整无重复键 JSON/404 精确 error.type=model_not_found 可自动 UNCOVERED;一般400、429、5xx、解析与治理异常不整类 skip,预期拒绝另按预声明机器字段断言。逐轮安全报告在 tests/outputs,写失败 FAIL,不增加生产数据面。 + 三态**不可折叠为布尔**: `unknown`(判不出)与 `absent`(确证没有)语义不同,把前者读作后者正是 `reasoning_tokens=None` 制造的那个歧义——MiniMax-M3 非流式开启推理时,推理内容已计费却不回传正文(2026-08-25 实测 completion 53 vs 关闭档 3),该档只能判 `unknown`,宣称「没推理」即撒谎。缺省取 `UNKNOWN` 使任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。 判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。 @@ -404,7 +411,7 @@ flowchart TB **`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**: | 值 | 含义 | 生产者 | cost | -|---|---|---|---| +| --- | --- | --- | --- | | `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 | | `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 | | `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** | @@ -440,7 +447,7 @@ flowchart TB ### 6.1 统一四分类 + 熔断信号(融合 CHSAnalyzer 三分类与 GovDoc 二分类) | 错误类 | 触发 | 重试 | 换源 | 熔断计数 | -|---|---|---|---|---| +| --- | --- | --- | --- | --- | | `TransientError` | 超时/5xx/429/网络抖动/SSE 异常(畸形帧、断流无 [DONE])/看门狗超时 | ✅ 退避后 | ✅ | ✅ | | `SourceDeadError` | 401/403/欠费/insufficient_quota(429 body 细分) | ❌ | ✅ 立即 | ✅ force_open | | `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ | @@ -458,7 +465,7 @@ flowchart TB ### 6.2 翻译规则(transport 层职责) | 输入 | 翻译为 | -|---|---| +| --- | --- | | httpx Timeout/Transport 错误、`StreamLivenessTimeout`、SSE 异常 | `TransientError` | | HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) | | HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` | @@ -524,6 +531,8 @@ flowchart TB ### 7.5 响应缓存 +**1.3.4 显式迁移前置(D3)**:key 与源指纹不新增包版本、语义 revision、fallback、能力表或 wire 版本。AUTO/raw/MiniMax 语义变更及同版本 fallback/能力表/自定义 wire 改变时,受影响调用集合必须在首次读写前切到从未承载旧语义的 namespace 或 salt。保留租户前缀与 epoch;覆盖工厂默认、全量注入和 per-call(只改默认对覆盖路径无效)。同一共享缓存身份只要一源受影响,整个调用集合须隔离或由下游显式拆分;不强制未受影响 chat 冷启动。新旧版本不共享新身份,回滚旧身份会重见旧值。**未迁移仍可能回放旧响应、绕过新拒绝**,库不会自动检查新可满足性;操作说明不能当自动防护。 + **key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`。 - `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。 @@ -561,7 +570,16 @@ 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`,给一次从来不带推理参数的调用挂上一个从未发出过的档。 +**`reasoning_effort` 列(1.3.4 四种行来源澄清,不改 schema)**:TEXT 可空,NULL 与明确要求不推理的 `'none'` 不同。只有真实成功尝试读 transport 的 response.applied_effort(nearest 后,不重算);AUTO 是编码选择,不是服务端内部强度,未知 AUTO 不构成能力验证,raw-only 为 NULL。 + +| 行类型 | 来源/限制 | +| --- | --- | +| 真实成功尝试 | response.applied_effort;未知/raw-only 限制如上 | +| 失败尝试 | effective_effort(请求>源>糖)的意图,可能零 HTTP,不能称实际发出 | +| cache_hit | 本次请求级 reasoning_effort,不读历史 applied、不推源级;观测回放历史,不是本次实测 | +| scope 终态失败 | 本次请求级 reasoning_effort,可能尚未选源,不补逐次根因 | + +实际档分析须 `cache_hit=false AND error IS NULL`。embedding/OCR text/layout 的成功与失败尝试由 reasoning_applies=False 保证 NULL,真实 client→emitter→临时 SQLite 与 chat 阳性共同守卫,不能用空行集合证明。生产 emitter 单一出口、端口字段数和 DDL 不变。 **`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` 独立成一档而不再被并进「未推理」。 @@ -580,7 +598,7 @@ flowchart TB **遥测池的资源语义(2026-08-24,issue #15)**: `PostgresRecorder` 此前 `create_pool(dsn, timeout=10)` 继承 asyncpg 默认的 `min_size=max_size=10`,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限"(`pool.py:457` 的 `if self._minsize:`)——建池是一次全有全无的重资源动作: 拿不到 10 条就抛异常。这让遥测成为全库唯一预占资源的组件(httpx transport 与三个 redis 后端全是按需建连),也就成了共享实例余量紧张时**必然第一个倒下**的一环,而它承担的恰恰是最不该悄悄失败的职责。改为 `create_pool(dsn, min_size=0, max_size=, timeout=<预算>, command_timeout=<预算>)`,三条随之确立: | 语义 | 内容 | -|---|---| +| --- | --- | | 建池零成本 | `min_size=0` 时 `_initialize` 只造 holder 对象、**一条连接都不连**(实测 0.000s,指向不可达端口也照样成功)。稳态占用由"每 client 常驻 10 条"变为"实际并发,闲时 0";真实 PG 实测: 建 recorder 后 0 → 一次写入后 1 → 20 行并发后 4(= `pool_max`)→ `aclose` 后 0 | | 只暴露 `max_size` | `min_size` **有意不给配置项**: 它唯一的作用是把上面那个脆点装回来,换取的只是首次写入省下 ≈390ms 建连。库没有理由提供一个只会伤人的旋钮(P1+P5)。`max_size` 则必须暴露——继承第三方默认值等于库对自己的资源占用不表态(P4) | | 写入有硬预算 | 整次写入(准备 + acquire + execute)由 `asyncio.timeout(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S)` 包一层,超时按行级丢弃。把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证 | @@ -593,7 +611,7 @@ flowchart TB 2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。 | 档 | 覆盖(按 SQLSTATE 分类而非异常类白名单——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移) | 处置 | -|---|---|---| +| --- | --- | --- | | 配置级致命 | `ClientConfigurationError`(DSN 不可解析);`create_pool` 抛的 `ValueError`/`TypeError` | 永久 no-op + 一条 **error**(人配错了,不是 warning) | | 环境级不可用 | SQLSTATE 类 `08`/`53`(含 53300 too many connections)/`57`/`28`/`3D`,具体码 `42501`(无权限)/`42P01`(表不存在);`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅在准备期路径可达**: 它是 `OSError` 子类,但写入期的超时先被 `record_llm_call` 的 `except TimeoutError` 接住并按行级丢弃,压根到不了本分类函数——见下方第 ④ 点);表确定不存在且建不出来 | **冷却降级**(内部常量 60s,不给配置项——无部署差异理由),到期放行**一次**重新准备,成功即恢复 | | 行级拒绝 | 其余 `PostgresError`(`22`/`23` 等数据与约束类),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级,接入节流复述 | @@ -613,7 +631,7 @@ flowchart TB ### 7.9 结构化输出阶梯(D14) | 级 | 内容 | 成本 | -|---|---|---| +| --- | --- | --- | | ① 预防 | provider 注册表声明支持时,用 response_format / function calling 直接约束(`NativeSchemaStrategy`) | 无额外 | | ② 修复 | 围栏剥离 → json_repair → provider 变体归一化(DeepSeek 参数平铺等)(`JsonRepairStrategy`) | 零网络 | | ③ 校验 | 调用方传 pydantic 模型时库内做**形态**校验;语义校验留业务层 | 零网络 | @@ -625,7 +643,7 @@ flowchart TB ### 7.10 OCR 端口族 | 端口 | 对应 MonkeyOCR 端点 | 协议 | 输出 | -|---|---|---|---| +| --- | --- | --- | --- | | `OcrTextPort.recognize_text(image: bytes)` | `POST /ocr/text` | multipart 上传 → JSON `{content}` | `OcrTextResult`(多行纯文本) | | `OcrLayoutPort.parse_layout(image: bytes)` | `POST /parse` | multipart → JSON(download_url) → GET ZIP → 解包 `*_middle.json` | `OcrLayoutResult`(elements 含 bbox + page_size) | @@ -680,7 +698,7 @@ src/polygateway/ ## 10. 非功能性需求(强制覆盖,继承 Video-Tree CLAUDE.md §4.2.1 条款) | 维度 | 回答 | -|---|---| +| --- | --- | | 持久化策略 | 遥测逐调用追加写(WAL);缓存写在响应成功后;崩溃最多丢当次调用的遥测记录 | | 幂等性 | 遥测 `INSERT OR IGNORE`(call_id 主键);缓存写幂等(同 key 同值);限流 permit 带 TTL 租约,进程死亡后自动过期回收 | | 断点续跑 | 库无长任务状态,天然无断点问题;响应缓存本身即业务侧重跑的加速器 | @@ -699,7 +717,7 @@ src/polygateway/ ### 11.1 GovDoc-SaaS(难度低,首个迁移) | 项目侧 | 处置 | -|---|---| +| --- | --- | | `docagent-core/llm/client.py`、`breaker.py`、`redis_cache.py`、`streaming.py`、`telemetry_sqlite.py` | 删除,由库继任 | | `protocols.py` 的 `LLMProvider.chat(messages, *, session_id, parent_call_id)` 签名 | 库保持兼容(或一行 shim) | | 倒推的库需求 | `from_env` 工厂(GovDoc 装配层本就缺失,库直接补上)、Postgres 遥测、缓存 key namespace 含租户 | @@ -709,7 +727,7 @@ src/polygateway/ > 下表保留作历史记录与能力倒推依据(VT 倒推的库能力——多逻辑角色、cache salt、多模态摘要进 hash、OcrTextPort 等——均已交付且被其他消费方使用,不回收);v1.0 验收标准相应改为 §11.1 + §11.3 两项目。 | 项目侧 | 处置 | -|---|---| +| --- | --- | | `adapters/llm.py`、`breaker.py`、`streaming.py`、`redis_cache.py`、`telemetry.py` | 删除,由库继任 | | `main.py:_build_adapters()` | 改为按角色调用 `from_env`(SEARCH/JUDGE/VL/EVOLVE;共享实例显式声明) | | `adapters/vlm.py`(base64 编码与注入)、抽帧、OCR 文本拼接与注入前缀 | 留在项目(业务侧),组装好 content 数组后调库 | @@ -719,7 +737,7 @@ src/polygateway/ ### 11.3 CHSAnalyzer(难度高,能力对标项) | 项目侧 | 处置 | -|---|---| +| --- | --- | | `app/providers/governance.py`、`app/coordination/limiter.py` + `scripts.py`、`provider_gate.py` | 删除,由库继任(库必须先达到能力对等,这是 M2 的验收内容) | | `app/providers/invokers.py` 的 VLM invoker / `MonkeyOcrParseInvoker` | 由库 transport / `OcrLayoutPort` 继任 | | `app/providers/table_locator.py`(几何映射)、`marker_imaging.py`(拼图/增强)、`position_scheduler.py`(公平调度) | 留在项目(业务侧) | @@ -731,7 +749,7 @@ src/polygateway/ ## 12. 里程碑 | 阶段 | 交付 | 可接入 | -|---|---|---| +| --- | --- | --- | | M1 核心 | types/errors/ports、OpenAICompat transport(含非流式)、看门狗、RetryMW、**多源多账号+选源+源冷却备忘(2026-07-20 人类拍板,自 M2 提前——理由: RetryMW 循环与端口签名 M1 冻结,多源行为一并钉死避免 M2 返工)**、内存版限流/熔断、缓存(Redis+内存)、SQLite 遥测、结构化输出双策略、provider 注册表、from_env | GovDoc、Video-Tree | | M2 分布式 | Redis 限流(六道闸+契约测试)/熔断后端、多源 × Redis 后端联合验证(全局限额跨 worker)、背压 stall、Postgres 遥测、pricing 成本 | CHSAnalyzer(治理部分) | | M3 OCR | OcrText/OcrLayout 端口 + MonkeyOCR transport,走同一治理栈 | CHSAnalyzer(全量)、Video-Tree(OCR 升级) | @@ -744,7 +762,7 @@ src/polygateway/ ## 13. 开放问题(待人类拍板) | # | 问题 | 建议 | -|---|---|---| +| --- | --- | --- | | Q1 | 打包与分发 | **已拍板(2026-07-22 用户)**: Gitea PyPI 包注册(gitea.iomgaa.online,内置 registry;twine 上传、项目侧 `pip install --index-url .../api/packages/iomgaa/pypi/simple/`);git+https 留作退路 | | Q2 | Python 最低版本 | **3.12(已拍板,2026-08-24 人类确认)**: "我们现在的项目至少都是 3.12 的了,3.11 都有点老"——原记载的依据"覆盖三项目: 3.11×2 + 3.13×1"**已过时**,三个迁移目标均已 ≥3.12,故抬版本不再让任何迁移目标装不上。落点: `requires-python = ">=3.12"`、ruff `target-version = "py312"`、CLAUDE.md 与 README 同步。收益是 `asyncio.timeout` 可直接用于遥测写入预算(3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,省掉一整块 `wait_for` 绕行补丁)与 PEP 695 泛型语法;代价是仍在 3.11 的部署 `pip install` 会被 pip 直接拒绝(issue #15,见 CHANGELOG"请先读这一条(一)") | | Q3 | Embedding 客户端是否纳入。**勘误(2026-07-20,VT 迁移文档 R11)**: 初版称"各有一套独立重试实现"不实——GovDoc 的 `OpenAICompatEmbedding` 有自研退避,但 Video-Tree 的 `RemoteEmbeddingProvider` 是**同步 SDK 裸调、无任何重试**;纳入库还需异步化其端口 | **已拍板(2026-07-20 人类)**: 纳入 M2(消灭无治理的裸调 + 统一重试),含端口异步化;Embedding 端口为公共 API,随 M2 设计文档过人类门 | diff --git a/research-wiki/designs/2026-09-04-reasoning-effort-design.md b/research-wiki/designs/2026-09-04-reasoning-effort-design.md index 7e3fce9..d7205a4 100644 --- a/research-wiki/designs/2026-09-04-reasoning-effort-design.md +++ b/research-wiki/designs/2026-09-04-reasoning-effort-design.md @@ -1,5 +1,7 @@ # 推理档位一等化设计(issue #20 及其一般形式) +> **替代指针(2026-09-09)**:§3–6/8/12 的 AUTO 无条件放行、MiniMax 内置 medium、受管 raw 覆盖与缓存迁移/遥测总括语义,以[1.3.4 已批准设计](2026-09-09-134-thinking-contracts-design.md) §4–8 为准。历史调研与实验事实保留,不倒改为新语义已验证。 + - **日期**: 2026-09-04 - **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门) - **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过 @@ -16,7 +18,7 @@ issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解 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 实测一致 | @@ -32,7 +34,7 @@ issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解 替换 `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) | @@ -82,7 +84,7 @@ class ThinkingCapability: 三个派生量,不单独存字段(存了就会漂移): | 派生 | 定义 | 用途 | -|---|---|---| +| --- | --- | --- | | `can_disable` | `Effort.NONE in supported_efforts` | 兼容旧语义 | | `cheapest_effort` | 除 `none` 外的第一档 | 不可关闭时的可执行替代(§4.1 Phase 5) | | 是否档位型 | 除 `none`/`auto` 外仍有 ≥1 档 | 决定告警文案(纯开关型不该说「可选档位」) | @@ -104,7 +106,7 @@ class ThinkingWire: 四个形态样例(经 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` | @@ -126,7 +128,7 @@ cherry 有两层我们**明确不做**: 判定顺序即语义。前三关是既有的,判据从 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 尽力注入,**不校验档位** | @@ -154,14 +156,14 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 `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 回退) | **逐字节等价** | @@ -189,7 +191,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 已知三条入口,缺一即漏: | # | 入口 | 归一点 | -|---|---|---| +| --- | --- | --- | | 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` | @@ -211,7 +213,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 真正需要处置的是两处,均因请求级档位而新增: | 层 | 处置 | 理由 | -|---|---|---| +| --- | --- | --- | | 源级 `reasoning_effort` | 并入 `_fingerprint_mark`,与 `enable_thinking` 同规则(**仅表态时**追加) | 与既有一致;全源不表态时指纹字面量不变,存量缓存不冷启动 | | 请求级 `reasoning_effort` | 进 `build_cache_key`,仅非 `None` 时参与 | `model_fingerprint` 是**装配期**算的集合级指纹,覆盖不到逐调用变化的值。不进 key 则同 messages 跑 low 与 max 会互相命中——issue #4「5 个 seed 全命中同一响应」的逐字翻版 | @@ -240,7 +242,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 ## 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** | @@ -254,7 +256,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 **落库规则**(Codex 审查补): 本表是**调研素材**,不是可直接转代码的表。只有 `supported_efforts` 能写成合法 `Effort` 元组的条目才进 `DEFAULT_CAPABILITIES`。分三档处置: | 情形 | 处置 | -|---|---| +| --- | --- | | 档位清单与「能否关闭」皆无冲突 | 直接登记 | | **档位清单三源一致,仅「能否关闭」存疑**(如 kimi-k3: 官方档位无 `none`,OpenRouter 却标 `mandatory:false`) | 按**保守方向**登记(不含 `none`),evidence 注明存疑点。理由: 不登记会退回 Phase 3 的「尽力注入」,下游配 `none` 时静默失效——**那正是 issue #20 的病**;保守登记则报错并给出最低档,明确且有出路 | | 档位清单本身无该型号直接证据(`未查到`,或仅由**同系**推定如 `推定同上`) | 不登记,走 Phase 3 | @@ -262,7 +264,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 第二档与第三档的分界是**有没有该型号自己的档位证据**,不是「关不关得掉存不存疑」: `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`——**待实测**) | @@ -283,7 +285,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 ## 9. 非功能维度 | 维度 | 回答 | -|---|---| +| --- | --- | | **并发** | 两张表仍是 `MappingProxyType` + 纯函数查找,无共享可变状态。transport 的 `_warned_models`/`_warned_mismatches` 是实例级 `set`,读写之间无 `await`,单事件循环内原子。节流键加入生效档位后基数上升(源×模型×档位),仍为有界小集合 | | **取消** | 档位解析全部是同步纯函数,不含 `await`,不改变 `CancelledError` 的穿透路径。既有保证不受影响 | | **降级方向** | 推理档位属**请求正确性**而非资源闸,故一律**报错不放行**(Phase 2/4/5(下同)),与「限流/熔断后端不可用须报错」同向。能力**未登记**是唯一例外——warning 后尽力注入,理由是新模型上线不该被库挡住(既有决策,保留) | @@ -299,7 +301,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 **测试策略**(先失败后通过,每条对应一个行为): | 层 | 用例 | -|---|---| +| --- | --- | | unit | 五道关卡各自的触发与不触发;`enable_thinking` 语法糖的三种等价;矛盾配置构造期报错;`nearest` 映射的取档方向;派生量(`can_disable`/`cheapest_effort`)与 `supported_efforts` 一致 | | unit | Phase 4 文案**含** `cheapest_effort` 与 env 键名(这是交付物,要断言内容而非只断言抛错) | | unit | 缓存 key: 同 messages 不同档位 → key 不同;不表态时 key 与存量形状一致(回归) | @@ -319,7 +321,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 **破坏性变更五处**(初稿只列了第 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 实现 | @@ -333,7 +335,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 真实的库内调用点(可复验): | 位置 | 用法 | 处置 | -|---|---|---| +| --- | --- | --- | | `thinking.py:169` | 读 `capability.can_disable` | 改读派生属性,行为不变 | | `tests/unit/test_thinking.py:128` | `ThinkingCapability(True, "实测")` **位置参数构造** | 随实现同步改——这是不可兼容的部分 | | `tests/e2e/test_thinking_live.py:455` | 读 `can_disable` | 派生属性覆盖 | diff --git a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md index ff73b9b..7f5c7bd 100644 --- a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md +++ b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md @@ -7,7 +7,7 @@ date: 2026-09-09 # 1.3.4 T0–T4/T7 实施验证 -> 状态:本轮限定的生产契约与确定性测试已实现;不是整个版本验收。T5/T6/T8/T9 尚未执行。所有原始输出在 `tests/outputs/134/`,不提交。 +> 状态:本轮限定的生产契约与确定性测试已实现;不是整个版本验收。原T0–T4/T7记录保留;T5/T6和T8文档续作见文末,独立验证/live/发布未执行。所有原始输出在 `tests/outputs/134/`,不提交。 ## 基线与修改边界 @@ -59,3 +59,56 @@ date: 2026-09-09 | 独立 verifier/集成/slow/下游迁移 | 由父会话后续执行,本轮不声明通过;设计所列真实缺测和下游缺失仍有效 | 日志方案沿已批设计:未知能力沿既有 loguru warning,实际调用仍经 TelemetryEmitter 单点出口,四类行来源和 NULL 契约用既有 schema 验证,不新增运行时数据面。 + +## T5/T6 与 T8 文档续作(起点 16fa0ca) + +本续作禁止发布/slow/付费调用,未改任何生产文件。已读完整批准设计、计划及 TDD/structured-logging/commit 技能。独立验证与全量集成/live 仍由父会话负责,本节不表示整个版本验收完成。 + +| 门/节点 | 本会话实际结果/原始日志(tests/outputs/134/) | +| --- | --- | +| 受影响基线 | `t56-baseline.log`:client/config/openai_compat **338 passed** | +| T5 新模块首次 | `t5-first.log`:98 passed;首次无行为红不计TDD,红证据来自下述隔离变异 | +| 当前受影响 | `t56-current-diagnostics-proof.log`:client/live_evidence/config **325 passed**;旧异步2/131通知已被当前结果取代 | +| 日常全单元 | `t56-accepted-unit.log/.exit`:**1357 passed,exit 0**(其后仅取证关联/报告字段收尾,受影响325再通过,最终门见提交日志) | +| 静态 | `t56-accepted-check.log`:make check 通过;compileall 测试支持模块通过;生产43模块123依赖、1契约通过 | +| live 采集 | `t56-accepted-collect.log/.exit`:e2e **90 tests collected,exit 0**;仅采集,不是真实通过 | + +### 隔离语义红→还原绿 + +仓库外临时副本只复制 src/tests/必要工程文件,不复制 `.env`、reference、`.pi`;PYTHONPATH及 cwd 指向副本,import来源见 `t56-mutation-import.log`/`t56-consumer-mutation-import.log`。每个变异均目标 AssertionError、退出1,恢复散列一致后节点退出0,不靠 import error 当红。 + +| 变异 | 目标节点(tests/unit/test_live_evidence.py) | 红/还原 | +| --- | --- | --- | +| 整类 skip | test_whole_exception_class_skip_is_forbidden | 1/0 | +| 正文子串 model_not_found | test_incomplete_or_ambiguous_error_body_fails | 1/0 | +| UNKNOWN 安静→PASS | test_coverage_is_proposition_specific | 1/0 | +| 缺轮缩分母 | test_missing_round_never_reduces_denominator | 1/0 | +| 身份无证据→skip | test_identity_requires_independent_raw_evidence | 1/0 | +| 丢第一轮 | test_round_consumer_keeps_first_success_when_second_assertion_fails | 1/0 | +| 部分档未覆盖→模型PASS | test_partial_uncovered_and_failed_rounds_never_become_model_pass | 1/0 | +| 不交付独立raw快照 | test_raw_identity_snapshot_reaches_round_consumer | 1/0 | + +汇总与逐例日志:`t56-mutation-summary.json`、`t56-consumer-mutation-summary.json`、`t56-mutation-*-{red,restored}.log`;脚本 `mutate-live.py`/`mutate-live-consumer.py`。主工作区从未放回假绿策略。 + +### 实现与矩阵边界 + +取证按 session/parent→attempt→HTTP;零HTTP和多HTTP分开,404严格唯一完整证据,成功非流式原始JSON独立解析并拒重复键。成功SSE不预读、不捕获。真实RetryMW两并发逻辑轮次各503→成功验证精确成功call_id;取消ContextVar复位、资源关闭与逐轮报告写失败显式失败均有离线节点。报告不保存任何原始正文/异常,白名单字段含校验布尔、状态、身份资格与固定安全原因;假凭据/提示词sentinel逐文件无泄漏。 + +四live文件均迁入窄通道,装配拒绝/平铺键/合成Protocol移入日常;外部Protocol缺包单列未覆盖。M3真实开启改medium,M2 AUTO复用既有T10档;L4迁为退出受管的raw-only高档,双来源本地拒绝由已实现单测守卫。L8不可关闭装配拒绝不计live,未知wire仍显式全None离线验证。UNKNOWN不靠completion长度或prompt锚点升格;L2b仅保留指定历史prompt锚点命题,不是关闭能力。 + +默认轮数/并发未增加,删除额外UNKNOWN长度锚点调用;T10保留既有一次重试设置,去除60s stall缩小值。静态矩阵:L1–L7合计93逻辑调用,L8可关闭19型号×5=95,T10 NONE 26×5=130及条件长复核≤78,开启57档×5=285,默认基线15,其他chat6+embed1=7;总上界703,不含既有治理重试/结构化重问。没有执行这些调用。 + +### 调试与未验证项 + +| 项 | 实际处置 | +| --- | --- | +| 新RetryPolicy测试参数误写base_delay_s | 当前工具实报TypeError,查源码后改backoff_base_s/backoff_max_s,239及后续242/325通过;该失败不计目标红 | +| make check初报SIM117/B017 | 合并测试上下文,按真实解析异常指定类型,不加ignore;最终静态门通过 | +| pi-lens解释器/StrEnum噪音 | 记录 `t56-diagnostics.txt`,conda内真实导入与ruff为门,不改任务外枚举;pytest wrapper generator的return report是协议必需,独立next/send/StopIteration.value测试通过 | +| T10型号→400机器字段基线不存在 | 父会话明确确认:不编造白名单,实际400默认FAIL并逐轮留证。纯负向契约精确类型/状态/type单独测试;具体live预期拒绝未验证、需人工基线 | +| 结构化反馈重问 | 请求摘要预期固定,发生反馈重问更改messages时保守FAIL,不从待测payload补齐预期;未改生产结构化行为,不声称该取证分支已取得live覆盖 | +| 发布/集成/live/下游 | 本任务未执行,M2空wire、M3非流式UNKNOWN、身份不足、三项目实际配置缺失仍保留为证据门 | + +文档已同步README M1–M9、CHANGELOG未发布段、env注释、ARCH D11/5.1/7.5/7.8、旧设计替代指针及既有schema/metric;无版本bump、无新生产字段/DDL。Wiki站已下线,不虚报线上页更新。 + +续作提交:`73008ad test: apply evidence-based live checks without hiding regressions`。最终提交前实际门:`t56-precommit-unit.log/.exit` **1357 passed/0**,`t56-precommit-affected.log` **331 passed**(包含生产默认factory节点),`t56-precommit-check.log/.exit` **make check通过/0**,`t56-precommit-collect.log` **90 collected**,`t56-precommit-compile.log`通过;`git diff --check`通过,`git diff --quiet -- src`确认生产零差异。T8仅文档部分完成,不勾选完整验收门。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index f509924..485d438 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -458,6 +458,13 @@ "relation": "tested_by", "evidence": "T0–T4/T7:1241单测与11隔离变异;未覆盖live/集成/发布", "added": "2026-09-09T05:39:12.170598+00:00" + }, + { + "source": "schema:llm-calls", + "target": "design:2026-09-09-134-thinking-contracts-design", + "relation": "implements", + "evidence": "复用既有26字段,四种行来源与无推理成败NULL;不增加生产数据面", + "added": "2026-09-09T06:32:06.527808+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index bca4254..6d27ba9 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,8 +1,9 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-09-09 05:39 UTC +> 自动生成,更新时间:2026-09-09 06:32 UTC ## design (42) + - [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design` - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` @@ -47,6 +48,7 @@ - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` ## finding (15) + - [1.3.4 T0–T4 与 T7 确定性验证](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation` - [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload` - [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance` @@ -64,6 +66,7 @@ - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` ## plan (37) + - [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts` - [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` @@ -103,11 +106,14 @@ - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` ## review (1) + - [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review` ## schema (1) + - [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls` ## metric (2) + - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` - [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage` diff --git a/research-wiki/log.md b/research-wiki/log.md index ef3fce5..1f4a0b1 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -154,3 +154,5 @@ - [2026-09-09 04:48 UTC] 重建索引: 97 篇页面 - [2026-09-09 05:39 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --tested_by--> finding:2026-09-09-134-thinking-contracts-validation - [2026-09-09 05:39 UTC] 重建索引: 98 篇页面 +- [2026-09-09 06:32 UTC] 新增边: schema:llm-calls --implements--> design:2026-09-09-134-thinking-contracts-design +- [2026-09-09 06:32 UTC] 重建索引: 98 篇页面 diff --git a/research-wiki/metrics/call-telemetry-coverage.md b/research-wiki/metrics/call-telemetry-coverage.md index 19ffabf..a3f6077 100644 --- a/research-wiki/metrics/call-telemetry-coverage.md +++ b/research-wiki/metrics/call-telemetry-coverage.md @@ -7,3 +7,13 @@ date: 2026-07-20 # 每次调用必录覆盖率(含缓存命中/失败/取消) +## 1.3.4 契约与基线 + +| 指标 | 确定性阈值/证据 | 实际 live 基线 | +| --- | --- | --- | +| 三个无推理入口成败行档位 NULL | embed、recognize_text、parse_layout 真实 client→emitter→临时 SQLite,非空失败/成功计数与 NULL 全部成立,契约要求100% | 待首次实际运行,不填伪百分比 | +| chat 阳性 | 糖失败 auto、显式意图失败、nearest 成功实际档均精确匹配,要求100%;防恒 NULL 假绿 | 待首次实际运行 | +| 四种行来源 | 真实成功=applied;失败=effective;cache_hit/scope终态=本次请求级 | 排除缓存/失败后才可做实际档分析 | +| live 覆盖 | 逐轮 PASS/FAIL/UNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS | + +复用 schema:llm-calls(无新字段/DDL),证据索引见 `findings/2026-09-09-134-thinking-contracts-validation.md`。独立错误取证只在 tests 内存,Markdown 只记录白名单安全摘要与布尔校验,不使用生产遥测旁路补失踪尝试。生产埋点仍是 TelemetryEmitter 单点出口。 diff --git a/research-wiki/plans/2026-09-09-134-thinking-contracts.md b/research-wiki/plans/2026-09-09-134-thinking-contracts.md index d11e32f..eec7893 100644 --- a/research-wiki/plans/2026-09-09-134-thinking-contracts.md +++ b/research-wiki/plans/2026-09-09-134-thinking-contracts.md @@ -7,7 +7,7 @@ date: 2026-09-09 # 1.3.4 推理契约与测试证据实施计划 -> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),T0–T4、T7 已实现并通过确定性验证;T5/T6/T8/T9 待执行**。 +> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),T0–T7 已实现并通过确定性验证;T8 文档已同步,独立验证/集成/live 与 T9 待执行**。 > 设计:`research-wiki/designs/2026-09-09-134-thinking-contracts-design.md`,用户已正式批准。 > 目标:解决 #21 的受管推理语义漏洞、#25 的测试归因漏洞、#26 的客户端遥测守卫缺口,不扩展生产端口或遥测 schema。 > 方案:在既有推理决策层添加窄校验并接入工厂/默认 transport;测试侧独立保留请求与响应证据,按明确命题判定覆盖。缓存仍由下游显式迁移,生产治理循环不重写。 @@ -263,7 +263,7 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc **验证**:`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py -q`。安全测试使用假的唯一 sentinel 凭据/私有提示词,逐文件检查不出现 sentinel,不能拿真实密钥做输出搜索。 -- [ ] 提交点:`test: distinguish unsupported live coverage from library failures`。 +- [x] 实现及离线证据完成:窄分类/hooks/独立身份/逐轮安全报告;与 T6 合并提交。 ### T6:迁移四个 live 文件并离线化装配断言 @@ -286,7 +286,7 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc **验证**:`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py::TestDefaultClientFactory -q`;`conda run -n PolyGateway pytest tests/e2e/ -m slow --collect-only -q`(只采集,不视作真实通过)。在离线注入旧整类 skip、丢第一轮、UNKNOWN→PASS、identity 丢失→skip 变异,分别红;新实现恢复绿。 -- [ ] 提交点:`test: apply evidence-based live checks without hiding regressions`。 +- [x] 实现及日常离线验证/90节点collect-only完成;未执行live,不代表能力覆盖通过。 ### T7:无推理路径真链路与四类变异 @@ -378,4 +378,10 @@ chat 阳性走真实 RetryMW+emitter:True 糖失败 auto、显式请求失 ## 本轮实施证据 -T0–T4/T7 的命令、实际失败与修复、11 个隔离变异及 1241 项单测通过,见 `findings/2026-09-09-134-thinking-contracts-validation.md`。未执行 live、集成和发布,T5/T6 的测试支持文件未创建。 +T0–T4/T7 的命令、实际失败与修复、11 个隔离变异及 1241 项单测通过,见 `findings/2026-09-09-134-thinking-contracts-validation.md`。T5/T6 已续作:1357单元通过、8个隔离假绿变异exit1/还原0、e2e 90节点仅采集;T8文档同步完成。未执行live、集成、独立verifier和发布。具体节点及残余见同一finding续作节。 + +### T5/T6续作决策记录 + +父会话确认无已批准型号→400机器type白名单:不编造,缺机器证据400默认FAIL;精确预期拒绝契约离线守卫,具体live负向缺基线记录未验证。不可关闭命题完整合格轮次有OBSERVED支持本条件下未关闭,全ABSENT证伪,无OBSERVED但UNKNOWN未覆盖。T8复选框保持未勾选,因为独立verifier与全量/live证据门未执行;本轮仅其文档同步部分完成,禁止发布。 + +T5/T6实现提交:`73008ad`。最终日常单元1357、受影响含factory331、make check、compileall、e2e collect-only90通过;完整T8/T9仍未执行。日志路径及8项红→还原绿详见同一finding。 diff --git a/research-wiki/schemas/llm-calls.md b/research-wiki/schemas/llm-calls.md index a40dd97..37bede5 100644 --- a/research-wiki/schemas/llm-calls.md +++ b/research-wiki/schemas/llm-calls.md @@ -7,11 +7,10 @@ date: 2026-07-20 # 表结构: llm_calls(遥测 26 字段) - ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) | 列 | 类型 | 说明 | -|---|---|---| +| --- | --- | --- | | call_id | TEXT PRIMARY KEY | 每次尝试独立 UUID;INSERT OR IGNORE 幂等 | | parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) | | model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) | @@ -31,12 +30,12 @@ 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`「明确要求不推理」不可混同)。见下方口径 | +| reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 | ## usage/成本口径(2026-07-30,est_tokens 解耦) | usage_source | 含义 | 生产者 | cost | -|---|---|---|---| +| --- | --- | --- | --- | | `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是事实) | 按 token 换算 | | `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 | | `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | NULL | @@ -67,7 +66,7 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL; 三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同: | 入口 | 调用者 | 有生效源? | 记什么 | -|---|---|---|---| +| --- | --- | --- | --- | | `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` | | `emit_cache_hit` | TelemetryMW(最外) | 无 | 仅 `request.sampling` | | `emit_terminal_failure` | TelemetryMW | 无 | 仅 `request.sampling` | @@ -107,17 +106,18 @@ ORDER BY model, calls DESC; ## 推理档位口径(2026-09-04,issue #20) -`reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。 +`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` | +| `emit_cache_hit` | 无 | 本次 `request.reasoning_effort`,不取历史 applied、不推源级 | +| `emit_terminal_failure` | 无 | 本次 `request.reasoning_effort`,可能尚未选源 | -成功行必须读实发档而非重算: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。 +真实成功尝试必须读实际编码档而非重算(分析须同时排除 cache_hit 和 error;未知 AUTO 仅尽力,不证明上游推理,raw-only=NULL): 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。 OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。 @@ -142,3 +142,7 @@ ORDER BY model, calls DESC; ## 评估基线 首版无历史基线,标"待首次运行后建立";验收断言: 单测覆盖成功/失败/缓存命中/取消四路径各产生恰一行;并发 50 协程写全落库。 + +## 1.3.4 测试侧证据(不新增 schema) + +`tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。 From 3eb22d2a55a5be687f7d12b96ec94072c0fb201a Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 03:34:48 -0400 Subject: [PATCH 14/17] test: fix structured reask evidence and live coverage conclusions --- ...09-09-134-thinking-contracts-validation.md | 32 +- .../2026-09-09-134-thinking-contracts.md | 6 + tests/e2e/conftest.py | 48 ++- tests/e2e/test_smoke_gateway.py | 8 +- tests/e2e/test_thinking_live.py | 155 +++++++-- tests/live_evidence.py | 12 +- tests/unit/test_live_evidence.py | 307 ++++++++++++++++++ 7 files changed, 540 insertions(+), 28 deletions(-) diff --git a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md index 7f5c7bd..5f9b1a5 100644 --- a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md +++ b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md @@ -106,9 +106,39 @@ date: 2026-09-09 | make check初报SIM117/B017 | 合并测试上下文,按真实解析异常指定类型,不加ignore;最终静态门通过 | | pi-lens解释器/StrEnum噪音 | 记录 `t56-diagnostics.txt`,conda内真实导入与ruff为门,不改任务外枚举;pytest wrapper generator的return report是协议必需,独立next/send/StopIteration.value测试通过 | | T10型号→400机器字段基线不存在 | 父会话明确确认:不编造白名单,实际400默认FAIL并逐轮留证。纯负向契约精确类型/状态/type单独测试;具体live预期拒绝未验证、需人工基线 | -| 结构化反馈重问 | 请求摘要预期固定,发生反馈重问更改messages时保守FAIL,不从待测payload补齐预期;未改生产结构化行为,不声称该取证分支已取得live覆盖 | +| 结构化反馈重问(已被本次审查修复替代) | 原固定摘要会误拒正常反馈重问,独立审查判 P1;不再保留为可接受限制,修复与真实 StructuredMW 离线两响应证据见下节 | | 发布/集成/live/下游 | 本任务未执行,M2空wire、M3非流式UNKNOWN、身份不足、三项目实际配置缺失仍保留为证据门 | 文档已同步README M1–M9、CHANGELOG未发布段、env注释、ARCH D11/5.1/7.5/7.8、旧设计替代指针及既有schema/metric;无版本bump、无新生产字段/DDL。Wiki站已下线,不虚报线上页更新。 续作提交:`73008ad test: apply evidence-based live checks without hiding regressions`。最终提交前实际门:`t56-precommit-unit.log/.exit` **1357 passed/0**,`t56-precommit-affected.log` **331 passed**(包含生产默认factory节点),`t56-precommit-check.log/.exit` **make check通过/0**,`t56-precommit-collect.log` **90 collected**,`t56-precommit-compile.log`通过;`git diff --check`通过,`git diff --quiet -- src`确认生产零差异。T8仅文档部分完成,不勾选完整验收门。 + +## 独立审查四项修复(起点 d332287) + +按 receiving-code-review 对照实际调用链核验 `verify134/live-contracts.md`:四项均成立。此处沿用户限定仅更新既有 finding/plan,不新建图实体或扩写设计。生产/版本零差异;没有 live、付费请求或子代理。本节是实现者核验,不冒充新一轮独立复审。 + +| 审查项/核验依据 | 最小修复与守卫 | +| --- | --- | +| P1 结构化重问:StructuredMW._with_feedback 追加两消息,旧 hook 固定完整摘要必错 | 仅结构化模型 smoke 启用原提示词前缀摘要、成对 assistant/user 字符串与已有预算;首个 attempt 仍精确原消息。薄委托保存本次摘要只校验 HTTP 保真,不从 wire 反填预期,不关闭重问。真实 GatewayClient+StructuredMW+MockTransport 缺字段→合法响应恰两 HTTP PASS;破坏前缀、角色、内容类型、配对、预算、wire 均 FAIL;首轮凭空反馈另有 FAIL 守卫 | +| P1 未登记候选:旧 None 分支被置 cannot_disable,ABSENT 假失败 | 明确 observation-only,先全轮请求/身份资格,再 UNCOVERED;ABSENT/OBSERVED/UNKNOWN 与资格 FAIL 四组运行真实 T10 消费者(剔除 .env 读取语句),不调用模型、不改长复核条件或轮次 | +| P1 结论重新 UUID,多个型号失去对应关系 | 每用例显式传同一 run/model;子运行用该 run 下唯一 matrix_id 关联,结论含完整计划/完成分母。L1–L8、T10短长/各档/默认全部复用;两型号一 PASS 一 UNCOVERED,在 NONE、tiers、L8 三组逐文件验证关联和轮数 | +| P2 机器字段未落盘 | 仅精确 model_not_found 保留;其他字符串(含假凭据 sentinel)为 omitted;写入端再拒未知机器值,绝不输出任意上游 type | + +### 本轮先红后绿与验证 + +日志位于 `tests/outputs/134/`,各命令结果后立即保存 `.exit`,原命令不接管道。 + +| 命令/节点 | 红证据 | 绿证据 | +| --- | --- | --- | +| `pytest tests/unit/test_live_evidence.py -k structured_reask -q` | `review-f1-red`:1 failed/6 passed,合法两响应误判 FAIL,exit 1 | `review-f1-green`:7 passed,exit 0 | +| `pytest tests/unit/test_live_evidence.py -k first_attempt_requires -q` | `review-f1-first-red`:首轮多反馈被放行,1 failed,exit 1 | `review-final-affected`:含该节点共129 passed,exit 0 | +| `pytest tests/unit/test_live_evidence.py -k unregistered_candidate -q` | `review-f2-red`:ABSENT 被误判FAIL,1 failed/3 passed,exit 1 | `review-f2-green`:4 passed,exit 0 | +| `pytest tests/unit/test_live_evidence.py -k capability_conclusions -q` | `review-f3-red`:三组结论缺型号断言红,3 failed,exit 1 | `review-f3-green`:三组+未登记四组共7 passed,exit 0 | +| `pytest tests/unit/test_live_evidence.py -k safe_machine_type -q` | `review-f4-red`:三组缺机器字段,3 failed,exit 1 | `review-f4-green`:3 passed,exit 0 | +| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | — | `review-final-unit`:**1375 passed,exit 0** | +| `make check` | — | `review-final-check`:格式/ruff/import-linter 1 kept,exit 0 | +| `conda run --no-capture-output -n PolyGateway pytest tests/e2e/ -m slow --collect-only -q` | — | `review-final-collect`:**90 collected,exit 0**;不是90通过 | + +上表节点命令也均加 `conda run --no-capture-output -n PolyGateway`;未使用缺 import 的伪红。最终受影响文件129单测通过,相比111新增18节点。pi-lens仍提示非conda缺httpx/pytest/pydantic及StrEnum等旧噪音,按已批准方向记录后继续conda门;新增测试命名空间显式 Any 类型,不抑制真实错误。 + +**尚未完成**:修复后独立复审、集成/make test覆盖率、真实能力取证、下游迁移和发布;T8/T9保持未勾选。M2空wire、M3非流UNKNOWN、真实机器拒绝白名单及外部服务状态不因离线绿变成已覆盖。 diff --git a/research-wiki/plans/2026-09-09-134-thinking-contracts.md b/research-wiki/plans/2026-09-09-134-thinking-contracts.md index eec7893..afa5fa5 100644 --- a/research-wiki/plans/2026-09-09-134-thinking-contracts.md +++ b/research-wiki/plans/2026-09-09-134-thinking-contracts.md @@ -385,3 +385,9 @@ T0–T4/T7 的命令、实际失败与修复、11 个隔离变异及 1241 项 父会话确认无已批准型号→400机器type白名单:不编造,缺机器证据400默认FAIL;精确预期拒绝契约离线守卫,具体live负向缺基线记录未验证。不可关闭命题完整合格轮次有OBSERVED支持本条件下未关闭,全ABSENT证伪,无OBSERVED但UNKNOWN未覆盖。T8复选框保持未勾选,因为独立verifier与全量/live证据门未执行;本轮仅其文档同步部分完成,禁止发布。 T5/T6实现提交:`73008ad`。最终日常单元1357、受影响含factory331、make check、compileall、e2e collect-only90通过;完整T8/T9仍未执行。日志路径及8项红→还原绿详见同一finding。 + +### 独立审查修复续作(d332287 后) + +已按 receiving-code-review 核验四项并仅修改测试及本计划/finding:结构化重问采用先验前缀/反馈角色与预算+委托摘要的 wire 保真;未登记候选先资格再观测未覆盖;同一用例 run/model 关联所有子运行并保留计划/完成分母;落盘机器字段只准 model_not_found/omitted。没有生产/版本修改、slow执行或额外调用预算。 + +新增18个离线节点,四项及首轮精确消息守卫均有目标断言先红→绿。最终129项取证单测、1375全单元、make check及e2e collect-only90通过;命令日志/退出码详见既有finding“独立审查四项修复”。修复后独立复审、集成与live尚未完成,**T8/T9仍不勾选,不放行发布**。原结构化重问“保守FAIL”说明已标为被本次修复替代,不能再当成可接受限制。 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index eef1582..c5a65cd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -51,6 +51,8 @@ class _Attempt: call_id: str exchanges: list[_Exchange] = field(default_factory=list) + messages_digest: str | None = None + messages_valid: bool = False class LiveCapture: @@ -66,6 +68,14 @@ class LiveCapture: raise ValueError("取证矩阵缺少必需预期或混用 chat/embed") if not isinstance(expected["control"], dict): raise ValueError("control 必须是显式对象") + if "structured_max_retries" in expected and ( + "stream" not in expected + or type(expected["structured_max_retries"]) is not int + or expected["structured_max_retries"] < 0 + or type(expected.get("messages_prefix_length")) is not int + or expected["messages_prefix_length"] < 1 + ): + raise ValueError("结构化预期缺少合法前缀长度或重问预算") self._expectations = {name: dict(value) for name, value in expectations.items()} self._round: ContextVar[tuple[str, str]] = ContextVar("live_round") self._attempt: ContextVar[_Attempt] = ContextVar("live_attempt") @@ -146,6 +156,29 @@ class LiveCapture: self._notes[key].append("原始 JSON 身份无法独立解析") return HttpEvidence(call_id, exchange.checks, status, body, identity) + def observe_messages(self, source: SourceConfig, messages: list[dict[str, Any]]) -> None: + """先验前缀/反馈契约与委托摘要分开;摘要仅验证 HTTP 序列化保真。""" + expected = self._expectations[source.name] + if "structured_max_retries" not in expected: + return + attempt = self._attempt.get() + prefix_length = expected["messages_prefix_length"] + feedback = messages[prefix_length:] + attempt.messages_digest = messages_digest(messages) + attempt.messages_valid = ( + (not feedback or bool(self._records[self._round.get()])) + and messages_digest(messages[:prefix_length]) == expected["messages_digest"] + and len(feedback) % 2 == 0 + and len(feedback) <= 2 * expected["structured_max_retries"] + and all( + isinstance(message, dict) + and set(message) == {"role", "content"} + and message["role"] == ("assistant" if index % 2 == 0 else "user") + and isinstance(message["content"], str) + for index, message in enumerate(feedback) + ) + ) + def client_factory(self, source: SourceConfig) -> httpx.AsyncClient: """鉴权仅内存比较;沿已校验源 timeout/trust_env。""" expected = self._expectations[source.name] @@ -176,8 +209,13 @@ class LiveCapture: "model": payload.get("model") == expected["model"], "authorization": request.headers.get("Authorization") == f"Bearer {source.api_key}", "control": control == expected["control"], - "messages_digest": messages_digest(payload.get("messages", payload.get("input"))) - == expected["messages_digest"], + "messages_digest": ( + attempt.messages_valid + and messages_digest(payload.get("messages")) == attempt.messages_digest + if "structured_max_retries" in expected + else messages_digest(payload.get("messages", payload.get("input"))) + == expected["messages_digest"] + ), } if "stream" in expected: checks["stream"] = payload.get("stream") is expected["stream"] and ( @@ -255,6 +293,7 @@ class ObservedTransport: ) -> TransportResult: """与生产端口逐参数同签名。""" with self._capture.attempt_context(call_id): + self._capture.observe_messages(source, messages) return await self._transport.complete( messages=messages, source=source, @@ -315,6 +354,7 @@ def chat_expectations( messages: list[dict[str, Any]], stream: bool, controls: Mapping[str, dict[str, Any]], + structured_max_retries: int | None = None, ) -> dict[str, dict[str, Any]]: """URL 从源配置声明,控制片段必须由矩阵独立给出。""" result = {} @@ -330,6 +370,10 @@ def chat_expectations( "control": controls[source.name], "messages_digest": messages_digest(messages), } + if structured_max_retries is not None: + result[source.name].update( + messages_prefix_length=len(messages), structured_max_retries=structured_max_retries + ) return result diff --git a/tests/e2e/test_smoke_gateway.py b/tests/e2e/test_smoke_gateway.py index 208ad20..e0e22d8 100644 --- a/tests/e2e/test_smoke_gateway.py +++ b/tests/e2e/test_smoke_gateway.py @@ -40,7 +40,13 @@ async def _smoke(matrix, prompt, validate, *, stream=True, structured=None): controls = source_controls(settings) capture = LiveCapture( expectations=chat_expectations( - settings, messages=messages, stream=stream, controls=controls + settings, + messages=messages, + stream=stream, + controls=controls, + structured_max_retries=( + settings.structured_max_retries if isinstance(structured, type) else None + ), ) ) async with observed_client(settings, capture) as client: diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py index 6a24c42..6c26e28 100644 --- a/tests/e2e/test_thinking_live.py +++ b/tests/e2e/test_thinking_live.py @@ -125,8 +125,36 @@ def _tier_settings(model): return dataclasses.replace(base, sources=(source,)) +@dataclasses.dataclass +class _CaseRun: + """单型号用例关联;子运行以同一 run_id 下的 matrix_id 唯一定位原件。""" + + run_id: str + model: str + subruns: list[dict] = dataclasses.field(default_factory=list) + + def report_fields(self): + """计划分母在收集前登记,完成数量只按实际回收轮次填写。""" + return { + "session_id": self.run_id, + "requested_model": self.model, + "subruns": self.subruns, + "planned_rounds": sum(group["planned_rounds"] for group in self.subruns), + "completed_rounds": sum(group["completed_rounds"] for group in self.subruns), + } + + async def _collect_rounds( - settings, *, rounds, stream, prompt, matrix_id, effort=None, capabilities=None, concurrency=1 + settings, + *, + run, + rounds, + stream, + prompt, + matrix_id, + effort=None, + capabilities=None, + concurrency=1, ): """保留所有失败轮,不把可用轮集合偷偷当新分母。""" if rounds < 1 or concurrency < 1: @@ -142,7 +170,13 @@ async def _collect_rounds( settings, messages=messages, stream=stream, controls=controls ) ) - run_id = uuid4().hex + if any(source.model != run.model for source in settings.sources): + raise ValueError("用例型号与收集源不一致") + if any(group["matrix_id"] == matrix_id for group in run.subruns): + raise ValueError("用例子运行标识重复") + group = {"matrix_id": matrix_id, "planned_rounds": rounds, "completed_rounds": 0} + run.subruns.append(group) + run_id = run.run_id semaphore = asyncio.Semaphore(concurrency) async with observed_client(settings, capture, capabilities=capabilities) as client: @@ -188,21 +222,29 @@ async def _collect_rounds( if isinstance(value, BaseException): raise value values.append(value) + group["completed_rounds"] = len(values) counts = summarize_verdicts([value["verdict"] for value in values], planned_rounds=rounds) write_live_round( _OUT_DIR, run_id=run_id, matrix_id=matrix_id + "-rounds", round_index=0, - safe_fields={"counts": counts, "completed_rounds": len(values), "planned_rounds": rounds}, + safe_fields={ + "session_id": run_id, + "requested_model": run.model, + "counts": counts, + "completed_rounds": len(values), + "planned_rounds": rounds, + }, ) return values -async def _run_rounds(rounds, *, stream=True, matrix_id="thinking", **source_overrides): +async def _run_rounds(rounds, *, run, stream=True, matrix_id="thinking", **source_overrides): """L1–L8 的资格证据出口,不作整类 skip。""" return await _collect_rounds( _settings(**source_overrides), + run=run, rounds=rounds, stream=stream, prompt=_PROMPT, @@ -218,6 +260,8 @@ def _qualified(rows, *, planned_rounds): def _coverage(rows, *, planned_rounds, proposition): """只有全轮资格通过才进入推理观测命题。""" verdict = _qualified(rows, planned_rounds=planned_rounds) + if verdict.status == "PASS" and proposition == "observation-only": + return LiveVerdict("UNCOVERED", "未登记候选只保留观测,不自动登记能力") if verdict.status == "PASS": verdict = assess_thinking_coverage( [row["response"].thinking_observation for row in rows], @@ -227,17 +271,18 @@ def _coverage(rows, *, planned_rounds, proposition): return verdict -def _conclude(matrix, verdict, *, proposition=None): +def _conclude(matrix, verdict, *, run, proposition=None): """命题汇总先落盘再交给 pytest,不覆盖逐轮原件。""" write_live_round( _OUT_DIR, - run_id=uuid4().hex, + run_id=run.run_id, matrix_id=matrix, round_index=0, safe_fields={ "status": verdict.status, "reason": verdict.reason, "proposition": proposition, + **run.report_fields(), }, ) enforce_verdict(verdict) @@ -247,18 +292,27 @@ class TestMiniMaxM3: """AUTO 拒绝已移至离线契约;真实开启明确请求 medium。""" async def test_l1_disable_actually_disables(self): + run = _CaseRun(uuid4().hex, "MiniMax-M3") rows = await _run_rounds( - _ROUNDS, matrix_id="L1", provider="minimax", model="MiniMax-M3", enable_thinking=False + _ROUNDS, + run=run, + matrix_id="L1", + provider="minimax", + model="MiniMax-M3", + enable_thinking=False, ) _conclude( "L1", _coverage(rows, planned_rounds=_ROUNDS, proposition="disabled"), + run=run, proposition="disabled", ) async def test_l2_enable_actually_enables(self): + run = _CaseRun(uuid4().hex, "MiniMax-M3") rows = await _run_rounds( _ROUNDS, + run=run, matrix_id="L2", provider="minimax", model="MiniMax-M3", @@ -267,14 +321,17 @@ class TestMiniMaxM3: _conclude( "L2", _coverage(rows, planned_rounds=_ROUNDS, proposition="enabled"), + run=run, proposition="enabled", ) async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self): """指定历史 prompt 锚点回归,不宣称关闭能力已覆盖。""" + run = _CaseRun(uuid4().hex, "MiniMax-M3") rounds = max(3, _ROUNDS // 3) off = await _run_rounds( rounds, + run=run, matrix_id="L2b-off", provider="minimax", model="MiniMax-M3", @@ -282,6 +339,7 @@ class TestMiniMaxM3: ) on = await _run_rounds( rounds, + run=run, matrix_id="L2b-on", provider="minimax", model="MiniMax-M3", @@ -295,22 +353,27 @@ class TestMiniMaxM3: verdict = LiveVerdict( "PASS" if distinct else "FAIL", "指定历史 prompt 锚点比较;不是关闭证明" ) - _conclude("L2b", verdict, proposition="historical-prompt-anchor") + _conclude("L2b", verdict, run=run, proposition="historical-prompt-anchor") async def test_l3_no_opinion_is_the_model_default(self): - rows = await _run_rounds(_ROUNDS, matrix_id="L3", provider="minimax", model="MiniMax-M3") + run = _CaseRun(uuid4().hex, "MiniMax-M3") + rows = await _run_rounds( + _ROUNDS, run=run, matrix_id="L3", provider="minimax", model="MiniMax-M3" + ) verdict = _qualified(rows, planned_rounds=_ROUNDS) if verdict.status == "PASS" and any( row["response"].applied_effort is not None for row in rows ): verdict = LiveVerdict("FAIL", "不表态路径擅自记录档位") - _conclude("L3", verdict, proposition="no-opinion-not-capability") + _conclude("L3", verdict, run=run, proposition="no-opinion-not-capability") async def test_l3b_none_is_recognised_not_silently_dropped(self): """保留原非法 raw 值对照预算,但不提升 UNKNOWN。""" + run = _CaseRun(uuid4().hex, "MiniMax-M3") rounds = max(3, _ROUNDS // 3) bogus = await _run_rounds( rounds, + run=run, matrix_id="L3b-bogus", provider="minimax", model="MiniMax-M3", @@ -318,6 +381,7 @@ class TestMiniMaxM3: ) off = await _run_rounds( rounds, + run=run, matrix_id="L3b-off", provider="minimax", model="MiniMax-M3", @@ -327,13 +391,15 @@ class TestMiniMaxM3: _coverage(bogus, planned_rounds=rounds, proposition="enabled"), _coverage(off, planned_rounds=rounds, proposition="disabled"), ] - _conclude("L3b", _combine(verdicts), proposition="raw-counterexample") + _conclude("L3b", _combine(verdicts), run=run, proposition="raw-counterexample") async def test_l4_raw_only_explicit_high(self): """退出受管意图后才保留 raw high;双来源拒绝在 unit 守卫。""" + run = _CaseRun(uuid4().hex, "MiniMax-M3") rounds = max(3, _ROUNDS // 2) rows = await _run_rounds( rounds, + run=run, matrix_id="L4", provider="minimax", model="MiniMax-M3", @@ -342,14 +408,17 @@ class TestMiniMaxM3: _conclude( "L4", _coverage(rows, planned_rounds=rounds, proposition="enabled"), + run=run, proposition="enabled", ) async def test_l5_non_stream_path_is_distinguishable_and_honestly_unknown(self): """保留流/非流预算;UNKNOWN 是明确未覆盖而非长度锚点成功。""" + run = _CaseRun(uuid4().hex, "MiniMax-M3") rounds = max(3, _ROUNDS // 2) off = await _run_rounds( rounds, + run=run, matrix_id="L5-off", stream=False, provider="minimax", @@ -358,6 +427,7 @@ class TestMiniMaxM3: ) on = await _run_rounds( rounds, + run=run, matrix_id="L5-on", stream=False, provider="minimax", @@ -372,6 +442,7 @@ class TestMiniMaxM3: _coverage(on, planned_rounds=rounds, proposition="enabled"), ] ), + run=run, proposition="nonstream-enabled-disabled", ) @@ -389,22 +460,36 @@ class TestOtherProviders: [("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")], ) async def test_existing_profiles_still_disable(self, matrix, provider, model): + run = _CaseRun(uuid4().hex, model) rows = await _run_rounds( - _ROUNDS, matrix_id=matrix, provider=provider, model=model, enable_thinking=False + _ROUNDS, + run=run, + matrix_id=matrix, + provider=provider, + model=model, + enable_thinking=False, ) _conclude( matrix, _coverage(rows, planned_rounds=_ROUNDS, proposition="disabled"), + run=run, proposition="disabled", ) async def test_qwen_enabled_is_observed(self): + run = _CaseRun(uuid4().hex, "qwen3.7-plus") rows = await _run_rounds( - _ROUNDS, matrix_id="L6b", provider="qwen", model="qwen3.7-plus", enable_thinking=True + _ROUNDS, + run=run, + matrix_id="L6b", + provider="qwen", + model="qwen3.7-plus", + enable_thinking=True, ) _conclude( "L6b", _coverage(rows, planned_rounds=_ROUNDS, proposition="enabled"), + run=run, proposition="enabled", ) @@ -419,9 +504,11 @@ class TestCapabilityDrift: ), ) async def test_declared_capability_matches_reality(self, model): + run = _CaseRun(uuid4().hex, model) rounds = max(3, _ROUNDS // 2) rows = await _run_rounds( rounds, + run=run, matrix_id="L8", provider=_MODEL_PROVIDER[model], model=model, @@ -430,14 +517,16 @@ class TestCapabilityDrift: _conclude( "L8", _coverage(rows, planned_rounds=rounds, proposition="disabled"), + run=run, proposition="disabled", ) -async def _probe_effort(model, effort, *, rounds, prompt, prompt_kind): +async def _probe_effort(model, effort, *, run, rounds, prompt, prompt_kind): """临时全档表仅用于 T10 探测,不写回 DEFAULT,也不生成预期 wire。""" return await _collect_rounds( _tier_settings(model), + run=run, rounds=rounds, stream=True, prompt=prompt, @@ -453,10 +542,22 @@ class TestTierProbe: @pytest.mark.parametrize("model", sorted(_MODEL_PROVIDER)) async def test_t10_none_direction_matches_declaration(self, model): + run = _CaseRun(uuid4().hex, model) capability = DEFAULT_CAPABILITIES.get(model) - proposition = "disabled" if capability and capability.can_disable else "cannot_disable" + proposition = ( + "observation-only" + if capability is None + else "disabled" + if capability.can_disable + else "cannot_disable" + ) short = await _probe_effort( - model, Effort.NONE, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="none-short" + model, + Effort.NONE, + run=run, + rounds=_TIER_ROUNDS, + prompt=_TIER_PROMPT, + prompt_kind="none-short", ) verdict = _coverage(short, planned_rounds=_TIER_ROUNDS, proposition=proposition) # 沿既有矩阵:短档没有 OBSERVED 才做长上下文复核;不新增锚点调用。 @@ -466,6 +567,7 @@ class TestTierProbe: long_rows = await _probe_effort( model, Effort.NONE, + run=run, rounds=_TIER_LONG_ROUNDS, prompt=_TIER_LONG_PROMPT, prompt_kind="none-long", @@ -475,18 +577,18 @@ class TestTierProbe: planned_rounds=_TIER_ROUNDS + _TIER_LONG_ROUNDS, proposition=proposition, ) - if capability is None and verdict.status != "FAIL": - verdict = LiveVerdict("UNCOVERED", "未登记候选只保留观测,不自动登记能力") - _conclude("T10-none", verdict, proposition=proposition) + _conclude("T10-none", verdict, run=run, proposition=proposition) @pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES)) async def test_t10_declared_tiers_actually_reason(self, model): + run = _CaseRun(uuid4().hex, model) tiers = [e for e in DEFAULT_CAPABILITIES[model].supported_efforts if e is not Effort.NONE] verdicts = [] for tier in tiers: rows = await _probe_effort( model, tier, + run=run, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="tier-" + tier.value, @@ -495,22 +597,31 @@ class TestTierProbe: verdicts.append(verdict) write_live_round( _OUT_DIR, - run_id=uuid4().hex, + run_id=run.run_id, matrix_id="T10-tier", round_index=0, safe_fields={ "requested_model": model, + "session_id": run.run_id, + "proposition": "enabled", + "subruns": [run.subruns[-1]], + "planned_rounds": _TIER_ROUNDS, + "completed_rounds": len(rows), "requested_effort": tier.value, "status": verdict.status, "reason": verdict.reason, }, ) - _conclude("T10-tiers", _combine(verdicts), proposition="enabled-all-declared-tiers") + _conclude( + "T10-tiers", _combine(verdicts), run=run, proposition="enabled-all-declared-tiers" + ) @pytest.mark.parametrize("model", ["gemini-3.1-pro", "gpt-5.5", "glm-5.3"]) async def test_t10_no_opinion_stays_no_opinion(self, model): + run = _CaseRun(uuid4().hex, model) rows = await _collect_rounds( _tier_settings(model), + run=run, rounds=_TIER_ROUNDS, stream=True, prompt=_TIER_PROMPT, @@ -522,4 +633,4 @@ class TestTierProbe: row["response"].applied_effort is not None for row in rows ): verdict = LiveVerdict("FAIL", "默认基线擅自推定档位") - _conclude("T10-default", verdict, proposition="no-opinion-not-capability") + _conclude("T10-default", verdict, run=run, proposition="no-opinion-not-capability") diff --git a/tests/live_evidence.py b/tests/live_evidence.py index e870d7c..f121d32 100644 --- a/tests/live_evidence.py +++ b/tests/live_evidence.py @@ -225,11 +225,12 @@ _SAFE_FIELDS = frozenset( "error_type", "error_status", "evidence_notes", + "subruns", } ) _ATTEMPT_FIELDS = frozenset({"call_id", "error_type", "http"}) _HTTP_FIELDS = frozenset( - {"status_code", "request_checks", "identity_captured", "error_body_complete"} + {"status_code", "request_checks", "identity_captured", "error_body_complete", "machine_type"} ) @@ -243,6 +244,8 @@ def _validate_safe(fields: Mapping[str, Any]) -> None: for event in attempt["http"]: if not isinstance(event, dict) or set(event) != _HTTP_FIELDS: raise ValueError("非法 HTTP 报告") + if event["machine_type"] not in ("model_not_found", "omitted"): + raise ValueError("报告机器字段不是认可枚举") if not isinstance(event["request_checks"], dict) or any( type(v) is not bool for v in event["request_checks"].values() ): @@ -273,7 +276,7 @@ def write_live_round( def safe_attempts(attempts: Sequence[AttemptEvidence]) -> list[dict[str, Any]]: - """只导出事实布尔值、异常类和状态;不落盘任何上游正文。""" + """只导出事实、异常类与认可机器枚举;任意上游字符串一律省略。""" return [ { "call_id": attempt.call_id, @@ -284,6 +287,11 @@ def safe_attempts(attempts: Sequence[AttemptEvidence]) -> list[dict[str, Any]]: "request_checks": dict(event.request_checks), "identity_captured": event.raw_identity[0], "error_body_complete": event.error_body is not None, + "machine_type": ( + "model_not_found" + if error_machine_type(event) == "model_not_found" + else "omitted" + ), } for event in attempt.http ], diff --git a/tests/unit/test_live_evidence.py b/tests/unit/test_live_evidence.py index 7a4d86c..3b9c28c 100644 --- a/tests/unit/test_live_evidence.py +++ b/tests/unit/test_live_evidence.py @@ -4,6 +4,7 @@ import asyncio import inspect import json from dataclasses import replace +from typing import Any import httpx import pytest @@ -814,3 +815,309 @@ def test_pytest_report_hook_preserves_report_and_uses_safe_fallback(tmp_path, mo assert finished.value.value is report paths = list(tmp_path.rglob("*.md")) assert len(paths) == 1 and '"status": "FAIL"' in paths[0].read_text() + + +@pytest.mark.parametrize( + "damage", [None, "prefix", "role", "content", "unpaired", "budget", "wire"] +) +async def test_structured_reask_preserves_message_contract(tmp_path, damage, monkeypatch): + """真实 StructuredMW 缺字段后重问成功;前缀、反馈结构及 wire 破坏均失败。""" + from pydantic import BaseModel + + from polygateway import GatewaySettings + from polygateway.middleware.structured import StructuredMW + from tests.e2e.conftest import captured_chat_round, observed_client + from tests.unit.test_config import _BASE_ENV + + class Answer(BaseModel): + """离线最小结构化契约。""" + + answer: int + reason: str + + settings = replace( + GatewaySettings.from_env("LLM", env=_BASE_ENV), + sources=(_source(),), + structured_max_retries=1, + ) + capture = _capture(messages_prefix_length=1, structured_max_retries=1) + original_feedback = StructuredMW._with_feedback + + def feedback(self, *args): + """只破坏重问产物,不替代生产阶梯或解析。""" + request = original_feedback(self, *args) + messages = [dict(message) for message in request.messages] + if damage == "prefix": + messages[0]["content"] = "changed" + elif damage == "role": + messages[-1]["role"] = "assistant" + elif damage == "content": + messages[-1]["content"] = ["wrong-type"] + elif damage == "unpaired": + messages.pop() + elif damage == "budget": + messages.extend(messages[-2:]) + return replace(request, messages=messages) + + monkeypatch.setattr(StructuredMW, "_with_feedback", feedback) + requests = [] + + def handler(request): + requests.append(json.loads(request.content)) + body = _response() + body["choices"][0]["message"]["content"] = ( + '{"answer":5}' if len(requests) == 1 else '{"answer":5,"reason":"sum"}' + ) + return httpx.Response(200, json=body) + + original_factory = capture.client_factory + + def factory(source): + client = original_factory(source) + client._transport = httpx.MockTransport(handler) + if damage == "wire": + + async def corrupt(request): + payload = json.loads(request.content) + if len(payload["messages"]) > 1: + payload["messages"][-1]["content"] = "well-shaped-but-corrupted" + request._content = json.dumps(payload).encode() + + client.event_hooks["request"].insert(0, corrupt) + return client + + capture.client_factory = factory + async with observed_client(settings, capture) as client: + response, verdict = await captured_chat_round( + client, + capture, + run_id="structured", + matrix_id="reask", + round_index=1, + output_dir=tmp_path, + messages=_MESSAGES, + models={"source": "gpt-5.5"}, + aliases={}, + stream=False, + structured=Answer, + ) + assert response.structured_data.answer == 5 + assert len(requests) == 2 + assert verdict.status == ("PASS" if damage is None else "FAIL") + text = "".join(path.read_text() for path in tmp_path.rglob("*.md")) + assert _SECRET not in text and _PROMPT not in text + + +def _thinking_consumer(): + """仅加载 live 消费者定义与字面矩阵,跳过所有环境读取语句。""" + import ast + from pathlib import Path + from types import SimpleNamespace + + path = Path(__file__).parents[1] / "e2e/test_thinking_live.py" + tree = ast.parse(path.read_text()) + excluded = { + "_ENV", + "_HAS_SOURCE", + "pytestmark", + "_ROUNDS", + "_TIER_ROUNDS", + "_TIER_LONG_ROUNDS", + "_TIER_CONCURRENCY", + } + tree.body = [ + node + for node in tree.body + if not ( + isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id in excluded for target in node.targets + ) + ) + ] + namespace: dict[str, Any] = { + "_ROUNDS": 3, + "_TIER_ROUNDS": 2, + "_TIER_LONG_ROUNDS": 1, + "_TIER_CONCURRENCY": 1, + } + exec(compile(tree, str(path), "exec"), namespace) + return SimpleNamespace(**namespace), namespace + + +@pytest.mark.parametrize( + ("observation", "qualification", "expected"), + [ + (O.ABSENT, "PASS", "UNCOVERED"), + (O.OBSERVED, "PASS", "UNCOVERED"), + (O.UNKNOWN, "PASS", "UNCOVERED"), + (O.ABSENT, "FAIL", "FAIL"), + ], +) +async def test_unregistered_candidate_has_no_disable_declaration( + observation, qualification, expected +): + """执行真实 T10 消费者,未登记不等于不可关闭,资格失败仍红。""" + from types import SimpleNamespace + + live, namespace = _thinking_consumer() + conclusions = [] + calls = [] + + async def probe(model, effort, *, rounds, **kwargs): + calls.append(rounds) + return [ + { + "verdict": LiveVerdict(qualification, "safe"), + "response": SimpleNamespace(thinking_observation=observation), + } + for _ in range(rounds) + ] + + namespace["_probe_effort"] = probe + namespace["_conclude"] = lambda matrix, verdict, **kwargs: conclusions.append(verdict) + await live.TestTierProbe().test_t10_none_direction_matches_declaration("claude-haiku-5") + assert conclusions[0].status == expected + assert calls == ([2, 1] if observation is not O.OBSERVED and qualification == "PASS" else [2]) + + +@pytest.mark.parametrize("case", ["none", "tiers", "L8"]) +async def test_capability_conclusions_link_models_and_all_subruns(tmp_path, case): + """两个型号的 PASS/UNCOVERED 结论必须关联原件和完整短长/档位轮数。""" + from contextlib import asynccontextmanager + from types import SimpleNamespace + + from polygateway import GatewaySettings + from tests.unit.test_config import _BASE_ENV + + live, namespace = _thinking_consumer() + settings = GatewaySettings.from_env("LLM", env=_BASE_ENV) + namespace["_OUT_DIR"] = tmp_path + namespace["_tier_settings"] = lambda model: replace( + settings, sources=(replace(_source(), model=model),) + ) + namespace["_settings"] = lambda **kwargs: replace( + settings, sources=(replace(_source(), **kwargs),) + ) + namespace["enforce_verdict"] = lambda verdict: None + + @asynccontextmanager + async def client(*args, **kwargs): + yield None + + async def round_call(client, capture, **kwargs): + model = kwargs["models"]["source"] + observation = ( + O.UNKNOWN if model == "gpt-5.4" else O.OBSERVED if case == "tiers" else O.ABSENT + ) + write_live_round( + kwargs["output_dir"], + run_id=kwargs["run_id"], + matrix_id=kwargs["matrix_id"], + round_index=kwargs["round_index"], + safe_fields={ + "requested_model": model, + "session_id": kwargs["run_id"], + "status": "PASS", + "thinking_observation": observation, + }, + ) + return SimpleNamespace(thinking_observation=observation), LiveVerdict("PASS", "safe") + + namespace["observed_client"] = client + namespace["captured_chat_round"] = round_call + for model in ("gpt-5.5", "gpt-5.4"): + if case == "none": + await live.TestTierProbe().test_t10_none_direction_matches_declaration(model) + elif case == "tiers": + await live.TestTierProbe().test_t10_declared_tiers_actually_reason(model) + else: + await live.TestCapabilityDrift().test_declared_capability_matches_reality(model) + matrix = {"none": "T10-none", "tiers": "T10-tiers", "L8": "L8"}[case] + finals = list(tmp_path.rglob(f"{matrix}-0-*.md")) + assert len(finals) == 2 + seen = set() + for path in finals: + row = json.loads(path.read_text().split("```json\n")[1].split("\n```")[0]) + assert "requested_model" in row, "结论缺型号,无法关联逐轮原件" + model = row["requested_model"] + seen.add(model) + assert row["session_id"] == path.parent.name + assert row["status"] == ("PASS" if model == "gpt-5.5" else "UNCOVERED") + assert row["proposition"] + subruns = row["subruns"] + expected_groups = ( + 2 + if case == "none" + else len( + [ + effort + for effort in live.DEFAULT_CAPABILITIES[model].supported_efforts + if effort is not live.Effort.NONE + ] + ) + if case == "tiers" + else 1 + ) + assert len(subruns) == expected_groups + assert row["planned_rounds"] == sum(subrun["planned_rounds"] for subrun in subruns) + assert row["completed_rounds"] == row["planned_rounds"] + for subrun in subruns: + originals = [ + p + for p in path.parent.glob(f"{subrun['matrix_id']}-*.md") + if p.name[len(subrun["matrix_id"]) + 1 :].split("-", 1)[0].isdigit() + and not p.name.startswith(subrun["matrix_id"] + "-0-") + ] + assert len(originals) == subrun["planned_rounds"] == subrun["completed_rounds"] + for original in originals: + data = json.loads(original.read_text().split("```json\n")[1].split("\n```")[0]) + assert data["requested_model"] == model + assert data["session_id"] == row["session_id"] + assert seen == {"gpt-5.5", "gpt-5.4"} + + +@pytest.mark.parametrize("machine_type", ["model_not_found", _SECRET, "arbitrary-upstream-text"]) +def test_safe_machine_type_retains_only_known_enum(tmp_path, machine_type): + """认可机器枚举可复核;任意机器正文与 sentinel 不得落盘。""" + _, attempts = _failure(body=json.dumps({"error": {"type": machine_type}}).encode()) + fields = {"attempts": safe_attempts(attempts)} + event = fields["attempts"][0]["http"][0] + assert event.get("machine_type") == ( + "model_not_found" if machine_type == "model_not_found" else "omitted" + ) + path = write_live_round( + tmp_path, run_id="safe", matrix_id="machine", round_index=1, safe_fields=fields + ) + text = path.read_text() + assert _SECRET not in text and "arbitrary-upstream-text" not in text + if machine_type != "model_not_found": + event["machine_type"] = machine_type + with pytest.raises(ValueError, match="机器"): + write_live_round( + tmp_path, run_id="unsafe", matrix_id="machine", round_index=1, safe_fields=fields + ) + + +async def test_structured_first_attempt_requires_exact_initial_messages(): + """结构化窄规则不能允许首轮凭空带入一对反馈。""" + capture = _capture(messages_prefix_length=1, structured_max_retries=1) + real = _real_transport(capture, lambda request: httpx.Response(200, json=_response())) + try: + with capture.round_context(session_id="first", parent_call_id="parent"): + await ObservedTransport(real, capture).complete( + messages=[ + *_MESSAGES, + {"role": "assistant", "content": "old"}, + {"role": "user", "content": "retry"}, + ], + source=_source(), + stream=False, + overlay={}, + call_id="first", + reasoning_effort=None, + ) + event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0] + assert not request_is_valid(event) + finally: + await real.aclose() From 7f6a824e79478416c9e31b9fd7173b347707f1a6 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 05:06:39 -0400 Subject: [PATCH 15/17] test: complete embedding probe report identity and round counts --- ...09-09-134-thinking-contracts-validation.md | 27 ++++ tests/e2e/test_embed_probe.py | 4 + tests/unit/test_live_evidence.py | 129 ++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md index 5f9b1a5..79c97b4 100644 --- a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md +++ b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md @@ -142,3 +142,30 @@ date: 2026-09-09 上表节点命令也均加 `conda run --no-capture-output -n PolyGateway`;未使用缺 import 的伪红。最终受影响文件129单测通过,相比111新增18节点。pi-lens仍提示非conda缺httpx/pytest/pydantic及StrEnum等旧噪音,按已批准方向记录后继续conda门;新增测试命名空间显式 Any 类型,不抑制真实错误。 **尚未完成**:修复后独立复审、集成/make test覆盖率、真实能力取证、下游迁移和发布;T8/T9保持未勾选。M2空wire、M3非流UNKNOWN、真实机器拒绝白名单及外部服务状态不因离线绿变成已覆盖。 + +## 重启恢复与 embedding 报告补漏(起点 3eb22d2) + +恢复时实际分支为 `feature/1.3.4-thinking-contracts`,HEAD=`3eb22d2`,已跟踪工作区无差异,仅既有 `.pi/` 未跟踪;前轮代码提交仍在,重启未丢代码。按用户限定只修测试与本 finding,不动生产 API/版本/计划,不联网、不付费、不运行 slow、不派子代理。本节为实现者验证,不冒充独立复审或版本验收。 + +原 `tests/outputs/134/slow-gate.log` 保留,大小 1625 字节;`slow-gate.exit` 不存在,属于重启中断、未取得终态,不能宣称 slow 通过。修复前后 SHA-256 均为 `05967dcf749b13db815dd80449a0db5b3e7ad2144aa2ae216f8c9ab5ecb21bf5`。历史日志不覆盖、不续写,也不把旧 full-gate 作为本轮测试证据。 + +| 根因/范围 | 本轮修复与证据 | +| --- | --- | +| embedding 消费者遗漏四个字段 | 仅在原 finally 报告出口增加 `requested_model=source.model`、`provider=source.provider`、`planned_rounds=1`、`completed_rounds=1`;完成计数指已收尾轮次,FAIL/UNCOVERED 也计入,不代表成功。沿用原 matrix/round/session/parent/attempt 关联,不新建报告框架 | +| 环境可覆盖请求型号 | 既有 `test_live_evidence.py` 增加真实 probe 消费者回归:AST 仅剔除 `.env`/pytestmark 顶层读取,合成环境实际经过 GatewaySettings;两种 probe 型号覆盖都与原 chat 型号不同,HTTP 与报告必须等于本次 source,不能拿默认型号占位 | +| 成功与所有目标错误路径 | 真实 OpenAICompatTransport+LiveCapture+ObservedTransport+报告写入;仅 HTTP 边界 MockTransport。两型号×成功/503/严格404/ConnectError 共8节点,分别 PASS/FAIL/UNCOVERED/FAIL;同时断言唯一报告、逻辑 UUID/attempt 配对、请求校验、状态/错误类型与客户端关闭 | +| 安全边界 | 假凭据及私有提示词 sentinel 放入成功 model 回显、错误正文和请求异常;逐份 Markdown 断言不泄漏,仍只写安全机器枚举/固定原因,不复制原始正文与异常 | + +日志均在 `tests/outputs/134/`,命令不接管道,先保存真实退出码到同名 `.exit` 再展示输出。 + +| 命令(pytest 前缀均为 `conda run --no-capture-output -n PolyGateway`) | 实际结果/日志 | +| --- | --- | +| `pytest tests/unit/test_live_evidence.py -k embed_probe_report -q`(修复前) | `embed-report-red.log/.exit`:8 failed,129 deselected,exit 1;八例均在真实报告消费处 `KeyError: requested_model`,不是 import/mock 签名失败 | +| 同命令(四字段补齐后) | `embed-report-green.log/.exit`:8 passed,129 deselected,exit 0 | +| `pytest tests/unit/test_live_evidence.py tests/unit/test_embedding.py tests/unit/test_openai_compat.py::TestDefaultClientFactory -q` | `embed-report-affected.log/.exit`:188 passed,exit 0 | +| `pytest tests/unit/ -q` | `embed-report-unit.log/.exit`:1383 passed,exit 0;格式化后 `embed-report-final-unit.log/.exit`:1383 passed,4.45秒,exit 0 | +| `make check` | 首次 `embed-report-check.log/.exit` 为新断言排版失败(exit 2),不是行为红;仅对该测试文件运行 conda ruff format。`embed-report-check-green.log/.exit`:94文件格式合格、ruff通过、import-linter 1 kept/0 broken,exit 0 | + +pi-lens 仍报非 conda 解释器缺 httpx/pytest/dotenv/pydantic 及旧 StrEnum 噪音;按任务授权记录,不添加 ignore、不改枚举、不扩环境修复范围。实际 conda 解释器为 `/home/iomgaa/miniconda3/envs/PolyGateway/bin/python`,本会话导入四依赖成功(httpx 0.28.1、pytest 9.1.1、python-dotenv 1.2.3、pydantic 2.13.4)。conda 启动器自身另有 base Python 3.13 的 RequestsDependencyWarning;未静音,不宣称输出零告警,测试进程与静态门实际退出0。 + +本修复不补写真正缺失的历史报告、不改变 embedding 能力判据;真实服务、slow、下游与发布证据仍由后续验收负责。 diff --git a/tests/e2e/test_embed_probe.py b/tests/e2e/test_embed_probe.py index 7f8662b..44bef9b 100644 --- a/tests/e2e/test_embed_probe.py +++ b/tests/e2e/test_embed_probe.py @@ -76,6 +76,10 @@ async def test_probe_real_gateway_embeddings(): matrix_id="embedding", round_index=1, safe_fields={ + "requested_model": source.model, + "provider": source.provider, + "planned_rounds": 1, + "completed_rounds": 1, "status": verdict.status, "reason": verdict.reason, "session_id": run_id, diff --git a/tests/unit/test_live_evidence.py b/tests/unit/test_live_evidence.py index 3b9c28c..edb13f5 100644 --- a/tests/unit/test_live_evidence.py +++ b/tests/unit/test_live_evidence.py @@ -684,6 +684,135 @@ async def test_round_consumer_keeps_first_success_when_second_assertion_fails(tm assert clients and all(client.is_closed for client in clients) +@pytest.mark.parametrize("model", ["embedding-override-a", "embedding-override-b"]) +@pytest.mark.parametrize( + ("outcome", "status", "error_type"), + [ + ("success", "PASS", None), + ("503", "FAIL", "TransientError"), + ("404", "UNCOVERED", "RequestRejectedError"), + ("request_error", "FAIL", "TransientError"), + ], +) +async def test_embed_probe_report_keeps_actual_source_and_round_identity( + tmp_path, monkeypatch, model, outcome, status, error_type +): + """真实探测消费者在成功与失败均留完整关联;只替换外部 HTTP。""" + import ast + from pathlib import Path + + from tests.unit.test_config import _BASE_ENV + + path = Path(__file__).parents[1] / "e2e/test_embed_probe.py" + tree = ast.parse(path.read_text()) + tree.body = [ + node + for node in tree.body + if not ( + isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id in {"_ENV", "pytestmark"} + for target in node.targets + ) + ) + ] + env = { + **_BASE_ENV, + "LLM__MINIMAX__1__BASE_URL": "https://example.test/v1", + "LLM__MINIMAX__1__API_KEY": _SECRET, + "LLM__MINIMAX__1__MODEL": "configured-chat-model", + "LLM__MINIMAX__1__TIMEOUT_S": "137", + "LLM__MINIMAX__1__TRUST_ENV": "false", + "PGW_EMBED_PROBE_MODEL": model, + } + namespace: dict[str, Any] = {"_ENV": env} + exec(compile(tree, str(path), "exec"), namespace) + monkeypatch.chdir(tmp_path) + original_factory = LiveCapture.client_factory + clients = [] + calls = [] + + def factory(capture, source): + """保留真实取证 hooks、源与逻辑 ID,只隔离网络出口。""" + client = original_factory(capture, source) + + def handler(request): + """提供完整成功/错误样本,敏感回显不得进入报告。""" + payload = json.loads(request.content) + assert payload["model"] == model == source.model + assert source.model != env["LLM__MINIMAX__1__MODEL"] + assert request.headers["Authorization"] == f"Bearer {_SECRET}" + calls.append((source, capture._round.get(), capture._attempt.get().call_id)) + if outcome == "request_error": + raise httpx.ConnectError(_SECRET + _PROMPT, request=request) + if outcome == "success": + return httpx.Response( + 200, + json={ + "data": [{"index": 0, "embedding": [0.1, 0.2]}], + "usage": {"prompt_tokens": 1}, + "model": _SECRET + _PROMPT, + }, + ) + return httpx.Response( + int(outcome), + json={"error": {"type": "model_not_found", "message": _SECRET + _PROMPT}}, + ) + + client._transport = httpx.MockTransport(handler) + clients.append(client) + return client + + monkeypatch.setattr(LiveCapture, "client_factory", factory) + probe = namespace["test_probe_real_gateway_embeddings"] + if status == "PASS": + await probe() + elif status == "UNCOVERED": + with pytest.raises(pytest.skip.Exception): + await probe() + else: + with pytest.raises(AssertionError): + await probe() + + assert len(calls) == len(clients) == 1 + assert all(client.is_closed for client in clients) + source, (session_id, parent_call_id), call_id = calls[0] + paths = list((tmp_path / "tests/outputs/134/live").rglob("*.md")) + assert len(paths) == 1 + text = paths[0].read_text() + assert _SECRET not in text and _PROMPT not in text + row = json.loads(text.split("```json\n")[1].split("\n```")[0]) + assert row["status"] == status + assert row["requested_model"] == source.model == model + assert row["provider"] == source.provider == "minimax" + assert row["planned_rounds"] == row["completed_rounds"] == 1 + assert row["session_id"] == paths[0].parent.name == session_id + assert row["parent_call_id"] == parent_call_id + assert len({session_id, parent_call_id, call_id}) == 3 + assert paths[0].name.startswith("embedding-1-") + assert text.startswith("# embedding · 轮次 1\n") + assert len(row["attempts"]) == 1 + attempt = row["attempts"][0] + assert attempt["call_id"] == call_id and attempt["error_type"] == error_type + assert len(attempt["http"]) == 1 + event = attempt["http"][0] + assert all(event["request_checks"].values()) + assert ( + event["status_code"] + == { + "success": 200, + "503": 503, + "404": 404, + "request_error": 0, + }[outcome] + ) + if outcome in {"503", "404"}: + assert event["error_body_complete"] is True + assert event["machine_type"] == "model_not_found" + elif outcome == "request_error": + assert "无可配对响应" in row["evidence_notes"] + + def test_partial_uncovered_and_failed_rounds_never_become_model_pass(): from tests.live_evidence import combine_live_verdicts, qualify_live_rounds From b7e69434974742833cdc70fc242c7bc0bf3009a2 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 05:14:37 -0400 Subject: [PATCH 16/17] test: keep cancelled embedding probe rounds incomplete --- ...09-09-134-thinking-contracts-validation.md | 15 ++++++++++ tests/e2e/test_embed_probe.py | 5 +++- tests/unit/test_live_evidence.py | 29 ++++++++++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md index 79c97b4..1cd30b2 100644 --- a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md +++ b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md @@ -169,3 +169,18 @@ date: 2026-09-09 pi-lens 仍报非 conda 解释器缺 httpx/pytest/dotenv/pydantic 及旧 StrEnum 噪音;按任务授权记录,不添加 ignore、不改枚举、不扩环境修复范围。实际 conda 解释器为 `/home/iomgaa/miniconda3/envs/PolyGateway/bin/python`,本会话导入四依赖成功(httpx 0.28.1、pytest 9.1.1、python-dotenv 1.2.3、pydantic 2.13.4)。conda 启动器自身另有 base Python 3.13 的 RequestsDependencyWarning;未静音,不宣称输出零告警,测试进程与静态门实际退出0。 本修复不补写真正缺失的历史报告、不改变 embedding 能力判据;真实服务、slow、下游与发布证据仍由后续验收负责。 + +## 独立审查补正:取消不计完成轮(起点 7f6a824) + +独立 verifier 指出:probe 的 finally 无条件写 `completed_rounds=1`,但 CancelledError 穿透时仍是 `FAIL/轮次未完成`,分母记录自相矛盾。已对照源码并在真实消费者复现,接受该问题;上节“已收尾即完成”的措辞不适用于取消,本节修正为**取得正常成功或普通异常分类终态才算完成**,不是 finally 执行过就完成。 + +最小修复仅在 probe 初始化 `completed_rounds=0`,成功判定或普通异常分类返回后置1;finally 写实际计数。取消仍穿透、计数保留0,不新增捕获 BaseException、不动生产 API/版本/分类器。扩展原消费者参数化测试增加两型号取消节点:真实 task 在 MockTransport 进入等待后由调用方 cancel,断言 CancelledError 穿透、task.cancelled、报告 FAIL/未完成、planned=1/completed=0、原调用关联及客户端关闭;其他8例保持完成1。所有节点只替换外部 HTTP,不联网、不付费、不跑 slow。 + +| 命令(pytest 前缀为 `conda run --no-capture-output -n PolyGateway`) | 本轮实际证据(tests/outputs/134/,各有 .log/.exit) | +| --- | --- | +| `pytest tests/unit/test_live_evidence.py -k 'embed_probe_report and cancelled' -q`,修复前 | `embed-cancel-red`:2 failed/137 deselected,exit1;两例均先验证取消穿透、报告存在及资源关闭,再因 `completed_rounds` 实际1而期望0失败 | +| `pytest tests/unit/test_live_evidence.py -k embed_probe_report -q`,修复后 | `embed-cancel-green`:10 passed/129 deselected,exit0;覆盖原8例与新增2例 | +| `pytest tests/unit/ -q` | `embed-cancel-unit`:1385 passed,4.08秒,exit0 | +| `make check` | `embed-cancel-check`:格式/ruff通过,import-linter 1 kept/0 broken,exit0 | + +既有非 conda LSP 误报继续只记录(本次额外将 `asyncio.timeout` 误判为缺属性);conda pytest 实际可执行,base RequestsDependencyWarning 未静音。此处是针对独立审查问题的实现及自验,修复后独立复核仍交父会话;不冒称审查门或版本验收已通过。原 slow 日志不改写。 diff --git a/tests/e2e/test_embed_probe.py b/tests/e2e/test_embed_probe.py index 44bef9b..039851f 100644 --- a/tests/e2e/test_embed_probe.py +++ b/tests/e2e/test_embed_probe.py @@ -53,6 +53,7 @@ async def test_probe_real_gateway_embeddings(): transport = ObservedTransport(real, capture) run_id, parent, call_id = uuid4().hex, uuid4().hex, uuid4().hex verdict = LiveVerdict("FAIL", "轮次未完成") + completed_rounds = 0 try: with capture.round_context(session_id=run_id, parent_call_id=parent): try: @@ -65,10 +66,12 @@ async def test_probe_real_gateway_embeddings(): assert len(events) == 1 and request_is_valid(events[0]) assert result.dim > 0 and len(result.vectors) == 1 verdict = LiveVerdict("PASS", "向量形状与实发请求合格") + completed_rounds = 1 except Exception as error: verdict = classify_live_failure( error, capture.attempts(session_id=run_id, parent_call_id=parent) ) + completed_rounds = 1 finally: write_live_round( Path("tests/outputs/134/live"), @@ -79,7 +82,7 @@ async def test_probe_real_gateway_embeddings(): "requested_model": source.model, "provider": source.provider, "planned_rounds": 1, - "completed_rounds": 1, + "completed_rounds": completed_rounds, "status": verdict.status, "reason": verdict.reason, "session_id": run_id, diff --git a/tests/unit/test_live_evidence.py b/tests/unit/test_live_evidence.py index edb13f5..1c75e2d 100644 --- a/tests/unit/test_live_evidence.py +++ b/tests/unit/test_live_evidence.py @@ -692,6 +692,7 @@ async def test_round_consumer_keeps_first_success_when_second_assertion_fails(tm ("503", "FAIL", "TransientError"), ("404", "UNCOVERED", "RequestRejectedError"), ("request_error", "FAIL", "TransientError"), + ("cancelled", "FAIL", None), ], ) async def test_embed_probe_report_keeps_actual_source_and_round_identity( @@ -731,18 +732,22 @@ async def test_embed_probe_report_keeps_actual_source_and_round_identity( original_factory = LiveCapture.client_factory clients = [] calls = [] + reached = asyncio.Event() def factory(capture, source): """保留真实取证 hooks、源与逻辑 ID,只隔离网络出口。""" client = original_factory(capture, source) - def handler(request): + async def handler(request): """提供完整成功/错误样本,敏感回显不得进入报告。""" payload = json.loads(request.content) assert payload["model"] == model == source.model assert source.model != env["LLM__MINIMAX__1__MODEL"] assert request.headers["Authorization"] == f"Bearer {_SECRET}" calls.append((source, capture._round.get(), capture._attempt.get().call_id)) + if outcome == "cancelled": + reached.set() + await asyncio.Future() if outcome == "request_error": raise httpx.ConnectError(_SECRET + _PROMPT, request=request) if outcome == "success": @@ -765,7 +770,19 @@ async def test_embed_probe_report_keeps_actual_source_and_round_identity( monkeypatch.setattr(LiveCapture, "client_factory", factory) probe = namespace["test_probe_real_gateway_embeddings"] - if status == "PASS": + if outcome == "cancelled": + task = asyncio.create_task(probe()) + try: + async with asyncio.timeout(5): + await reached.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert task.cancelled() + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + elif status == "PASS": await probe() elif status == "UNCOVERED": with pytest.raises(pytest.skip.Exception): @@ -785,7 +802,10 @@ async def test_embed_probe_report_keeps_actual_source_and_round_identity( assert row["status"] == status assert row["requested_model"] == source.model == model assert row["provider"] == source.provider == "minimax" - assert row["planned_rounds"] == row["completed_rounds"] == 1 + assert row["planned_rounds"] == 1 + assert row["completed_rounds"] == (0 if outcome == "cancelled" else 1) + if outcome == "cancelled": + assert row["reason"] == "轮次未完成" assert row["session_id"] == paths[0].parent.name == session_id assert row["parent_call_id"] == parent_call_id assert len({session_id, parent_call_id, call_id}) == 3 @@ -804,12 +824,13 @@ async def test_embed_probe_report_keeps_actual_source_and_round_identity( "503": 503, "404": 404, "request_error": 0, + "cancelled": 0, }[outcome] ) if outcome in {"503", "404"}: assert event["error_body_complete"] is True assert event["machine_type"] == "model_not_found" - elif outcome == "request_error": + elif outcome in {"request_error", "cancelled"}: assert "无可配对响应" in row["evidence_notes"] From dae12f9a16f80432f864d0d063af2699f1655e2f Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 9 Sep 2026 06:52:27 -0400 Subject: [PATCH 17/17] chore: prepare release 1.3.4 --- CHANGELOG.md | 9 ++- README.md | 13 +++-- pyproject.toml | 2 +- ...09-09-134-thinking-contracts-validation.md | 55 ++++++++++++++++++- src/polygateway/__init__.py | 2 +- 5 files changed, 70 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aef3d9..7d88ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,18 @@ # Changelog -## 未发布(1.3.4) +## 1.3.4(2026-09-09) -- **推理意图**:已登记 AUTO 必须为能力清单成员,True 糖同约束;nearest 不代选强度。MiniMax on_base 改空,M3 要显式选 medium 等登记档;M2.5/M2.7 空 wire 真实复验待完成。未知仍尽力+warning,不保证开启。 +> [!WARNING] +> **patch 版号不代表无迁移成本。** 已登记但不含 AUTO 的 True/auto 配置及受管+raw 双来源现在明确拒绝;受影响缓存必须在首次新语义读写前显式换 namespace/salt。升级步骤见 [README 迁移节](README.md#134-推理配置迁移)。 + +- **推理意图**:已登记 AUTO 必须为能力清单成员,True 糖同约束;nearest 不代选强度。MiniMax on_base 改空,M3 要显式选 medium 等登记档;M2.5/M2.7 空 wire 流式 AUTO 各5轮定向复验通过,不代表全矩阵覆盖。未知仍尽力+warning,不保证开启。 - **所有权**:受管意图下两层 raw 推理控制同值/被遮蔽也拒绝;raw-only 与普通采样浅覆盖保留。自定义 on_base 禁止偷带强度。 - **缓存迁移前置**:受影响调用更换从未承载旧语义的 namespace/salt;同版本 fallback、能力表、wire 变化亦需迁移。不加自动指纹,未迁移仍可回放旧语义。M1–M9、per-call/多源/回滚示例见 README。 - **测试证据**:默认 FAIL,不整类 skip;404 仅完整独立证据可未覆盖,公共身份缺失无独立证据 FAIL。逐轮脱敏,UNKNOWN/SKIP/缺轮不算关闭覆盖;不可关闭与预期拒绝独立判定。缺型号级 400 机器字段基线仍 FAIL,不编造白名单。 - **遥测守卫**:真实客户端/临时 SQLite 的 embedding、OCR 双入口成败 NULL、chat 阳性与四种行来源回归;不新增 schema、生产端口或成功 SSE 捕获器。 +**验收例外**:2026-09-09 用户正式批准不再补全模型矩阵,失败/UNKNOWN/不可达/缺轮及缺下游现行配置证据作为本版例外保留,不冒称 PASS。embedding 实测 503 不符合严格404未覆盖条件,仍为 FAIL;claude-opus-5 开启档位命题仍 FAIL,不能把 HTTP 200 当推理开启证明。独立审查、红绿/变异、日常与定向实测均按适用范围复用;具体失败、网络诊断及原始证据见[验证记录](research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md)。该例外不取消下游缓存迁移前置,也不代表合并后检查、上传或外部包验收已执行。 + ## 1.3.3(2026-09-05) 推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。 diff --git a/README.md b/README.md index 30e7060..c007326 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,10 @@ **降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。 -## 1.3.4 推理配置迁移(未发布) +## 1.3.4 推理配置迁移 + +> [!WARNING] +> **1.3.4 虽为 patch,升级仍会拒绝部分旧配置。** 已登记但不含 AUTO 的模型不再接受 `ENABLE_THINKING=true`/`REASONING_EFFORT=auto`;受管推理与 raw 控制并存(即使同值)也会拒绝。请先按下表选择显式档或 raw-only,并在受影响调用首次使用新语义前更换缓存 namespace/salt;**只升级包不会自动隔离旧缓存**。 **先明确意图,再在首次新语义缓存读写前切换缓存身份。** `auto` 要求开启但不指定强度,不是 `None`(不表态),也不是库代选付费档位。已登记模型只有清单含 AUTO 才接受 True/auto;nearest 不把 AUTO 映射成强度。未知模型仍尽力+warning,空开启片段可能零推理字节,不保证开启。完整型号证据见[批准设计 §4/5](research-wiki/designs/2026-09-09-134-thinking-contracts-design.md)。 @@ -40,7 +43,7 @@ | M2 | deepseek-v4-pro/flash/flash-vision-exp、glm-5.2 True/auto | 删除糖,选 high 或 max;非空开关也不能豁免 AUTO 成员检查 | | M3 | glm-5.3/5.3-flash、kimi-k3/kimi-for-coding True/auto | 删除糖,选 low/high/max;nearest 不能修复 AUTO | | M4 | gpt-5.4/5.5、claude-opus-5/sonnet-5、gemini-3.1-pro True/auto | 删除糖,可选表内 medium;不可达不能补 AUTO,也不等于 live 证明 | -| M5 | MiniMax-M2.5/M2.7 True/auto | 仍接受,但 on_base 不再偷带 medium,改为空片段;缓存须迁移,空 wire 真实语义待复验 | +| M5 | MiniMax-M2.5/M2.7 True/auto | 仍接受,但 on_base 不再偷带 medium,改为空片段;缓存须迁移。2026-09-09 两型各5轮流式 AUTO 复验通过,不外推到其他模式/渠道 | | M6 | qwen 五型、glm-5/5.1/4.6v True/auto | 保留;glm-5/5.1 历史身份不足仍未覆盖,不推及其他型号 | | M7 | 未登记模型 True/auto | 可保留尽力;确定保证须先独立取证再登记能力 | | M8 | 受管意图+任一层 raw 推理控制,即使同值/被遮蔽 | 保留受管档并删除源 extra_body、请求 overlay 的控制键;或清空源糖/档和请求意图,仅 raw(applied_effort=NULL) | @@ -64,7 +67,9 @@ M8 包括 reasoning_effort、enable_thinking、thinking、thinking_budget、reas 真实成功尝试记 response.applied_effort;失败尝试记 effective 请求意图(可能零 HTTP);缓存命中和 scope 终态只记**本次请求级**档,不借历史 applied 或源级补值。embedding、OCR text/layout 成败行均 NULL。实际档分析须排除缓存命中与错误行,未知 AUTO 不证明上游能力。 -测试侧默认 FAIL:404 只有请求、唯一尝试、完整无重复键 JSON、error.type=model_not_found 等独立证据全满足才 UNCOVERED;429/5xx/网络/解析错误不整类 skip。成功公共身份缺失无独立证据仍 FAIL;成功 SSE 不新增捕获器。关闭须完整合格轮次全 ABSENT,UNKNOWN 不能靠长度升格成功。不可关闭探测的 OBSERVED 仅支持本条件下未关闭;预期拒绝另按预声明类型、状态、机器字段判定。必需 live 的 SKIP/UNKNOWN/缺轮不因 pytest exit 0 通过发布门。三项目现行配置迁移仍需各负责人取证,合成兼容测试不能代替。 +测试侧默认 FAIL:404 只有请求、唯一尝试、完整无重复键 JSON、error.type=model_not_found 等独立证据全满足才 UNCOVERED;429/5xx/网络/解析错误不整类 skip。成功公共身份缺失无独立证据仍 FAIL;成功 SSE 不新增捕获器。关闭须完整合格轮次全 ABSENT,UNKNOWN 不能靠长度升格成功。不可关闭探测的 OBSERVED 仅支持本条件下未关闭;预期拒绝另按预声明类型、状态、机器字段判定。必需 live 的 SKIP/UNKNOWN/缺轮不因 pytest exit 0 通过发布门。 + +**本版验收例外(2026-09-09 用户正式批准)**:不再补全模型矩阵;既有失败、UNKNOWN、不可达、缺轮及下游现行配置缺证据如实保留,不改成 PASS。M2 两型的定向成功不代表全模型通过;三项目实际配置迁移仍未核验,合成兼容测试不能代替,缓存迁移操作前置也未被豁免。逐项实测、网络诊断与证据索引见[1.3.4 验证记录](research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md)。 ## 安装 @@ -72,7 +77,7 @@ M8 包括 reasoning_effort、enable_thinking、thinking、thinking_budget、reas ```bash pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ - "polygateway[redis,postgres,structured]>=1.3.0,<2" + "polygateway[redis,postgres,structured]>=1.3.4,<2" ``` 核心仅依赖 `httpx` + `pydantic`;按需选 extras: diff --git a/pyproject.toml b/pyproject.toml index 5f0c1cf..b36e9f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polygateway" -version = "1.3.3" +version = "1.3.4" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 diff --git a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md index 1cd30b2..e8ddaae 100644 --- a/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md +++ b/research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md @@ -1,13 +1,13 @@ --- type: finding node_id: finding:2026-09-09-134-thinking-contracts-validation -title: "1.3.4 T0–T4 与 T7 确定性验证" +title: "1.3.4 推理契约验证与发布准备" date: 2026-09-09 --- -# 1.3.4 T0–T4/T7 实施验证 +# 1.3.4 推理契约验证与发布准备 -> 状态:本轮限定的生产契约与确定性测试已实现;不是整个版本验收。原T0–T4/T7记录保留;T5/T6和T8文档续作见文末,独立验证/live/发布未执行。所有原始输出在 `tests/outputs/134/`,不提交。 +> 最新状态(2026-09-09):发布准备完成;用户已正式批准将未补全模型矩阵、失败/UNKNOWN/不可达/缺轮及缺下游现行配置证据作为本版验收例外,保留原始结论而非 PASS。可移交合并与包发布;本轮未 merge/push/tag/构建/上传,合并后门与外部产物验收仍待执行。以下各节是分阶段历史,不追改当时结论;最新证据与例外见文末。原始输出在 `tests/outputs/134/`,不提交。 ## 基线与修改边界 @@ -184,3 +184,52 @@ pi-lens 仍报非 conda 解释器缺 httpx/pytest/dotenv/pydantic 及旧 S | `make check` | `embed-cancel-check`:格式/ruff通过,import-linter 1 kept/0 broken,exit0 | 既有非 conda LSP 误报继续只记录(本次额外将 `asyncio.timeout` 误判为缺属性);conda pytest 实际可执行,base RequestsDependencyWarning 未静音。此处是针对独立审查问题的实现及自验,修复后独立复核仍交父会话;不冒称审查门或版本验收已通过。原 slow 日志不改写。 + +## 1.3.4 发布准备与用户验收例外(2026-09-09,起点 b7e6943) + +**授权与边界**:用户正式批准不再补全模型矩阵,保留失败/UNKNOWN/不可达、未完成轮次及缺下游现行配置证据为本版验收例外,继续 1.3.4 发布准备。例外不是测试通过,不调整分类器/覆盖分母/能力表,不把渠道问题自动归因成库外错误,也不免除受影响下游首次新语义读写前的缓存迁移。原设计/计划的“需取证或人类明确豁免”分支由本次授权满足;不勾选完整 T8/T9 或任何尚未执行的发布门。 + +本轮仅修改 README、CHANGELOG、pyproject、包版本和本 finding。没有新增测试或生产行为变更;原红绿与变异按前述节点复用,不为了版本 bump 人为造红,不启动子代理,不重跑付费模型矩阵。`.env.example` 与 ARCH 的行为同步已在既有提交完成,Wiki 仍下线。 + +### 已核对并复用的证据 + +下表路径未写前缀时均相对 `tests/outputs/134/`;这些是已有原件,本轮只核对,不冒称本轮新跑。 + +| 门/范围 | 原始证据与适用结论 | +| --- | --- | +| 生产独立审查 | run `b8552a94-dc93-4834-b9ff-c6b457c315ae` 的 `verify134/production.md`:目标生产 diff 无 Critical/Important/Minor;后续仅测试补漏及本轮文档/版本,不重演同一生产审查 | +| 四项取证修复复审 | run `3bdee9d3-4678-4ddb-83d9-544156eb00cc` 的 `verify134/executable-retry.md`:HEAD 3eb22d2 四项真实消费者复审无问题,1375 单元/18 定向节点通过,90 仅采集 | +| 取消计数独立复核 | run `c317eac4-e214-46fc-86a5-08a0d2187d3b` 的 `recovery/cancel-recheck.md`:b7e6943 限定复核无阻塞,139 取证单测与 make check 通过;取消 completed=0、普通终态=1、穿透与资源关闭 | +| 红绿/变异 | 前文对应的11个契约变异、8个假绿变异、审查四项红绿及 embedding 报告/取消红绿均保留;不外推成新 live 证明 | +| 日常全量 | `full-gate.log/.exit`:1508 passed、23 skipped、108 deselected、95%覆盖率、exit0;`full-gate-monitor-note.md` 说明外层监控包装失败不等于 pytest 失败。该历史全量早于 embedding 报告补漏;其后测试改动由1385单元及独立复核补证,不声称是新 HEAD 的完整全量 | +| M2 空 wire AUTO | `recovery-20260909/m2-auto.log/.exit`:2 passed、80 deselected、exit0;M2.5 run `946ac7bf89d742aba8717e722c1e2f60`、M2.7 run `5587e6d9de1e4c7bb6a352afedb7e732` 各5/5轮流式、并发1,最终 wire 无偷带 medium、请求/身份资格及开启命题通过。只消除这两个单元的缺测,不外推其他模式/渠道 | +| Redis 时间语义 | `recovery-20260909/non-llm-slow.log/.exit`:contracts/integration slow **18 passed、156 deselected、exit0**(1142.05秒),不等同整个 slow 套件通过 | + +上述独立报告原件位于 `/home/iomgaa/.pi/agent/sessions/--home-iomgaa-Projects-PolyGateway--/subagent-artifacts/outputs//`;本轮另原样复制到 `release/reused-reviews/`,不覆盖旧报告、不提交运行产物。 + +### 最新实测、网络诊断与明确未覆盖 + +| 项目 | 已取得的事实/本版结论 | +| --- | --- | +| 旧完整 slow 中断 | `slow-gate.log` 无对应 `.exit`,保留原 SHA-256 `05967dcf749b13db815dd80449a0db5b3e7ad2144aa2ae216f8c9ab5ecb21bf5`;不把日志中的局部成功当整套通过 | +| embedding 定向实测 | `recovery-followup-20260909/embedding.log/.exit`:1 failed、exit1。独立三请求诊断 `channel-diagnosis-20260909.jsonl` 同一源 `minimax_1`、请求 `text-embedding-v1` 得503,`error.type=new_api_error`、`error.code=model_not_found`、无可用渠道语义命中;**不是**404且 type 不匹配,仍 FAIL,不改成严格404未覆盖或“所有网关不支持 embeddings” | +| M3 与 claude 开启档位 | `recovery-followup-20260909/remaining-tiers.log/.exit`:1 passed、1 failed、60 deselected、exit1(首错停止)。M3 run `8c852937e7144fa4b1641744785c9b2b` 六个显式档各5轮、共30/30完成,流式开启命题 PASS;claude-opus-5 run `d0303b5033f4449d82838690f1671470` 五档各5轮、共25/25完成,但至少一个开启命题 FAIL。逐轮请求成功不等于型号能力 PASS,也不能据M3流式覆盖消除非流式 UNKNOWN | +| claude 独立网络诊断 | `channel-diagnosis-20260909.jsonl` 中 high+简单题/复杂题均 HTTP200、SSE有DONE、回报身份一致;usage推理token=0,reasoning_content原长1但去空白长0(只有空白)。可证明该次传输完成却缺非空推理信号,不能证明 high 已开启、不能把空白提升 OBSERVED,也不据此断言所有渠道/档位均不能推理。三请求诊断完成不等于三项能力通过 | +| 剩余矩阵停止 | `remaining-20260909/matrix.log/.exit`:选中40节点,在首个 gemini-3-flash NONE 节点约1080秒后 KeyboardInterrupt,exit1,无测试终态通过汇总。日志不能独立证明停止原因或网络根因;保持未完成,不算40失败或40通过,不继续补跑 | +| 其余证据缺口 | 历史 UNKNOWN/身份不足、未执行的型号/模式/关闭单元、型号级400机器字段基线、下游现行配置缺证据均按原记录保留。GovDoc/CHS 现行配置未取证,Video-Tree退出迁移后的历史兼容测试也非现行配置验收;不宣称三项目完成本版迁移 | + +所有已有逐轮报告仍保留在 `live//`,汇总文件不能覆盖失败原件。本轮盘点共有453份报告:PASS标签255、FAIL标签108、UNCOVERED标签33、无status的轮数汇总57;**混有逐轮、命题、pytest及历史报告,不能相加成独立模型/测试通过率**。716个历史证据文件的 SHA-256 清单保存在 `release/prior-evidence-sha256.json`,最终复核字节不变。盘点首跑因轮数汇总无status产生 KeyError,保存 `release/evidence-audit.log/.exit`(exit1);修正盘点脚本区分汇总后 `release/evidence-audit-final.log/.exit` 为exit0,未修改原报告或生产代码。 + +### 本轮发布准备亲跑结果与交接门 + +| 检查/命令 | 实际结果/证据 | +| --- | --- | +| 先查远端占用 | 改文件前 `git ls-remote --tags origin refs/tags/v1.3.4 refs/tags/v1.3.4^{}`:exit0且空;匿名 GET 包 simple/polygateway 索引HTTP200、不含1.3.4;GET releases/tags/v1.3.4 HTTP404。日志 `release/remote-*`,未读取或输出凭据;未来发布前仍需复查以免竞态 | +| 版本与 README 数字 | 两处版本均1.3.4;README安装下界改为 `>=1.3.4,<2`,新增醒目迁移警示和例外指针;CHANGELOG按实际日期2026-09-09定版。`inspect.signature` 实测 TelemetryRecorder 不含self为26参,能力表24条/含AUTO10条;`release/evidence-audit-final.log` | +| `make check` | `release/check.log/.exit`:94文件格式通过、ruff通过、import-linter 1 kept/0 broken、exit0 | +| `conda run --no-capture-output -n PolyGateway pytest tests/unit/test_package.py -q` | `release/package.log/.exit`:**6 passed,0.08秒,exit0**,两版本一致且导出面可用 | +| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | `release/unit.log/.exit`:**1385 passed,4.41秒,exit0**,未新增测试,无新付费调用 | + +conda 启动器既有 RequestsDependencyWarning 保留,不宣称零告警。本轮 conda 内实际导入 dotenv/httpx/redis.asyncio 成功;工具的非conda LSP旧诊断不转成代码修改或忽略规则。 + +**可移交合并与包发布,不等于已发布。** 父会话按本版例外边界完成本次文档/版本差异审查,再执行合并后静态/日常门及未豁免的发布检查;不把本次例外解释为必须补全模型矩阵,也不把豁免项勾成已跑通过。merge/push/tag/构建/twine上传/下载解包独立安装/Release及registry页面检查均尚未执行;只能在实际完成后记录。不得覆盖已有同版本不同字节。 diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 312e1a3..6254135 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -50,7 +50,7 @@ from polygateway.types import ( ThinkingObservation, ) -__version__ = "1.3.3" +__version__ = "1.3.4" __all__ = [ "DEFAULT_PROFILES",