20 Commits

Author SHA1 Message Date
iomgaa af57f93adc chore: merge release 1.3.4 thinking contracts 2026-09-09 06:56:21 -04:00
iomgaa dae12f9a16 chore: prepare release 1.3.4 2026-09-09 06:52:27 -04:00
iomgaa b7e6943497 test: keep cancelled embedding probe rounds incomplete 2026-09-09 05:14:37 -04:00
iomgaa 7f6a824e79 test: complete embedding probe report identity and round counts 2026-09-09 05:06:39 -04:00
iomgaa 3eb22d2a55 test: fix structured reask evidence and live coverage conclusions 2026-09-09 03:34:48 -04:00
iomgaa d332287b28 docs: document reasoning ownership and explicit cache migration 2026-09-09 02:41:25 -04:00
iomgaa 73008ad7d5 test: apply evidence-based live checks without hiding regressions 2026-09-09 02:40:13 -04:00
iomgaa 16fa0ca474 docs: record deterministic reasoning contract validation 2026-09-09 01:39:13 -04:00
iomgaa c710c3a7ec fix: explain explicit auto migration and verify probe cleanup 2026-09-09 01:37:07 -04:00
iomgaa a0a33c0c01 test: guard custom reasoning roots at the transport boundary 2026-09-09 01:35:14 -04:00
iomgaa 47488ee4fd test: guard reasoning-free telemetry through real client paths 2026-09-09 01:34:36 -04:00
iomgaa d0078c1be5 test: pin explicit cache migration and reasoning row semantics 2026-09-09 01:34:33 -04:00
iomgaa 71f1bdf26b test: isolate factory checks from developer proxy settings 2026-09-09 01:27:25 -04:00
iomgaa 8e61a66342 fix: reject conflicting raw reasoning overrides before sending 2026-09-09 01:26:34 -04:00
iomgaa 1ee74c35a8 fix: validate ownership of managed reasoning parameters 2026-09-09 01:24:54 -04:00
iomgaa 4ed144c9e4 fix: enforce registered auto reasoning capabilities 2026-09-09 01:22:18 -04:00
iomgaa dda55567ae docs: register thinking contracts and record baseline checks 2026-09-09 00:48:57 -04:00
iomgaa 2553fc7f34 docs: record approved thinking contracts and implementation plan 2026-09-09 00:48:31 -04:00
iomgaa 6a090541be test: skip L8 when the channel drops the model instead of failing
The 2:25 slow run left exactly one red: kimi-for-coding answers 404
model_not_found because the channel removed it from the account group
between 09:44 (four green probes, correct model_reported) and 15:00. L8
was reading that as "the capability table drifted", which is a statement
about the model the channel no longer serves.

The 404/model_not_found rule already used by T10 now lives in one helper
and is applied on the L1-L9 side too, via the same unreachable fallback:
that one rejection skips and records an uncovered row, every other
RequestRejectedError still bubbles, since those are the real failures
this suite exists to catch.
2026-09-05 18:51:22 -04:00
iomgaa 758a127f06 test: stop reading channel outages as library defects in live e2e
L9's "unknown shape" sample was the openai profile, which 1.3.3 gave a real
shape (off/on_base/effort_key all set), so the guard had nothing to reject.
It now registers a shapeless provider of its own and tests the mechanism
rather than whichever profile happens to be blank that month.

L8 checks the reported model before judging the capability table: this channel
answers glm-5 / glm-5.1 / glm-5.2 with glm-5.3, which is a routing problem the
library already warns about, not drift. All three are guarded, including the
one that passed by luck.

T10 tells 404 model_not_found (the channel dropped the model) apart from 400
(the tier really is refused), reading the status code and the body's type field
rather than the whole message; only the latter still counts as a conclusion
about a tier. An all-skipped tier list now skips instead of going green.

TestMiniMaxM3 gained the unreachable fallback its own docstring promised: an
outage now skips and leaves an uncovered row, where before it failed ahead of
_record and left no trace of what happened.
2026-09-05 16:24:24 -04:00
36 changed files with 5120 additions and 1466 deletions
+10 -5
View File
@@ -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__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15 # LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不表态 / true=要求开启 / false=要求关闭 # LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不表态 / true=要求开启 / false=要求关闭
# 本键是 REASONING_EFFORT 的语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态 # 本键是语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态
# "要求开启"注入什么随 provider 段而定: openai/anthropic/google 三段的开启形态是 # 已登记模型须清单含 AUTO 才接受 true;nearest 不代选强度。
# on_base={}——一个字节都不注入,走模型自己的默认档(该默认档若不推理,本键不会报错 # 未登记仍尽力+warning,空 wire 可能零推理字节,不保证开启。
# 也不会开推理,见 CHANGELOG 1.3.3「已知限制」/ issue #21);要确保开启请配 REASONING_EFFORT # M3 删除糖并选 medium 等表内档;M2.5/M2.7 AUTO 不再偷带 medium。
# LLM__QWEN__1__REASONING_EFFORT=low # 本源默认推理档位;缺省=不表态(随模型自己的默认档) # 完整 M1M9 与缺测见 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 | minimal | low | medium | high | xhigh | max
# none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度 # none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度
# 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low), # 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low),
@@ -114,6 +116,9 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None # PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价; # # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高 # # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
# 推理语义/fallback/能力表/wire 变化前须换从未使用的新 namespace 或 salt。
# 保留租户前缀与 epoch;per-call 覆盖也要迁移,只改此处无效。
# 未迁移仍可回放旧语义并绕过新拒绝;回滚旧身份会重见旧值,库不自动隔离。
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化) # PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0 # PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略) # PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
+13 -3
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## 1.3.4(2026-09-09)
> [!WARNING]
> **patch 版号不代表无迁移成本。** 已登记但不含 AUTO 的 True/auto 配置及受管+raw 双来源现在明确拒绝;受影响缓存必须在首次新语义读写前显式换 namespacesalt。升级步骤见 [README 迁移节](README.md#134-推理配置迁移)。
- **推理意图**:已登记 AUTO 必须为能力清单成员,True 糖同约束;nearest 不代选强度。MiniMax on_base 改空,M3 要显式选 medium 等登记档;M2.5M2.7 空 wire 流式 AUTO 各5轮定向复验通过,不代表全矩阵覆盖。未知仍尽力+warning,不保证开启。
- **所有权**:受管意图下两层 raw 推理控制同值/被遮蔽也拒绝;raw-only 与普通采样浅覆盖保留。自定义 on_base 禁止偷带强度。
- **缓存迁移前置**:受影响调用更换从未承载旧语义的 namespacesalt;同版本 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未覆盖条件,仍为 FAILclaude-opus-5 开启档位命题仍 FAIL,不能把 HTTP 200 当推理开启证明。独立审查、红绿/变异、日常与定向实测均按适用范围复用;具体失败、网络诊断及原始证据见[验证记录](research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md)。该例外不取消下游缓存迁移前置,也不代表合并后检查、上传或外部包验收已执行。
## 1.3.3(2026-09-05) ## 1.3.3(2026-09-05)
推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。 推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。
@@ -196,7 +209,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了
- `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。 - `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。
- 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。 - 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。
## 1.3.0(2026-08-24) ## 1.3.0(2026-08-24)
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。 遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
@@ -265,7 +277,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了
- SQLite 遥测初始化失败后终于有日志了。此前 `sqlite.py` 初始化失败直接 `return`,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版**只做可见性**,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。 - SQLite 遥测初始化失败后终于有日志了。此前 `sqlite.py` 初始化失败直接 `return`,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版**只做可见性**,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。
- 写入路径不再用 `async with pool.acquire(...)``Pool.release()` 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。 - 写入路径不再用 `async with pool.acquire(...)``Pool.release()` 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。
## 1.2.4(2026-08-20) ## 1.2.4(2026-08-20)
熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。 熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。
@@ -287,7 +298,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。 - `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。 - `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
## 1.2.3(2026-08-19) ## 1.2.3(2026-08-19)
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。 遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
+42 -1
View File
@@ -30,13 +30,54 @@
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。 **降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。
## 1.3.4 推理配置迁移
> [!WARNING]
> **1.3.4 虽为 patch,升级仍会拒绝部分旧配置。** 已登记但不含 AUTO 的模型不再接受 `ENABLE_THINKING=true``REASONING_EFFORT=auto`;受管推理与 raw 控制并存(即使同值)也会拒绝。请先按下表选择显式档或 raw-only,并在受影响调用首次使用新语义前更换缓存 namespace/salt;**只升级包不会自动隔离旧缓存**。
**先明确意图,再在首次新语义缓存读写前切换缓存身份。** `auto` 要求开启但不指定强度,不是 `None`(不表态),也不是库代选付费档位。已登记模型只有清单含 AUTO 才接受 Trueautonearest 不把 AUTO 映射成强度。未知模型仍尽力+warning,空开启片段可能零推理字节,不保证开启。完整型号证据见[批准设计 §4/5](research-wiki/designs/2026-09-09-134-thinking-contracts-design.md)。
| 项 | 旧配置/受影响模型 | 用户明确选择的新配置(示例,不是成本推荐) |
| --- | --- | --- |
| M1 | MiniMax-M3 Trueauto | 删除糖,`REASONING_EFFORT=medium` 可恢复旧 medium 字节;也可选表内其他档 |
| M2 | deepseek-v4-proflashflash-vision-exp、glm-5.2 Trueauto | 删除糖,选 high 或 max;非空开关也不能豁免 AUTO 成员检查 |
| M3 | glm-5.35.3-flash、kimi-k3kimi-for-coding Trueauto | 删除糖,选 lowhighmaxnearest 不能修复 AUTO |
| M4 | gpt-5.45.5、claude-opus-5sonnet-5、gemini-3.1-pro Trueauto | 删除糖,可选表内 medium;不可达不能补 AUTO,也不等于 live 证明 |
| M5 | MiniMax-M2.5M2.7 Trueauto | 仍接受,但 on_base 不再偷带 medium,改为空片段;缓存须迁移。2026-09-09 两型各5轮流式 AUTO 复验通过,不外推到其他模式/渠道 |
| M6 | qwen 五型、glm-55.14.6v Trueauto | 保留;glm-5/5.1 历史身份不足仍未覆盖,不推及其他型号 |
| M7 | 未登记模型 True/auto | 可保留尽力;确定保证须先独立取证再登记能力 |
| M8 | 受管意图+任一层 raw 推理控制,即使同值/被遮蔽 | 保留受管档并删除源 extra_body、请求 overlay 的控制键;或清空源糖/档和请求意图,仅 rawapplied_effort=NULL |
| M9 | 如 glm-5.3,请求 mediumnearest 改 error | 同步更换 namespacesalt;旧身份仍可能回放 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 textlayout 成败行均 NULL。实际档分析须排除缓存命中与错误行,未知 AUTO 不证明上游能力。
测试侧默认 FAIL:404 只有请求、唯一尝试、完整无重复键 JSON、error.type=model_not_found 等独立证据全满足才 UNCOVERED;429/5xx/网络/解析错误不整类 skip。成功公共身份缺失无独立证据仍 FAIL;成功 SSE 不新增捕获器。关闭须完整合格轮次全 ABSENT,UNKNOWN 不能靠长度升格成功。不可关闭探测的 OBSERVED 仅支持本条件下未关闭;预期拒绝另按预声明类型、状态、机器字段判定。必需 live 的 SKIPUNKNOWN/缺轮不因 pytest exit 0 通过发布门。
**本版验收例外(2026-09-09 用户正式批准)**:不再补全模型矩阵;既有失败、UNKNOWN、不可达、缺轮及下游现行配置缺证据如实保留,不改成 PASS。M2 两型的定向成功不代表全模型通过;三项目实际配置迁移仍未核验,合成兼容测试不能代替,缓存迁移操作前置也未被豁免。逐项实测、网络诊断与证据索引见[1.3.4 验证记录](research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md)。
## 安装 ## 安装
发布在实验室 Gitea PyPI(公开包,匿名可装): 发布在实验室 Gitea PyPI(公开包,匿名可装):
```bash ```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]>=1.3.0,<2" "polygateway[redis,postgres,structured]>=1.3.4,<2"
``` ```
核心仅依赖 `httpx` + `pydantic`;按需选 extras: 核心仅依赖 `httpx` + `pydantic`;按需选 extras:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.3.3" version = "1.3.4"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
+19 -1
View File
@@ -128,6 +128,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**决策**: 借鉴 Clean Architecture 的三条原则——依赖规则(核心不依赖具体技术)、端口与适配器(Protocol 定义接缝)、组装点(所有构造集中注入);**不照搬**其面向应用的四层分层(Entities/Use Cases/Interface Adapters/Frameworks)。库内部的组织模式采用**中间件洋葱**(同 ASGI middleware / gRPC interceptor / Rust tower):重试、限流、熔断、缓存、遥测各为一层,层与层正交,顺序与取舍是配置。 **决策**: 借鉴 Clean Architecture 的三条原则——依赖规则(核心不依赖具体技术)、端口与适配器(Protocol 定义接缝)、组装点(所有构造集中注入);**不照搬**其面向应用的四层分层(Entities/Use Cases/Interface Adapters/Frameworks)。库内部的组织模式采用**中间件洋葱**(同 ASGI middleware / gRPC interceptor / Rust tower):重试、限流、熔断、缓存、遥测各为一层,层与层正交,顺序与取舍是配置。
**背景与讨论**: 人类提问"是否借鉴《Clean Architecture》,是否有更好的指导思想"。结论:那本书为应用程序而写,库没有"用例层",硬套四层会造出空转抽象。对库更适配的思想来源: **背景与讨论**: 人类提问"是否借鉴《Clean Architecture》,是否有更好的指导思想"。结论:那本书为应用程序而写,库没有"用例层",硬套四层会造出空转抽象。对库更适配的思想来源:
- **Hexagonal / Ports & Adapters**(Cockburn):三项目已在实践的本质。 - **Hexagonal / Ports & Adapters**(Cockburn):三项目已在实践的本质。
- **《A Philosophy of Software Design》(Ousterhout)的"深模块、窄接口"**:接口复杂度是用户付的成本。落地为——90% 用户三行起步(`from_env()``chat()`),全部可配置性经构造函数暴露给需要的人,但绝不强迫简单用户理解。 - **《A Philosophy of Software Design》(Ousterhout)的"深模块、窄接口"**:接口复杂度是用户付的成本。落地为——90% 用户三行起步(`from_env()``chat()`),全部可配置性经构造函数暴露给需要的人,但绝不强迫简单用户理解。
- **中间件洋葱**:与治理栈天然同构。反面证据:三项目的 `GovernedLLMClient.chat()` 是约 500 行的方法,五层治理手工内联在一个重试循环里,横切关注点没有被切开,遥测调用因此被迫复制 4 次。洋葱模型下遥测就是一层,只写一次。 - **中间件洋葱**:与治理栈天然同构。反面证据:三项目的 `GovernedLLMClient.chat()` 是约 500 行的方法,五层治理手工内联在一个重试循环里,横切关注点没有被切开,遥测调用因此被迫复制 4 次。洋葱模型下遥测就是一层,只写一次。
@@ -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,而深路径引用正是模块重组会打断下游的原因。 **职责拆分(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 才接受 TrueAUTOnearest 不将 AUTO 代选强度;未知模型按已知 wire 尽力+warning,不保证开启。MiniMax on_base 改空,M3 仍不含 AUTOM2.5M2.7 空 wire 真实复验待完成,不新增能力条目。
有受管意图(含 NONE/糖/未知模型)时,source.extra_body 或 request.overlay 任一层出现标准控制根或当前 wire 两向控制根/effort_key 均拒绝,同值和后层遮蔽也不豁免;无意图保留 raw-only,不推断 applied。on_base 不得含自己的 effort_key 或标准 reasoning_effortoutput_config.effort,点号仍是字面顶层键,不新增私有方言解释器。工厂源级校验在装配期;chat 仅前置校验显式请求档与已知 raw;默认 transport 对选中源完整校验并在 HTTP 前 RequestRejected,可能已经准入,finally 结算不变。自定义 transport 由端口实现方履约,不新增 preflight。细则与 M1M9 见[批准设计 §45](designs/2026-09-09-134-thinking-contracts-design.md)。
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律) ### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。 **决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
@@ -391,6 +396,8 @@ flowchart TB
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) | | `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
| `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 | | `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 |
**测试证据边界(1.3.4**:运行时 UNKNOWN 不告警不等于关闭测试成功。关闭须完整合格轮次全 ABSENT;不可关闭命题在完整合格轮次有 OBSERVED 可支持本条件下未关闭,全 ABSENT 证伪,无 OBSERVED 但 UNKNOWN 仅未覆盖。开启保留完整计划分母与多数 OBSERVED,不丢失败轮。身份缺失只有独立原始 JSON 证据才可归上游;公共身份丢失且无取证 FAIL,成功 SSE 不新增捕获器。默认 FAIL,仅完整请求/唯一尝试/完整无重复键 JSON404 精确 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 在字段设计上的落法。 三态**不可折叠为布尔**: `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**——它是结果不是请求。 判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。
@@ -524,6 +531,8 @@ flowchart TB
### 7.5 响应缓存 ### 7.5 响应缓存
**1.3.4 显式迁移前置(D3**:key 与源指纹不新增包版本、语义 revision、fallback、能力表或 wire 版本。AUTOrawMiniMax 语义变更及同版本 fallback/能力表/自定义 wire 改变时,受影响调用集合必须在首次读写前切到从未承载旧语义的 namespace 或 salt。保留租户前缀与 epoch;覆盖工厂默认、全量注入和 per-call(只改默认对覆盖路径无效)。同一共享缓存身份只要一源受影响,整个调用集合须隔离或由下游显式拆分;不强制未受影响 chat 冷启动。新旧版本不共享新身份,回滚旧身份会重见旧值。**未迁移仍可能回放旧响应、绕过新拒绝**,库不会自动检查新可满足性;操作说明不能当自动防护。
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:` **key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。 - `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` 路径,失败仍只逐行降级、不判死。 **`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_effortnearest 后,不重算);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`。embeddingOCR textlayout 的成功与失败尝试由 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` 独立成一档而不再被并进「未推理」。 **`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` 独立成一档而不再被并进「未推理」。
@@ -1,5 +1,7 @@
# 推理档位一等化设计(issue #20 及其一般形式) # 推理档位一等化设计(issue #20 及其一般形式)
> **替代指针(2026-09-09**:§36812 的 AUTO 无条件放行、MiniMax 内置 medium、受管 raw 覆盖与缓存迁移/遥测总括语义,以[1.3.4 已批准设计](2026-09-09-134-thinking-contracts-design.md) §4–8 为准。历史调研与实验事实保留,不倒改为新语义已验证。
- **日期**: 2026-09-04 - **日期**: 2026-09-04
- **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门) - **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门)
- **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过 - **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过
@@ -0,0 +1,332 @@
---
type: design
node_id: design:2026-09-09-134-thinking-contracts-design
title: "1.3.4 推理意图与测试证据设计"
date: 2026-09-09
---
# 1.3.4:推理意图的可满足性与测试证据契约
> 日期:2026-09-09。状态:**已通过独立审查并获人类正式批准;进入实施计划阶段,编码须先完成计划审查**。
> 用户已批准合并处理 #25/#26/#21,及 D1(未知 AUTO 尽力+告警)、D2(受管推理与 raw 冲突拒绝)、D3(下游显式迁移 namespace/salt)。下文细则供正式设计审查,不重新悬置已决方向。
> 本轮只修订本文件;未改生产代码/测试、未提交、未启动子代理、未执行真实付费调用。既有审查历史与剩余证据门见 §11。
## 1. 目标、非目标与事实源
本批修复共同问题:库声明的推理意图、实际发送参数和验证证据应一致;无法确认的事实不得变成成功声明。
| 范围 | 交付 | 明确不做 |
| --- | --- | --- |
| #21 | 已登记 AUTO 成员检查;移除默认 provider 偷选 medium;冲突守卫;能力证据审计与迁移说明 | 新推理 DSL、通用 overlay 系统、预算型推理、模型名猜测、无证据批量加 AUTO |
| #25 | 测试侧窄归因函数、逐轮留证、未覆盖汇总;纯本地断言离线化 | 改四分类、整类异常 skip、重跑到绿、扩大生产遥测、自动豁免发布覆盖 |
| #26 | 真实 client→治理→emitter→recorder 守护无推理路径,附变异证据 | 新端口、替换 reasoning_applies、遥测 schema 改造 |
| 后续批次 | #19#23 归 1.3.5#22 归 1.3.6#24 独立设计 | 调用上下文 API、deadline、429 算法、对冲或取消结算重构 |
规范依据:CLAUDE.md、brainstormingstructured-logging skill、ARCHITECTURE D11、§4.55.167.57.8、docs-convention。
旧设计:`2026-09-04-reasoning-effort-design.md` §36812;旧实验:`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=mediumM3 不是仍必然不推理;问题是 applied_effort=auto 却发 medium |
| `openai_compat.py::_build_payload` | resolution 后依次浅层 update extra_bodyoverlayraw 可改写或新增推理控制,成功档位与最终字节失配 |
| `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-sdkGovDoc-SaaS、Video-Tree-TRM5、CHSAnalyzer 工作区均缺失,**未核验三项目现行配置**。迁移示例不是下游迁移完成证据。
## 3. 备选方案与已批方向
| 方案 | 收益 | 代价/结论 |
| --- | --- | --- |
| Asupported_efforts 包含 AUTO 的语义能力 | 不新增模型字段,统一可满足性判据 | 部分旧 True 配置报错,须审计清单并迁移;**用户已选 A** |
| B:新增模型默认推理三态 | 默认开启与未知可分别表达 | 多维护一套事实,易与能力清单漂移;不选 |
| Cprovider/模型把 AUTO 映射固定档 | 保持旧配置字节 | 库代选付费档位,模型事实塞进 provider;用户不选 |
| 决策 | 已批准 | 不采纳的备选及理由 |
| --- | --- | --- |
| D1 | 未登记 AUTO 保留尽力注入+warning,不保证开启;已登记一律成员检查 | 不改成未知即拒绝;也不把未知当能力已验证 |
| D2 | 有受管意图时拒绝 raw 推理冲突;无受管意图保留 raw | 不保留双来源互相覆盖,不从最终 payload 反向猜档位 |
| D3 | 下游显式换 namespacesalt 隔离语义变化 | 不加自动 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_effortenable_thinking 糖(True→AUTO、False→NONE);None 不覆盖源级表态,源上两字段矛盾的既有构造校验保留。
| 生效输入/条件 | 结果 |
| --- | --- |
| effort=None | 不注入,applied_effort=Noneraw 仍按旧优先级发送,不推断其档位 |
| 所需方向形态未知 | 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.5M2.7 | 已有 AUTO;历史默认/mandatory 旁证及 T10 开启观测。T10 未留最终 wire,移除 medium 后需补空 on_base 实测,不冒充已复验 |
| qwen3.7-plusmax、qwen3.6-plus、qwen3.5-flash、qwen-plus-latest、glm-4.6v | 保留已有 AUTO 与逐型号证据,不推及其他型号 |
| glm-55.1 | 保留既有文档推定 AUTOT10 回报 glm-5.3,身份不足,不算本型号实测 |
| deepseek-v4-proflashflash-vision-exp、glm-5.25.35.3-flash、kimi-k3kimi-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-5sonnet-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_effortthinking_budget 也拒绝,不能只检查字典交集 |
| 无受管意图 | 即请求、源级档与糖都不表态,保留 extra_body→overlay 原有浅覆盖(含 raw 推理),applied_effort=None;原 model/messages/stream/stream_options 禁写规则照旧 |
自定义 wire 不新增路径 DSLeffort_key 仍是一个**顶层字面键**,不把点号解释成嵌套路径;on_base/off 可含嵌套对象,raw 守卫保护其整个顶层根。
自定义 on_base 不得包含自己的 effort_key,也不得借标准 reasoning_effort 偷带档位;无论其值是 medium、auto 或 None 都拒绝为配置错误,不能默默删键。标准嵌套 `output_config.effort` 同属禁带强度的已知路径;不解析任意私有嵌套方言。需要强度请走显式档,不能将其固化在开启片段。
其余自定义不透明方言的语义真实性由注册者提供证据;本批保证声明键不被 raw 冲掉,**不宣称可以识别所有未声明的私有别名/预算语义**。扩展别名应登记 wire 后受控,不新增通用参数解释器。
### 4.4 守卫时机、错误与保证范围
| 入口 | 时机/职责 |
| --- | --- |
| 工厂 `_guard_thinking` | 已有 profilecapabilitysource 材料齐全;装配期(网络及准入前)校验 wire、源级可满足性与源 extra_body 冲突,抛 ThinkingUnsupportedError(配置 ValueError)。工厂拒绝的源不能靠未来请求覆盖“救活” |
| `chat` 前置参数校验 | 保留 validate_request_overlay;请求显式档+调用 overlay 的已知控制词表冲突可在进入洋葱/准入前报配置 ValueError。不新增全源能力预解析,不声称这里可见自定义 transport 的注册表 |
| 默认 transport `_build_payload` | 以本次选中源、实际 profile、请求覆盖后的意图,对两层 raw 再做完整守卫;全量注入与自定义注册表同样覆盖。ThinkingUnsupportedError 翻译 RequestRejectedErrorHTTP 发送前拒绝,无重试/换源/故障熔断计数 |
| 自定义 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 namespacesalt 覆盖 |
| 切换 | 为受影响调用集合选择从未承载旧语义的 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-proflashflash-vision-exp、glm-5.2 | 同上;开关 on_base 非空也拒绝 | 删除糖,显式 `REASONING_EFFORT=high` 或经用户选择 max | `test_thinking.py`:非空 wire AUTO 成员约束 |
| M3 同上 | glm-5.35.3-flash、kimi-k3kimi-for-coding | 同上,nearest 不能解 AUTO | 删除糖,显式 `REASONING_EFFORT=low`(也可选 highmax | `test_thinking.py`AUTOnearest 拒绝 |
| M4 同上 | gpt-5.45.5、claude-opus-5sonnet-5、gemini-3.1-pro | 同上;不可达不补 AUTO | 删除糖,用户选表内 `REASONING_EFFORT=medium`;这不是新增 live 证明 | `test_thinking.py`:空 wire 非成员拒绝 |
| M5 同上 | MiniMax-M2.5M2.7 | 仍 AUTOon_base 从 medium 改空,真实语义待补测 | 保留 True/AUTO 并迁移缓存;需要旧 raw 字节者须完全退出受管意图,不可谎称该模型支持 medium | `test_thinking.py`:空 wire AUTO 字节;live 单列缺测 |
| M6 同上 | qwen 五型、glm-55.14.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 的同时显式迁移 namespacesalt | 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;既有 cachettl 参数照常注入 |
| 自定义 wire 偷带强度 | `on_base={"reasoning_effort":"high"}` | `on_base={}`、保留 effort_key;用户显式选 HIGH(须模型支持),并迁移缓存 |
三项目迁移验收须由各自负责人提供脱敏的实际配置/装配与调用位置,映射 M1–M9、提交所选替代与缓存身份切换证据。**当前三项目均为未核验**;Protocol 合成兼容测试通过也不能代替现行配置迁移验收。
### 5.3 旧行为处置
| 旧行为 | 处置 |
| --- | --- |
| True→AUTO、请求>源>糖、NoneNONE、未知尽力+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 requestresponse hooks 的真实 AsyncClientrequest hook 检查 method、规范 URL、JSON modelstream/控制片段,Authorization 与该源凭据仅在内存精确比较,输出布尔值 |
| HTTP 错误体 | response hook 保留本次响应引用;该次 complete 结束后读取**已缓冲** content(最多接受 64 KiB 完整内容作为分类输入)。未缓冲/超限/解析失败均标证据不足;不在 hook 预读成功 SSE,不另发请求,不以摘要假装完整 JSON |
| attempt 关联 | 测试专用窄 Transport 委托器原样转发 completeembed 参数与异常,将入参 call_idattempt 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 去 userinfoquery,只存安全 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 |
| 4295xx401403、网络错误 | **本批不建立自动外因豁免**:当前资料未给可核验的网关机器码白名单及独立归因来源,全部 FAIL 并保存实收证据;不能仅凭 HTTP 状态、TransientSourceDead 类或“请求离线测过”跳过 |
| no_sourcesstalledretry_exhaustedAllSourcesExhausted | FAIL;本批不从生产遥测补失踪逐次原因,不将“曾见过一条 429”推断为整个终态均外因 |
| SSEJSON 解析、空补全、ValueError、一般 RequestRejectedResultInvalid、断言失败 | 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→TrueOCR False→True(两个入口分别红);chat True→False;移除 emitter applies 短路。记录节点、目标语义失败断言、退出码,原实现及还原后通过。
Issue #26 当前实现正确,先红来自上述变异,不为 TDD 改坏主工作区;仅因无关签名异常变红不算杀死目标变异。注入资源由测试自己关闭。
## 8. 非功能与四种遥测行口径
继续通过 `TelemetryEmitter` 唯一出口与现有 `llm_calls.reasoning_effort`;**不新增生产遥测字段、表、事件或旁路日志流水**。
| 行类型 | reasoning_effort 来源 | 分析限制 |
| --- | --- | --- |
| 真实成功尝试 | response.applied_effortnearest 后);无推理路径由 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 隔离 |
| 取消 | 同步守卫不捕 BaseExceptionCancelledError 穿透,既有 in-flight finally 释放;不重构真实调用/遥测取消时序 |
| 降级 | 缓存/遥测故障 warning 降级,限流/熔断后端不可用仍报错;配置守卫与运行期四分类见 §4.4 |
| 持久化/原子性 | 无 schema/DDL;SQLite 临时文件,旧缓存不改写,报告安全写且失败显式失败;无任务恢复子系统 |
| 告警/评估 | 未登记与对账 warning 保持实例节流;错误含 model、请求档、支持集合与可执行配置例,不泄露凭据;离线矩阵和变异须全过,live 覆盖基线待实际运行 |
## 9. 实施接缝与文档同步
| 接缝 | 预期改动 |
| --- | --- |
| thinkingproviders | AUTO 成员约束、nearest 边界、文案、空 wire 语义、D2 窄纯校验;已有能力证据保留出处,不无证据追加 |
| client/默认 transport | 工厂及请求前置可执行的守卫、最终双 raw 守卫与错误翻译;不改端口、不移动层序、不引入全源请求准入解析 |
| cache | 本批不加语义 revision 或自动校验;只交付显式迁移回归与边界说明 |
| 单元/轻集成/e2e | 真实调用链+SQLite、分类输入与反例、逐轮完整性、隔离变异;M3 True 改为“本地拒绝”和“medium 真实开启”两个命题 |
| 文档 | 实施时同步 ARCH D11/§5.17.57.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_keyoff 根、非法 on_base;无受管意图保留 raw,普通采样不误拒 |
| 守卫时机 | 工厂失败零准入/零 HTTP;前置可判请求冲突零准入;默认 transport 拒绝允许既有准入但零 HTTP、正确结算、无重试换源;全量注入同测 |
| 缓存迁移 | 旧客户端写旧身份→新客户端使用全新身份 miss 并执行新拒绝;nearest 写→error 读、能力表/wire 变化均换身份;工厂默认、per-call 覆盖、全量注入、多源集合、并行旧新客户端覆盖 |
| 缓存已知边界 | 另测未迁移的共享身份可能命中并绕过 transport;将其明确记录为操作风险,不把迁移前未拒绝伪装为迁移后安全已验证;无强制全部 chat 冷启动 |
| 归因/防假绿 | 404 精确 type/仅 message/截断/重复键;400429503SSEno_sources 默认 FAIL;故意改错 model、Authorization、端点、SSE 解析必须红;第二轮失败保留第一轮证据;取消穿透 |
| 遥测 | 四种行来源逐项断言;三个无推理入口真实调用链/SQLite NULL 与 chat 阳性;按 parent/session 归组且 attempt UUID 唯一;四类变异被目标断言杀死 |
| 真实核心 | M3 AUTOTrue 拒绝零网络、M3 medium 流/非流、M2.5M2.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、§10D3 显式迁移,不加 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 审查对修订版无 CriticalImportantMinornative reviewer 另发现两个测试归因 Important:公共响应身份不足以归因上游,以及 NONE 关闭成功与不可关闭负向研究混同。父会话已在 §6.2/6.3 作最小修订(证据不足 FAIL、不新增成功 SSE 捕获器、按测试命题判定),native reviewer 定向复审通过(run `2f92c90a-298d-459d-8fcb-087dea475770`),无新增 CriticalImportantMinor。
剩余证据门为 M2 空 wire 实测、其他 live 缺测及三项目实际迁移,不重新悬置已决 D1–D3。2026-09-09 用户已明确选择“批准并继续”,正式批准本文件;独立审查及 native reviewer 定向复审均已通过。
**当前结论:设计已批准,进入实施计划;计划独立审查通过后直接执行。** 设计批准不等于变异、真实矩阵或下游迁移已通过;尚未取得的证据仍按 §10 门控。
@@ -0,0 +1,235 @@
---
type: finding
node_id: finding:2026-09-09-134-thinking-contracts-validation
title: "1.3.4 推理契约验证与发布准备"
date: 2026-09-09
---
# 1.3.4 推理契约验证与发布准备
> 最新状态(2026-09-09):发布准备完成;用户已正式批准将未补全模型矩阵、失败/UNKNOWN/不可达/缺轮及缺下游现行配置证据作为本版验收例外,保留原始结论而非 PASS。可移交合并与包发布;本轮未 merge/push/tag/构建/上传,合并后门与外部产物验收仍待执行。以下各节是分阶段历史,不追改当时结论;最新证据与例外见文末。原始输出在 `tests/outputs/134/`,不提交。
## 基线与修改边界
| 项目 | 实际证据 |
| --- | --- |
| 起点 | `6a09054`,保留既有两个本地测试提交,工作区仅原 `.pi/` 与待提交设计/计划 |
| T0 静态 | `t0-check.log``.exit`make check,退出 0import-linter 1 kept |
| T0 指定测试 | `t0-baseline.log``.exit`660 passed,退出 0 |
| 生产范围 | 只修改 thinkingprovidersclientopenai_compat 四文件;端口、类型、缓存指纹、遥测 schema、embeddingOCR 循环未改 |
| 文档回滚 | `2553fc7``dda5556`wiki 工具 add_entity 会覆盖无 frontmatter 的同名文件,故先补原文 frontmatter,再以节点存在性保护调用工具,显式登记图节点/implements 边 |
## 红绿证据
| 任务 | 红证据 | 绿证据 |
| --- | --- | --- |
| T1 AUTO 成员/MiniMax/未知告警 | `t1-red.log`9 failed103 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(工厂/配置/transportretry/纯解析) |
| 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→True6 failedOCR False→True12 failedtextlayout 独立红) | `t7-final-green.log`328 passed |
| T7 阳性/emitter | chat True→False:并发实际档断言失败;去 applies 短路:6 failed | 各还原退出 0chat 糖失败 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 TrueFalse。`t3-isolated-green.log`207 passed |
| factory 失败随 T3 提交进入历史 | `8e61a66` 当时附带上述未隔离测试;随后 `71f1bdf` 独立修复。最终工作区全绿;不声称每个历史提交均全绿 |
| per-call 迁移工厂测试缺缓存参数 | 合成 env 仍为 cache_backend=noneloader 正确清空 namespacettl;改为 memory+显式 TTL,新测试 9 passed,不改生产配置默认 |
| conda run 默认捕获模式下 stdin 脚本未执行 | T0 第一次文档提交只有原始两文件;通过 --no-capture-output 重跑安全登记并单独提交,未将第一次零输出当登记成功 |
| pi-lens LSP 报缺 pytestloguru、旧 StrEnum Literal 噪音、Python 3.12 语法不支持 | 非 conda 解释器限制;父监督明确批准记录并继续既定 conda pytestruffimport-linter,不改枚举/不加 ignore。后续异步 stale 测试报告已标 superseded,最终实际全量单测输出为准 |
## 当前检查与后续门
| 检查 | 结果 |
| --- | --- |
| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | `last-unit.log/.exit`**1241 passed3.95 秒,退出 0** |
| `make check` | `last-check.log/.exit`:格式/ruffimport-linter通过,退出 0 |
| `git diff --check` | 通过 |
| 本轮网络/slow | 未执行;所有 HTTP 为 MockTransportSQLite 为临时文件,无付费调用 |
| 独立 verifier/集成/slow/下游迁移 | 由父会话后续执行,本轮不声明通过;设计所列真实缺测和下游缺失仍有效 |
日志方案沿已批设计:未知能力沿既有 loguru warning,实际调用仍经 TelemetryEmitter 单点出口,四类行来源和 NULL 契约用既有 schema 验证,不新增运行时数据面。
## T5T6 与 T8 文档续作(起点 16fa0ca
本续作禁止发布/slow/付费调用,未改任何生产文件。已读完整批准设计、计划及 TDDstructured-loggingcommit 技能。独立验证与全量集成/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 passedexit 0**(其后仅取证关联/报告字段收尾,受影响325再通过,最终门见提交日志) |
| 静态 | `t56-accepted-check.log`make check 通过;compileall 测试支持模块通过;生产43模块123依赖、1契约通过 |
| live 采集 | `t56-accepted-collect.log/.exit`e2e **90 tests collectedexit 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 | 10 |
| 正文子串 model_not_found | test_incomplete_or_ambiguous_error_body_fails | 10 |
| UNKNOWN 安静→PASS | test_coverage_is_proposition_specific | 10 |
| 缺轮缩分母 | test_missing_round_never_reduces_denominator | 10 |
| 身份无证据→skip | test_identity_requires_independent_raw_evidence | 10 |
| 丢第一轮 | test_round_consumer_keeps_first_success_when_second_assertion_fails | 10 |
| 部分档未覆盖→模型PASS | test_partial_uncovered_and_failed_rounds_never_become_model_pass | 10 |
| 不交付独立raw快照 | test_raw_identity_snapshot_reaches_round_consumer | 10 |
汇总与逐例日志:`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=95T10 NONE 26×5=130及条件长复核≤78,开启57档×5=285,默认基线15,其他chat6+embed1=7;总上界703,不含既有治理重试/结构化重问。没有执行这些调用。
### 调试与未验证项
| 项 | 实际处置 |
| --- | --- |
| 新RetryPolicy测试参数误写base_delay_s | 当前工具实报TypeError,查源码后改backoff_base_s/backoff_max_s239及后续242/325通过;该失败不计目标红 |
| make check初报SIM117/B017 | 合并测试上下文,按真实解析异常指定类型,不加ignore;最终静态门通过 |
| pi-lens解释器/StrEnum噪音 | 记录 `t56-diagnostics.txt`,conda内真实导入与ruff为门,不改任务外枚举;pytest wrapper generator的return report是协议必需,独立next/send/StopIteration.value测试通过 |
| T10型号→400机器字段基线不存在 | 父会话明确确认:不编造白名单,实际400默认FAIL并逐轮留证。纯负向契约精确类型/状态/type单独测试;具体live预期拒绝未验证、需人工基线 |
| 结构化反馈重问(已被本次审查修复替代) | 原固定摘要会误拒正常反馈重问,独立审查判 P1;不再保留为可接受限制,修复与真实 StructuredMW 离线两响应证据见下节 |
| 发布/集成/live/下游 | 本任务未执行,M2空wire、M3非流式UNKNOWN、身份不足、三项目实际配置缺失仍保留为证据门 |
文档已同步README M1M9、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 反填预期,不关闭重问。真实 GatewayClientStructuredMWMockTransport 缺字段→合法响应恰两 HTTP PASS;破坏前缀、角色、内容类型、配对、预算、wire 均 FAIL;首轮凭空反馈另有 FAIL 守卫 |
| P1 未登记候选:旧 None 分支被置 cannot_disableABSENT 假失败 | 明确 observation-only,先全轮请求/身份资格,再 UNCOVEREDABSENTOBSERVEDUNKNOWN 与资格 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 failed6 passed,合法两响应误判 FAILexit 1 | `review-f1-green`7 passedexit 0 |
| `pytest tests/unit/test_live_evidence.py -k first_attempt_requires -q` | `review-f1-first-red`:首轮多反馈被放行,1 failedexit 1 | `review-final-affected`:含该节点共129 passedexit 0 |
| `pytest tests/unit/test_live_evidence.py -k unregistered_candidate -q` | `review-f2-red`ABSENT 被误判FAIL1 failed3 passedexit 1 | `review-f2-green`4 passedexit 0 |
| `pytest tests/unit/test_live_evidence.py -k capability_conclusions -q` | `review-f3-red`:三组结论缺型号断言红,3 failed,exit 1 | `review-f3-green`:三组+未登记四组共7 passedexit 0 |
| `pytest tests/unit/test_live_evidence.py -k safe_machine_type -q` | `review-f4-red`:三组缺机器字段,3 failedexit 1 | `review-f4-green`3 passedexit 0 |
| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | — | `review-final-unit`**1375 passedexit 0** |
| `make check` | — | `review-final-check`:格式/ruffimport-linter 1 keptexit 0 |
| `conda run --no-capture-output -n PolyGateway pytest tests/e2e/ -m slow --collect-only -q` | — | `review-final-collect`**90 collectedexit 0**;不是90通过 |
上表节点命令也均加 `conda run --no-capture-output -n PolyGateway`;未使用缺 import 的伪红。最终受影响文件129单测通过,相比111新增18节点。pi-lens仍提示非conda缺httpx/pytest/pydantic及StrEnum等旧噪音,按已批准方向记录后继续conda门;新增测试命名空间显式 Any 类型,不抑制真实错误。
**尚未完成**:修复后独立复审、集成/make test覆盖率、真实能力取证、下游迁移和发布;T8/T9保持未勾选。M2空wire、M3非流UNKNOWN、真实机器拒绝白名单及外部服务状态不因离线绿变成已覆盖。
## 重启恢复与 embedding 报告补漏(起点 3eb22d2
恢复时实际分支为 `feature/1.3.4-thinking-contracts`HEAD=`3eb22d2`,已跟踪工作区无差异,仅既有 `.pi/` 未跟踪;前轮代码提交仍在,重启未丢代码。按用户限定只修测试与本 finding,不动生产 API/版本/计划,不联网、不付费、不运行 slow、不派子代理。本节为实现者验证,不冒充独立复审或版本验收。
`tests/outputs/134/slow-gate.log` 保留,大小 1625 字节;`slow-gate.exit` 不存在,属于重启中断、未取得终态,不能宣称 slow 通过。修复前后 SHA-256 均为 `05967dcf749b13db815dd80449a0db5b3e7ad2144aa2ae216f8c9ab5ecb21bf5`。历史日志不覆盖、不续写,也不把旧 full-gate 作为本轮测试证据。
| 根因/范围 | 本轮修复与证据 |
| --- | --- |
| embedding 消费者遗漏四个字段 | 仅在原 finally 报告出口增加 `requested_model=source.model``provider=source.provider``planned_rounds=1``completed_rounds=1`;完成计数指已收尾轮次,FAIL/UNCOVERED 也计入,不代表成功。沿用原 matrixroundsessionparentattempt 关联,不新建报告框架 |
| 环境可覆盖请求型号 | 既有 `test_live_evidence.py` 增加真实 probe 消费者回归:AST 仅剔除 `.env`pytestmark 顶层读取,合成环境实际经过 GatewaySettings;两种 probe 型号覆盖都与原 chat 型号不同,HTTP 与报告必须等于本次 source,不能拿默认型号占位 |
| 成功与所有目标错误路径 | 真实 OpenAICompatTransportLiveCaptureObservedTransport+报告写入;仅 HTTP 边界 MockTransport。两型号×成功/503/严格404ConnectError 共8节点,分别 PASSFAILUNCOVEREDFAIL;同时断言唯一报告、逻辑 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 failed129 deselectedexit 1;八例均在真实报告消费处 `KeyError: requested_model`,不是 importmock 签名失败 |
| 同命令(四字段补齐后) | `embed-report-green.log/.exit`8 passed129 deselectedexit 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 passedexit 0 |
| `pytest tests/unit/ -q` | `embed-report-unit.log/.exit`1383 passedexit 0;格式化后 `embed-report-final-unit.log/.exit`1383 passed4.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 kept0 brokenexit 0 |
pi-lens 仍报非 conda 解释器缺 httpxpytestdotenvpydantic 及旧 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=1completed=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 failed137 deselectedexit1;两例均先验证取消穿透、报告存在及资源关闭,再因 `completed_rounds` 实际1而期望0失败 |
| `pytest tests/unit/test_live_evidence.py -k embed_probe_report -q`,修复后 | `embed-cancel-green`10 passed129 deselectedexit0;覆盖原8例与新增2例 |
| `pytest tests/unit/ -q` | `embed-cancel-unit`1385 passed4.08秒,exit0 |
| `make check` | `embed-cancel-check`:格式/ruff通过,import-linter 1 kept0 brokenexit0 |
既有非 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 无 CriticalImportantMinor;后续仅测试补漏及本轮文档/版本,不重演同一生产审查 |
| 四项取证修复复审 | 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、exit0M2.5 run `946ac7bf89d742aba8717e722c1e2f60`、M2.7 run `5587e6d9de1e4c7bb6a352afedb7e732` 各5/5轮流式、并发1,最终 wire 无偷带 medium、请求/身份资格及开启命题通过。只消除这两个单元的缺测,不外推其他模式/渠道 |
| Redis 时间语义 | `recovery-20260909/non-llm-slow.log/.exit`contracts/integration slow **18 passed、156 deselected、exit0**1142.05秒),不等同整个 slow 套件通过 |
上述独立报告原件位于 `/home/iomgaa/.pi/agent/sessions/--home-iomgaa-Projects-PolyGateway--/subagent-artifacts/outputs/<run>/`;本轮另原样复制到 `release/reused-reviews/`,不覆盖旧报告、不提交运行产物。
### 最新实测、网络诊断与明确未覆盖
| 项目 | 已取得的事实/本版结论 |
| --- | --- |
| 旧完整 slow 中断 | `slow-gate.log` 无对应 `.exit`,保留原 SHA-256 `05967dcf749b13db815dd80449a0db5b3e7ad2144aa2ae216f8c9ab5ecb21bf5`;不把日志中的局部成功当整套通过 |
| embedding 定向实测 | `recovery-followup-20260909/embedding.log/.exit`1 failed、exit1。独立三请求诊断 `channel-diagnosis-20260909.jsonl` 同一源 `minimax_1`、请求 `text-embedding-v1` 得503`error.type=new_api_error``error.code=model_not_found`、无可用渠道语义命中;**不是**404且 type 不匹配,仍 FAIL,不改成严格404未覆盖或“所有网关不支持 embeddings” |
| M3 与 claude 开启档位 | `recovery-followup-20260909/remaining-tiers.log/.exit`1 passed、1 failed、60 deselected、exit1(首错停止)。M3 run `8c852937e7144fa4b1641744785c9b2b` 六个显式档各5轮、共30/30完成,流式开启命题 PASSclaude-opus-5 run `d0303b5033f4449d82838690f1671470` 五档各5轮、共25/25完成,但至少一个开启命题 FAIL。逐轮请求成功不等于型号能力 PASS,也不能据M3流式覆盖消除非流式 UNKNOWN |
| claude 独立网络诊断 | `channel-diagnosis-20260909.jsonl` 中 high+简单题/复杂题均 HTTP200、SSE有DONE、回报身份一致;usage推理token=0reasoning_content原长1但去空白长0(只有空白)。可证明该次传输完成却缺非空推理信号,不能证明 high 已开启、不能把空白提升 OBSERVED,也不据此断言所有渠道/档位均不能推理。三请求诊断完成不等于三项能力通过 |
| 剩余矩阵停止 | `remaining-20260909/matrix.log/.exit`:选中40节点,在首个 gemini-3-flash NONE 节点约1080秒后 KeyboardInterruptexit1,无测试终态通过汇总。日志不能独立证明停止原因或网络根因;保持未完成,不算40失败或40通过,不继续补跑 |
| 其余证据缺口 | 历史 UNKNOWN/身份不足、未执行的型号/模式/关闭单元、型号级400机器字段基线、下游现行配置缺证据均按原记录保留。GovDoc/CHS 现行配置未取证,Video-Tree退出迁移后的历史兼容测试也非现行配置验收;不宣称三项目完成本版迁移 |
所有已有逐轮报告仍保留在 `live/<run>/`,汇总文件不能覆盖失败原件。本轮盘点共有453份报告:PASS标签255、FAIL标签108、UNCOVERED标签33、无status的轮数汇总57;**混有逐轮、命题、pytest及历史报告,不能相加成独立模型/测试通过率**。716个历史证据文件的 SHA-256 清单保存在 `release/prior-evidence-sha256.json`,最终复核字节不变。盘点首跑因轮数汇总无status产生 KeyError,保存 `release/evidence-audit.log/.exit`(exit1);修正盘点脚本区分汇总后 `release/evidence-audit-final.log/.exit` 为exit0,未修改原报告或生产代码。
### 本轮发布准备亲跑结果与交接门
| 检查/命令 | 实际结果/证据 |
| --- | --- |
| 先查远端占用 | 改文件前 `git ls-remote --tags origin refs/tags/v1.3.4 refs/tags/v1.3.4^{}`exit0且空;匿名 GET 包 simple/polygateway 索引HTTP200、不含1.3.4GET releases/tags/v1.3.4 HTTP404。日志 `release/remote-*`,未读取或输出凭据;未来发布前仍需复查以免竞态 |
| 版本与 README 数字 | 两处版本均1.3.4README安装下界改为 `>=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 kept0 broken、exit0 |
| `conda run --no-capture-output -n PolyGateway pytest tests/unit/test_package.py -q` | `release/package.log/.exit`**6 passed0.08秒,exit0**,两版本一致且导出面可用 |
| `conda run --no-capture-output -n PolyGateway pytest tests/unit/ -q` | `release/unit.log/.exit`**1385 passed4.41秒,exit0**,未新增测试,无新付费调用 |
conda 启动器既有 RequestsDependencyWarning 保留,不宣称零告警。本轮 conda 内实际导入 dotenvhttpxredis.asyncio 成功;工具的非conda LSP旧诊断不转成代码修改或忽略规则。
**可移交合并与包发布,不等于已发布。** 父会话按本版例外边界完成本次文档/版本差异审查,再执行合并后静态/日常门及未豁免的发布检查;不把本次例外解释为必须补全模型矩阵,也不把豁免项勾成已跑通过。merge/push/tag/构建/twine上传/下载解包独立安装/Release及registry页面检查均尚未执行;只能在实际完成后记录。不得覆盖已有同版本不同字节。
+36
View File
@@ -210,6 +210,21 @@
"id": "plan:reasoning-effort", "id": "plan:reasoning-effort",
"label": "实现计划: 推理档位一等化", "label": "实现计划: 推理档位一等化",
"type": "plan" "type": "plan"
},
{
"id": "design:2026-09-09-134-thinking-contracts-design",
"label": "1.3.4 推理意图与测试证据设计",
"type": "design"
},
{
"id": "plan:2026-09-09-134-thinking-contracts",
"label": "1.3.4 推理契约实施计划",
"type": "plan"
},
{
"id": "finding:2026-09-09-134-thinking-contracts-validation",
"label": "1.3.4 T0T4 与 T7 确定性验证",
"type": "finding"
} }
], ],
"links": [ "links": [
@@ -429,6 +444,27 @@
"relation": "implements", "relation": "implements",
"evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定", "evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定",
"added": "2026-09-05T04:07:17.723586+00:00" "added": "2026-09-05T04:07:17.723586+00:00"
},
{
"source": "plan:2026-09-09-134-thinking-contracts",
"target": "design:2026-09-09-134-thinking-contracts-design",
"relation": "implements",
"evidence": "已批准设计;T0基线660 passed、make check通过",
"added": "2026-09-09T04:48:57.560089+00:00"
},
{
"source": "plan:2026-09-09-134-thinking-contracts",
"target": "finding:2026-09-09-134-thinking-contracts-validation",
"relation": "tested_by",
"evidence": "T0T4/T71241单测与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"
} }
] ]
} }
+13 -4
View File
@@ -1,8 +1,10 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-09-05 04:07 UTC > 自动生成,更新时间:2026-09-09 06:32 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-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
@@ -45,7 +47,9 @@
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions` - [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (14) ## finding (15)
- [1.3.4 T0T4 与 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-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance` - [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline` - [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
@@ -61,7 +65,9 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (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-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -100,11 +106,14 @@
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## review (1) ## review (1)
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review` - [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1) ## schema (1)
- [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls` - [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2) ## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
- [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage` - [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage`
+6
View File
@@ -150,3 +150,9 @@
- [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort) - [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort)
- [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort - [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort
- [2026-09-05 04:07 UTC] 重建索引: 95 篇页面 - [2026-09-05 04:07 UTC] 重建索引: 95 篇页面
- [2026-09-09 04:48 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --implements--> design:2026-09-09-134-thinking-contracts-design
- [2026-09-09 04:48 UTC] 重建索引: 97 篇页面
- [2026-09-09 05:39 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --tested_by--> finding:2026-09-09-134-thinking-contracts-validation
- [2026-09-09 05:39 UTC] 重建索引: 98 篇页面
- [2026-09-09 06:32 UTC] 新增边: schema:llm-calls --implements--> design:2026-09-09-134-thinking-contracts-design
- [2026-09-09 06:32 UTC] 重建索引: 98 篇页面
@@ -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;失败=effectivecache_hitscope终态=本次请求级 | 排除缓存/失败后才可做实际档分析 |
| live 覆盖 | 逐轮 PASSFAILUNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS |
复用 schema:llm-calls(无新字段/DDL),证据索引见 `findings/2026-09-09-134-thinking-contracts-validation.md`。独立错误取证只在 tests 内存,Markdown 只记录白名单安全摘要与布尔校验,不使用生产遥测旁路补失踪尝试。生产埋点仍是 TelemetryEmitter 单点出口。
@@ -0,0 +1,393 @@
---
type: plan
node_id: plan:2026-09-09-134-thinking-contracts
title: "1.3.4 推理契约实施计划"
date: 2026-09-09
---
# 1.3.4 推理契约与测试证据实施计划
> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),T0T7 已实现并通过确定性验证;T8 文档已同步,独立验证/集成/live 与 T9 待执行**。
> 设计:`research-wiki/designs/2026-09-09-134-thinking-contracts-design.md`,用户已正式批准。
> 目标:解决 #21 的受管推理语义漏洞、#25 的测试归因漏洞、#26 的客户端遥测守卫缺口,不扩展生产端口或遥测 schema。
> 方案:在既有推理决策层添加窄校验并接入工厂/默认 transport;测试侧独立保留请求与响应证据,按明确命题判定覆盖。缓存仍由下游显式迁移,生产治理循环不重写。
> 技术:Python 3.12+、asyncio、httpx hooksMockTransport、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`T1T7 行为测试执行 `test-driven-development` 并阅读其 testing-anti-patternsT1T5 落日志前执行 `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;不改变生产 emitterclient 循环 |
| 新建 | `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`、cachetelemetry 实现及 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_baseoff 全部顶层根与 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 解码(拒绝重复键)取得;不得从 TransportResultLLMResponse.model_reported 回填。未缓冲、无可配对响应、非成功非流式、JSON 非对象/非法或 model 非字符串非 null 均不生成肯定证据,并保存原因供 FAIL;不把解析异常解释成身份缺失。成功 SSE 始终 (False, None),不新增捕获器、不预读流。
`request_checks` 必须完整包含 methodoriginpathmodelstreamembedding 为 input_shape)/authorizationcontrolmessages_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`completeembed 签名逐字保持 `ports.py`,参数原样传递。每次调用将 call_id 绑定实例持有的 ContextVarfinally 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、streamembedding 用 input_shape)、control、messages_digest;缺键直接测试配置错误,不自动从待测 payload 补齐。结构化预期片段放 control 的显式预期对象,输出路径由 write_live_round 单独接收;并发异构轮次使用各自 capture 实例,不共享可变“当前预期”。预期不调用生产 `_build_payload` 生成。实际请求 hooks 逐项比较,凭据不进 repr/序列化;可保留失败事实后由轮次出口 FAIL,不能改写实发请求“修正”它。
取证 live 使用 `GatewayClient(...)` 全量注入。conftest 可复用 `client.py` 现有 `_build_limiter``_build_breaker``_build_selector``_build_structured` 等装配函数,但不新增生产注入口、不复制它们实现;自己创建的组件用 ExitStack/显式 finally 关闭,注入 GatewayClient 不会代关。工厂行为单独离线验证。未获得取证通道的工厂 live,异常一律保存后 FAIL。
T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidence` 保存快照;T6 使用最终 `LLMResponse.call_id` 调用 `capture.raw_identity(...)`,将返回值交给 `assess_model_identity(raw_identity=...)`,不可取本轮最后一条响应猜关联。查找必须精确匹配本轮且只有一条成功 HTTP 事件;重复/跨轮 call_id、多个候选响应是取证契约错误,报告后 FAIL,不降成外因未覆盖。未发 HTTP 或没有独立身份快照时返回 (False, None)。
成功非流式原始 model 通过上述快照接口交付;成功 SSE 不加捕获器、不预读流。原始身份无从确认而公共结果缺失/不符时 FAIL;原始正确而结果丢失/改错也 FAIL。只要公共身份合格且请求合格,正常能力测试不要求新增成功 SSE 的原始副本。
## 4. 任务与提交点
### T0:基线、计划审查与文档回滚点
- [x] 修改设计批准状态,新增本计划;父会话自审后前台 Codex 独立审,具体问题修正后方可执行 T1。计划无需再走人类门,不能把“已生成”当“已审”。
- [x] 记录 `git status --short --branch``git log --oneline origin/main..HEAD`、实际 HEAD;确认源代码零差异,保存未跟踪文件清单,禁止暂存 `.pi/`
- [x] 执行基线:`make check``conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_cache.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py -q`。记录实际失败,不能先改期待绕过;本步骤预期现有非 slow 测试通过。
- [x] 以 `research-wiki` 工具登记 designplan 节点及 implements 边,现有同路径文档不可被 add_entity 模板覆盖;先读工具已有文件处理行为,再登记、重建索引、检查生成 diff。只在本计划 writer 移交后由父会话执行这些额外文件写入。
- [x] 调用 commit skill,提交点 `docs: record approved thinking contracts and implementation plan`,形成生产修改前回滚点。
### T1AUTO 成员语义与默认 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 的 errornearest 都明确拒绝且不提示 nearest。先跑新测试,旧实现因未拒绝而红;再修改 `_settle_tier``_tier_unsupported`,避免 AUTO 进入 `EFFORT_ORDER.index`
2. 保留强度→纯开关 AUTO、等距弱侧、NONE 不自动映射、None 不表态、未知空/非空 wire 尽力警告。删除 default MiniMax 的 medium 并修正注释;先测试 M3 AUTO 拒绝、M3 medium payload、M2.5M2.7 AUTO 空 payload 与 applied=AUTO。默认能力表成员不增删。
3. 用 loguru sink 检查未知告警确实说明不保证生效,default transport 实例节流不改;不引入新日志通道,不输出 raw 密钥。按 structured-logging 明确这是既有 warning 与既有列的修正。
**验证**`conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_providers.py -q`;新拒绝和 wire 回归先红后绿,其余保留行为通过。若旧下游形态测试依赖 M3 True,需要在 T3 明确改为已批准迁移样本,不能暗改能力表让它绿。
- [x] 提交点:`fix: enforce registered auto reasoning capabilities`
### T2:纯 wireraw 所有权校验
**文件**`src/polygateway/thinking.py``tests/unit/test_thinking.py`。实现 §3.1 两个签名;`resolve_thinking` 集中校验 wireproviders 不反向 import thinking。
| 红绿组 | 最小反例/保留不变量 |
| --- | --- |
| 标准控制 | 六个顶层根+output_config.effortAUTO 空片段仍拒绝 raw highNONE/糖/未知同测 |
| 双来源 | 同值仍拒绝;分别传源与请求 raw 验证,被后层遮蔽也拒绝;不修改两个 Mapping |
| 自定义 | on_baseoff 根并集、effort_key 自定义字面键、点号不解释路径;on_base 偷带自己的键或标准强度值(含 None)拒绝 |
| 嵌套 | 替换 thinking 整个根即拒绝;profile 不拥有 output_config 时仅 format 可过、effort 不可过;拥有根时 format 也不可覆写 |
| 不误伤 | effort=None 时 raw 原样允许;temperatureseedresponse_format 不属词表,合法普通采样保持;off-only 形态及当前方向未知语义保持 |
新校验首次未实现导致的 import 错误不算行为红;可先在隔离基线把同输入经现有 payload 路径表现记录为“覆盖成功但本应拒绝”,或待 T3 在旧实现回放其失败断言,补齐语义红证据。纯函数自身还需逐例断言异常及未修改输入。
**验证**`conda run -n PolyGateway pytest tests/unit/test_thinking.py -q`,每类目标反例有有效红绿,保留行为绿。
- [x] 提交点:`fix: validate ownership of managed reasoning parameters`
### T3:接入工厂、请求入口和默认 transport
**文件**`src/polygateway/client.py``src/polygateway/transports/openai_compat.py``tests/unit/test_client.py``test_config.py``test_openai_compat.py``test_retry.py`
先在旧路径跑源 HIGH+相同 raw HIGH、本次 AUTOraw HIGH 的行为反例,确认旧实现实际发出 raw 参数而未拒绝。再接入两守卫:工厂先求 effective_effort 并校验 source.extra_bodychat coerce 后调用 wire=None 的已知词表检查;transport 对当前 profile 和 effective_effort 分别检查 extra_bodyoverlay,再保持原浅 update 顺序。
| 接缝 | 验收 |
| --- | --- |
| 工厂 | SourceConfig 仍能表达 raw-onlyfrom_envfrom_settings 对受管源拒绝发生在 limiterHTTP client 创建之前;记录构建计数零,不以网络偶然没发代替 |
| 请求前置 | 显式请求档与 overlay 已知键冲突,ValueError 且 handler/准入未触发;源级意图或自定义根留 transport 再查 |
| 全量注入 | 真实 OpenAICompatTransport 翻译为 RequestRejectedErrorMockTransport 记录零 HTTPRetryMW 不换源不重试、limiter inflight=0、已有探针收尾路径正常 |
| 参数保真 | raw-only 允许,applied=None;普通采样源<请求<结构化 overlay 的现状保留;显式档/nearest 成功 payload、TransportResultLLMResponse applied 与真实成功遥测相符 |
| 多源/并发 | 每次以选中源 profile 校验,不因另一个源清单不同提前判整个池死;共享 client 无“最后档”串线;错误不包含 raw 值 |
工厂将来被请求覆盖不能救活一个已拒绝源,这是已批行为。不要为全量注入自定义 transport 添加 preflight 端口。已有 fixture 需要调整时,只将不再合法的受管+raw 双来源改为显式单来源,新增拒绝反例保留迁移证明。
**新增生产默认 HTTP factory 离线守卫**:现有 `tests/unit/test_openai_compat.py` 没有 authtimeouttrust_env 构造断言,不能写作“保留”。新增 `TestDefaultClientFactory`,直接调用生产 `_default_client_factory(source)` 返回真实 AsyncClient,不使用 T5 的测试 factory,也不 mock 整个 AsyncClient。用两组不同假 api_key、非默认 timeout_s、trust_env=TrueFalse 参数化;不发送网络,finally aclose。节点为 `test_authorization_uses_source_api_key`(检查 client.headers 及 build_request 生成的 Authorization)、`test_timeout_uses_source_timeout_for_all_phases`connect/read/write/pool 全部等于输入 timeout_s)、`test_trust_env_uses_source_setting`(检查 client.trust_env)。在隔离副本逐个删除 Authorization 传入、遗漏 timeout 参数、遗漏 trust_env 参数/写死 True,指定节点必须因值不符红,再恢复通过;当前实现本来正确,以这些语义变异作为红证据,不改生产 factory 凑红。
独立命令:`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py::TestDefaultClientFactory -q`,原始实现绿、每个遗漏变异被对应断言杀死、恢复绿;这套测试与 T5 hooks 校验分别验证生产装配和测试取证两条路径。
**验证**`conda run -n PolyGateway pytest tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -q`,加 T1/T2 的测试一起跑;工厂/请求/全量注入拒绝均有旧实现红、新实现绿。
- [x] 提交点:`fix: reject conflicting raw reasoning overrides before sending`
### T4:显式缓存迁移和四种遥测口径回归
**文件**`tests/unit/test_cache.py``tests/unit/test_client.py``tests/unit/test_telemetry.py`;不改生产 cache、指纹或 emitter。
使用真实 InMemoryCache、GatewayClient、默认 transportMockTransport 构造两个客户端。旧语义 payload 可按 1.3.3 真实序列化形态预写(历史数据夹具,不需要在当前生产放回漏洞);同版本 nearest→error 则运行真实客户端写入。
| 场景 | 断言 |
| --- | --- |
| 旧 AUTO/raw 记录 | 旧身份可回放是已知风险;换全新 namespace 或 salt 后 miss,实际进入新拒绝,异常不缓存 |
| nearest→error | 源不表态、请求 medium、模型 glm-5.3nearest 写入后 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_adepth_b),on_base 均为空。旧客户端写缓存,新客户端同身份回放旧值且无新 HTTP;新 namespace 或 salt 后 missMockTransport 必须收到 depth_b=high 且无 depth_a,返回可区分的新结果,旧身份仍能回放旧值。profile 仅局部注入,不改源配置让现有指纹意外变化 |
| 入口与范围 | 工厂默认 namespace、per-call 覆盖默认、构造全量注入、共享多源 scope、两个租户原前缀保留;只改默认无法覆盖 per-call,需专门反例 |
| 并行/回滚 | 旧新身份可并行且不覆盖对方;回到旧身份确实重见旧值;未受影响调用 key 黄金值逐字不变 |
| 四行口径 | 真实成功=applied、失败尝试=effective 意图、cache_hitscope 终态=本次请求级;缓存不读取历史 applied 作本次遥测档 |
该任务多数是已有正确行为的守卫,不人为改生产获得红:隔离变异遗漏 namespacesalt、将 cache_hit 遥测改读历史 applied,要求相应行为断言红,恢复后绿。T3 新拒绝路径旧实现红绿可复用,但不能只报它替代迁移维度证据。
**验证**`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_client.py tests/unit/test_telemetry.py -q`。两项新增能力/wire 迁移节点均置于 `tests/unit/test_cache.py::TestExplicitCacheMigration`,单跑 `conda run -n PolyGateway pytest tests/unit/test_cache.py::TestExplicitCacheMigration -q`;分别在隔离副本去掉其 namespace/salt 隔离输入,必须因没有新拒绝/新 wire 而红,恢复后绿,不能只以 nearest→error 的测试代替这两类。两客户端的源指纹必须断言相等,生产指纹算法一字不改。
- [x] 提交点:`test: pin explicit cache migration and reasoning row semantics`
### T5:有限测试归因和独立取证
**文件**:新增 `tests/live_evidence.py``tests/e2e/conftest.py``tests/unit/test_live_evidence.py`。按 §3.2/3.3 实现;不读 .env 的模块可被日常单测安全 import。执行 structured-logging:记录内容按设计 §6,不另建库表。
1. 纯分类默认 FAIL。仅一条完整 HTTP 错误、请求检查齐全、无别的 attempt 异常、404、完整无重复键 JSON 的 error.type 精确匹配、外抛 RequestRejectedError 且 status 一致,才 UNCOVERED;多次尝试/多 HTTP、不同 call_id、空检查元组、重复 JSON 键均 FAIL。
2. hooks 不预读成功 SSE,不将 summary 当 JSONresponse 引用等该次 transport 完成后检查 content 是否已缓冲。64 KiB 上限、0 字节、非对象 error、重复键、坏编码各有反例。薄委托器 finally 恢复上下文,零 HTTP 尝试也保存。
3. 身份函数区分 raw 取证缺失和原始响应明确缺 model;增加 `test_raw_identity_snapshot_reaches_round_consumer`,用真实默认 transportMockTransport 非流式响应依次覆盖正确 model、缺失/null,以及 JSON 非对象/非法/重复键,断言 §3.3 accessor 的来源和区别;故意让公共响应丢 model 时原始快照仍保留正确串并判 FAIL。并发两逻辑轮次+一次重试验证按 sessionparent/成功 call_id 精确选择,不回放前次失败的身份;成功 SSE 快照未取证且公共身份异常时必须 FAIL。覆盖函数按 enableddisabledcannot_disable 命题判断,UNKNOWN 不假绿;预期 400 负向契约单独测。
4. 用 MockTransport 驱动 requestresponse hooks:改错 model、Authorization、端点、SSEJSON 解析→FAIL;成功 SSE 不被提前消费;原始 model 正确但公共字段错误→FAIL。交错并发及取消证明 context reset、凭据不泄露、资源释放;薄委托器不额外调用一次 HTTP。
5. 安全报告每轮独立文件,采用 run UUID+轮次与矩阵安全标识;只接受白名单 safe_fields,拒绝原始异常/HttpEvidence 对象直接序列化。第二轮失败仍可读第一轮;写入失败是 FAIL;最终汇总统计 PASSFAILUNCOVERED 和缺轮,不能只数 pytest 退出码。
对旧策略红证据:用合成记录隔离执行现有“整类 skip/正文子串/UNKNOWN 安静”判据,目标测试要求 FAIL/UNCOVERED,确认语义不符;恢复新纯函数后通过。新文件缺失造成 import error 不计红。
**验证**`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py -q`。安全测试使用假的唯一 sentinel 凭据/私有提示词,逐文件检查不出现 sentinel,不能拿真实密钥做输出搜索。
- [x] 实现及离线证据完成:窄分类/hooks/独立身份/逐轮安全报告;与 T6 合并提交。
### T6:迁移四个 live 文件并离线化装配断言
**文件**:四个 `tests/e2e/test_*.py` 路径见 §2`tests/e2e/conftest.py``tests/unit/test_live_evidence.py``tests/unit/test_client.py``tests/unit/test_config.py`
| 原接缝 | 改动与离线验收 |
| --- | --- |
| smokecompat chat | 每轮 session_idparent_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”;timeouttrust_env 取已校验源配置,不用 30s 硬编码压紧生产预算 |
| L1L9 | 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 的头/timeouttrust_env 离线守卫由 T3 **新增** `TestDefaultClientFactory`,T6 验证时一并运行,不宣称旧源码已有覆盖。各能力轮次用 §3.3 的 `raw_identity(session_id=..., parent_call_id=..., call_id=resp.call_id)` 给身份函数提供独立快照,缺失来源不得猜测。live 无取证通道的失败按 FAIL,不为凑分类额外开生产接口。
**验证**`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py::TestDefaultClientFactory -q``conda run -n PolyGateway pytest tests/e2e/ -m slow --collect-only -q`(只采集,不视作真实通过)。在离线注入旧整类 skip、丢第一轮、UNKNOWN→PASS、identity 丢失→skip 变异,分别红;新实现恢复绿。
- [x] 实现及日常离线验证/90节点collect-only完成;未执行live,不代表能力覆盖通过。
### T7:无推理路径真链路与四类变异
**文件**`tests/unit/test_embedding.py``tests/unit/test_ocr_client.py``tests/unit/test_telemetry.py``tests/unit/test_monkey_ocr.py`;生产不改。
复用 `_embed_client``_client`/脚本 transport/内存 recorder,参数化 embed、recognize_text、parse_layout 与源 TrueHIGH,两次尝试(Transient→成功)必须恰有 2 行、一错一成、所有 reasoning_effort None。再覆盖 RequestRejected 一行和耗尽非零失败行;不要求新增不存在的逻辑终态。SQL 锚点用真实 `SQLiteRecorder(tmp_path / "reasonless.sqlite", auto_migrate=True)`,三入口分别走 client→emitter→SQLite,查询总数/失败数/NULL 数,finally 同步 close 注入 recorder。
chat 阳性走真实 RetryMWemitterTrue 糖失败 auto、显式请求失败保留意图、nearest 成功为实际映射档。四行遥测继续沿 T4 口径。共享 recorder 并发用测试 sessionparent 配对,attempt call_id 唯一且集合不相交。embedding 默认 transport 和 MonkeyOCR textlayout 真实 MockTransport 回包验证 wire 无推理键,不仅断言 emitter 的 False 实参。
| 隔离变异 | 必须被哪些断言杀死 |
| --- | --- |
| `embedding.py::_emit` False→True | 误配 TrueHIGH 的失败尝试 NULL 断言 |
| `ocr.py::_emit` False→True | text 和 layout 各一个独立节点均因错误行非 NULL 红 |
| `middleware/retry.py::_emit` True→False | chat 阳性实际档/请求档断言,不是签名 TypeError |
| `middleware/telemetry.py::_attempt_effort` 去掉 applies 短路 | 无推理真实失败行断言;确认不是全空数据或假 recorder |
工作方法:以 T7 当前提交建仓库外临时副本(仅 src/tests/必要工程文件,不复制 `.env`reference.pi),用 `PYTHONPATH=<副本>/src` 和副本 cwd 执行 conda pytest;先检查 `polygateway.__file__` 指向副本。逐个变异、跑指定节点记录 exit 1 和目标断言、恢复文件校验散列,再跑 exit 0。绝不在主工作区改 False 假装先红。
**验证**`conda run -n PolyGateway pytest tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_telemetry.py tests/unit/test_monkey_ocr.py tests/unit/test_retry.py -q`,再执行上表隔离变异;原实现绿、四类有效红、还原绿。
- [x] 提交点:`test: guard reasoning-free telemetry through real client paths`
### T8:文档、日志登记与独立验证
**文件**:§2 列出的用户文档/架构/旧设计/schema/metric/知识索引,以及验收 finding;不新增运行时字段。
同步设计 M1–M9 到 README 可执行迁移节与 `.env.example` 注释,保留型号证据来源;CHANGELOG 未发布段点名 AUTO 新拒绝、未知尽力、raw 同值拒绝、显式缓存身份迁移、UNKNOWN/SKIP 限制。schema 既有 reasoning_effort “实际发出”总括修成四种行来源,不修改 DDL;metric 复用既有 call-telemetry-coverage,记录三个无推理入口错误行 NULL/chat 阳性为 100% 契约,真实覆盖基线留待首次实际运行,不能填伪百分比。
由父会话前台派全新 verifier:只给批准设计、计划、分支 diff、验证命令,不给实现自评。至少覆盖正确性/回归和测试归因/范围两个角度;Critical/Important 清零。审查先核对实际路径与仓库语言,不接受不存在文件的结果。补丁回到单 writer,重跑受影响红绿及静态门。
| 检查 | 命令/证据要求 |
| --- | --- |
| 静态与边界 | `make check``git diff --check``conda run -n PolyGateway python -m compileall -q src/polygateway tests/live_evidence.py tests/e2e/conftest.py` |
| 日常全量 | `make test`,保存真实退出码/coverage ≥80%,不能只运行改动文件;连接依赖 skip 单列 |
| LSP | 若会话已有 LSP diagnostics 工具,对四个生产变更文件和新增测试支持文件取诊断;本轮检查 conda 内 pyrightbasedpyright 均未安装且工程无其配置,不安装新依赖或虚报 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`,不可用明确记未执行,ruffimport-lintercompileall 是实际既有静态门,不冒称等价 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`,命令 stdoutstderr 重定向到 `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`;查看 Releaseregistry 包页面正文、仓库链接、下载产物,逐项记录 URL 与实际结果 |
测试不在 wheel 内,下载后以无网络小调用核对已安装 `resolve_thinking` 和冲突守卫;不要从工作树 src import 后宣称发布包通过。只在外部结果确认后评论/关闭 #21/#25/#26,正文引用各自验证与迁移边界,不能称所有渠道故障已自动识别。若上传成功但页面/下载校验失败,记录部分发布状态,不重发同版本不同字节。
- [ ] 提交/发布点:main 的发布提交与 `v1.3.4` 对齐;Release 与 registry 外部验证全部成立。
## 5. 阻塞矩阵:哪些可以执行,哪些不能冒充通过
| 缺口 | 本库可完成 | 不可自行宣称/处置 |
| --- | --- | --- |
| 下游工作区缺失 | 本库完整合成 env、runtime Protocol、M1M9 与显式缓存迁移回归 | 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.14.2 AUTO 与 MiniMax、未知尽力 | T1T3 双入口,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 四种行口径与日志 | T1T4T7T8;既有 schemaemitter,不新增数据面 |
| §9/10 文档、下游与发布门 | T8/T9及阻塞矩阵;外部结果与测试缺口不冒充通过 |
自审已核对:生产守卫所有消费者在 §3 定义;新增测试文件有确定路径;conftest 当前不存在故明确新建;默认工厂不支持 transport 注入故使用已批准全量注入而非偷扩 API;缓存不改指纹;无从公共 model_reported 倒推上游身份;所有命令均在 conda 环境;未执行的测试不写为已通过。
计划审查由父会话组织,完成后直接实施,不新增人类计划审批门。执行中本文件任务勾选与 finding 保持实际状态一致;本次计划编写未运行 pytest、变异或真实模型调用。
## 本轮实施证据
T0–T4/T7 的命令、实际失败与修复、11 个隔离变异及 1241 项单测通过,见 `findings/2026-09-09-134-thinking-contracts-validation.md`。T5/T6 已续作:1357单元通过、8个隔离假绿变异exit1/还原0、e2e 90节点仅采集;T8文档同步完成。未执行live、集成、独立verifier和发布。具体节点及残余见同一finding续作节。
### T5/T6续作决策记录
父会话确认无已批准型号→400机器type白名单:不编造,缺机器证据400默认FAIL;精确预期拒绝契约离线守卫,具体live负向缺基线记录未验证。不可关闭命题完整合格轮次有OBSERVED支持本条件下未关闭,全ABSENT证伪,无OBSERVED但UNKNOWN未覆盖。T8复选框保持未勾选,因为独立verifier与全量/live证据门未执行;本轮仅其文档同步部分完成,禁止发布。
T5/T6实现提交:`73008ad`。最终日常单元1357、受影响含factory331、make check、compileall、e2e collect-only90通过;完整T8/T9仍未执行。日志路径及8项红→还原绿详见同一finding。
### 独立审查修复续作(d332287 后)
已按 receiving-code-review 核验四项并仅修改测试及本计划/finding:结构化重问采用先验前缀/反馈角色与预算+委托摘要的 wire 保真;未登记候选先资格再观测未覆盖;同一用例 run/model 关联所有子运行并保留计划/完成分母;落盘机器字段只准 model_not_foundomitted。没有生产/版本修改、slow执行或额外调用预算。
新增18个离线节点,四项及首轮精确消息守卫均有目标断言先红→绿。最终129项取证单测、1375全单元、make check及e2e collect-only90通过;命令日志/退出码详见既有finding“独立审查四项修复”。修复后独立复审、集成与live尚未完成,**T8/T9仍不勾选,不放行发布**。原结构化重问“保守FAIL”说明已标为被本次修复替代,不能再当成可接受限制。
+9 -5
View File
@@ -7,7 +7,6 @@ date: 2026-07-20
# 表结构: llm_calls(遥测 26 字段) # 表结构: llm_calls(遥测 26 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
| 列 | 类型 | 说明 | | 列 | 类型 | 说明 |
@@ -31,7 +30,7 @@ date: 2026-07-20
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 | | tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 |
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB | | 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`。见下方口径 | | 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/成本口径(2026-07-30,est_tokens 解耦)
@@ -107,7 +106,7 @@ ORDER BY model, calls DESC;
## 推理档位口径(2026-09-04,issue #20) ## 推理档位口径(2026-09-04,issue #20)
`reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。 `reasoning_effort` 的来源取决于行类型,不能总括为「实际发出」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
三个 emit 入口的取值同样各自定死,与 `sampling` 同构: 三个 emit 入口的取值同样各自定死,与 `sampling` 同构:
@@ -115,9 +114,10 @@ ORDER BY model, calls DESC;
| --- | --- | --- | | --- | --- | --- |
| `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** | | `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** |
| `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** | | `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`,记一个档也是记录一个从未发出的参数。 OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。
@@ -142,3 +142,7 @@ ORDER BY model, calls DESC;
## 评估基线 ## 评估基线
首版无历史基线,标"待首次运行后建立";验收断言: 单测覆盖成功/失败/缓存命中/取消四路径各产生恰一行;并发 50 协程写全落库。 首版无历史基线,标"待首次运行后建立";验收断言: 单测覆盖成功/失败/缓存命中/取消四路径各产生恰一行;并发 50 协程写全落库。
## 1.3.4 测试侧证据(不新增 schema)
`tests/live_evidence.py`e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。
+1 -1
View File
@@ -50,7 +50,7 @@ from polygateway.types import (
ThinkingObservation, ThinkingObservation,
) )
__version__ = "1.3.3" __version__ = "1.3.4"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
+19 -8
View File
@@ -34,7 +34,12 @@ from polygateway.sources import (
RoundRobinSelector, RoundRobinSelector,
SourceCooldownMemo, 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.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ( from polygateway.types import (
ChatRequest, ChatRequest,
@@ -82,21 +87,26 @@ def _guard_thinking(
就带着指路信息炸掉`get_provider` 现在就是同一形态的双点调用 就带着指路信息炸掉`get_provider` 现在就是同一形态的双点调用
""" """
for source, profile in zip(sources, profiles, strict=True): for source, profile in zip(sources, profiles, strict=True):
resolve_thinking( effort = effective_effort(
profile,
get_capability(source.model, table=capabilities),
# 装配期看不见请求级档位(它逐次调用才产生),故只解源级两层;请求级
# 只能在运行期由 transport 校验(设计 §10 的装配期/运行期分工)
effective_effort(
request_effort=None, request_effort=None,
source_effort=source.reasoning_effort, source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking, enable_thinking=source.enable_thinking,
), )
resolve_thinking(
profile,
get_capability(source.model, table=capabilities),
effort,
model=source.model, model=source.model,
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被 # 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
# 判死,而它在运行期本来是能映射到最近档跑起来的 # 判死,而它在运行期本来是能映射到最近档跑起来的
fallback=source.effort_fallback, fallback=source.effort_fallback,
) )
validate_thinking_raw(
source.extra_body,
effort=effort,
wire=profile.thinking,
origin=f"{source.name} extra_body",
)
def _fingerprint_mark(source: SourceConfig) -> str: def _fingerprint_mark(source: SourceConfig) -> str:
@@ -359,6 +369,7 @@ class GatewayClient:
if reasoning_effort is None if reasoning_effort is None
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)") else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
) )
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
request = ChatRequest( request = ChatRequest(
messages=messages, messages=messages,
session_id=session_id, session_id=session_id,
+4 -14
View File
@@ -29,9 +29,8 @@ class ThinkingWire:
``=None`` ``thinking_budget`` 调深度,不是档位) ``=None`` ``thinking_budget`` 调深度,不是档位)
============== ========================================================== ============== ==========================================================
`on_base={}` `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于 `on_base={}` `on_base=None` 不可混: 前者是协议无需额外开启字节
开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加), 是否满足 AUTO 由模型能力清单决定后者是不知道怎么表达
后者是"不知道怎么表达"
**为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat / **为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat /
openai-responses / anthropic-messages / google-generate-content 四种端点协议, 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 复测结论不变。 # 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。
# enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线 # enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线
# 194),reasoning_effort 才是真开关——本段形态据此成立。 # 194),reasoning_effort 才是真开关——本段形态据此成立。
# `on_base={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段 # 开启片段不代选强度;AUTO 可满足性由具体模型能力清单决定。
# 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。
# 之所以接受: 本次一度改成 `on_base={}`("开"不需要任何参数),该形态依赖
# "模型默认就推理"这个前提,而 T10 真实网关实测推翻了它——MiniMax-M3 不发任何
# 推理参数时 5/5 轮不推理(六个强度值 minimal..max 则全部生效且彼此等价)。
# 于是存量配 ENABLE_THINKING=true 的下游会从"真开推理"静默变成"不推理"。
# 取 medium 是为逐字恢复旧版的 thinking_on,与存量行为一致;M3 六档等价,
# 故选哪档对效果无差别。
# 正解是让 `auto` 受能力表约束(模型不支持"由模型自定"时报错并指路显式档位),
# 属公共行为变更,已记入 gitea issue #21 待下一版处理。
"minimax": ProviderProfile( "minimax": ProviderProfile(
name="minimax", name="minimax",
thinking=ThinkingWire( thinking=ThinkingWire(
off={"reasoning_effort": "none"}, off={"reasoning_effort": "none"},
on_base={"reasoning_effort": "medium"}, on_base={},
effort_key="reasoning_effort", effort_key="reasoning_effort",
), ),
strip_think_tags=False, strip_think_tags=False,
+80 -12
View File
@@ -441,6 +441,62 @@ def effective_effort(
return Effort.AUTO if enable_thinking else Effort.NONE return Effort.AUTO if enable_thinking else Effort.NONE
_RAW_THINKING_ROOTS = frozenset(
{
"reasoning_effort",
"enable_thinking",
"thinking",
"thinking_budget",
"reasoning",
"thinkingConfig",
}
)
def _has_output_effort(raw: Mapping[str, Any]) -> bool:
"""标准嵌套强度只检查明确的路径,不猜私有方言。"""
output = raw.get("output_config")
return isinstance(output, Mapping) and "effort" in output
def validate_thinking_wire(wire: ThinkingWire, *, model: str) -> None:
"""拒绝开启片段代选强度,未知形态仍交由请求方向检查。"""
base = wire.on_base
if base is not None and (
"reasoning_effort" in base
or (wire.effort_key is not None and wire.effort_key in base)
or _has_output_effort(base)
):
raise ThinkingUnsupportedError(
f"模型 {model!r} 的 on_base 不得包含强度档位;请移除强度并显式传 reasoning_effort"
)
def validate_thinking_raw(
raw: Mapping[str, Any],
*,
effort: Effort | None,
wire: ThinkingWire | None,
origin: str,
) -> None:
"""受管推理只有一个来源;同值或被遮蔽的 raw 控制也拒绝。"""
if effort is None:
return
roots = set(_RAW_THINKING_ROOTS)
if wire is not None:
for fragment in (wire.on_base, wire.off):
if fragment is not None:
roots.update(fragment)
if wire.effort_key is not None:
roots.add(wire.effort_key)
if roots.intersection(raw) or _has_output_effort(raw):
# 不打印 raw 或自定义键名,防配置中夹带秘密。
raise ThinkingUnsupportedError(
f"{origin} 与受管 reasoning_effort={effort.value!r} 冲突;"
"请删除 raw 推理控制,或移除源级/请求级推理表态后仅用 raw"
)
def resolve_thinking( def resolve_thinking(
profile: ProviderProfile, profile: ProviderProfile,
capability: ThinkingCapability | None, capability: ThinkingCapability | None,
@@ -471,11 +527,8 @@ def resolve_thinking(
信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是 信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是
issue #20 的成因。 issue #20 的成因。
**`auto` 不受档位清单约束**: 它表达的是"开启,但不指定强度",在请求体里就是 **已登记的 AUTO 同样受清单约束**: 开启形态不证明模型支持不指定强度
"不写 `effort_key`",而不是写进 `effort_key` 的某个取值, Phase 5 放行它 AUTO 不在强弱轴上不允许 nearest 静默代选付费档位未知模型仍尽力并告警
反过来判会让存量的 `ENABLE_THINKING=true`(T5 起等价于 `auto`) deepseek-v4
glm-5.3 这类清单里没有 `auto` 的模型上当场报错,而设计 §12 明确承诺存量
配置继续可跑那里唯一允许新报错的是"关闭一个官方不可关的模型"
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性, `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,
`capability` None(未登记)时无从从别处取得模型名 `capability` None(未登记)时无从从别处取得模型名
@@ -493,6 +546,7 @@ def resolve_thinking(
而是**静默判否**: Phase 2 按开启方向取字段Phase 4 整条被绕过,最后在拼错误 而是**静默判否**: Phase 2 按开启方向取字段Phase 4 整条被绕过,最后在拼错误
文案时才以 `AttributeError` 现形(一个未文档化也不属四分类的异常) 文案时才以 `AttributeError` 现形(一个未文档化也不属四分类的异常)
""" """
validate_thinking_wire(profile.thinking, model=model)
# Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举 # Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举
if effort is not None: if effort is not None:
effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})") effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})")
@@ -517,7 +571,7 @@ def resolve_thinking(
# 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由) # 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由)
if effort is Effort.NONE and not capability.can_disable: if effort is Effort.NONE and not capability.can_disable:
raise ThinkingUnsupportedError(_cannot_disable(model, capability)) raise ThinkingUnsupportedError(_cannot_disable(model, capability))
# Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring) # Phase 5: 已登记选择必须可满足;AUTO 不允许按强度距离映射
applied = _settle_tier(effort, capability, model=model, fallback=fallback) applied = _settle_tier(effort, capability, model=model, fallback=fallback)
return ThinkingResolution(_inject(profile, applied, model=model), applied) return ThinkingResolution(_inject(profile, applied, model=model), applied)
@@ -544,12 +598,15 @@ def _settle_tier(
) -> Effort: ) -> Effort:
"""Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。 """Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。
`auto` 直接放行: 它不是写进 `effort_key` 的取值,而是"不写 effort_key" AUTO 与强度档统一检查成员但不参与最近强度映射
(理由见 `resolve_thinking` docstring)
""" """
if effort is Effort.AUTO or effort in capability.supported_efforts: if effort in capability.supported_efforts:
return effort 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: if mapped is None:
raise ThinkingUnsupportedError( raise ThinkingUnsupportedError(
_tier_unsupported(model, effort, capability, fallback=fallback) _tier_unsupported(model, effort, capability, fallback=fallback)
@@ -634,7 +691,18 @@ def _tier_unsupported(
else f"该模型只有开关、没有强度档位,可用: {listed}" else f"该模型只有开关、没有强度档位,可用: {listed}"
) )
# 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话 # 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话
hint = "" if fallback == "nearest" else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest" hint = (
""
if fallback == "nearest" or effort is Effort.AUTO
else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest"
)
if effort is Effort.AUTO:
example = capability.cheapest_effort or Effort.NONE
hint = (
";请显式选择清单中的档位,例如 "
f"{{SCOPE}}__{{PROVIDER}}__{{N}}__REASONING_EFFORT={example.value}"
f"或调用时传 reasoning_effort=Effort.{example.name};库不会自动应用此选择"
)
return f"{head}{body}{hint}" return f"{head}{body}{hint}"
@@ -676,7 +744,7 @@ def _warn_unregistered(
) -> None: ) -> None:
logger.warning( logger.warning(
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});" "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});"
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", "不保证开启、关闭或强度生效。实测后请用 register_capability 登记",
model, model,
profile.name, profile.name,
dict(payload), dict(payload),
+9 -5
View File
@@ -34,6 +34,7 @@ from polygateway.thinking import (
observe_thinking, observe_thinking,
reconcile_thinking, reconcile_thinking,
resolve_thinking, resolve_thinking,
validate_thinking_raw,
) )
from polygateway.transports._http_errors import compose_message, summarize_body from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.types import ( from polygateway.types import (
@@ -371,20 +372,23 @@ class OpenAICompatTransport:
self._warned_models.add(source.model) self._warned_models.add(source.model)
# 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。 # 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。
# 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错" # 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错"
resolution = resolve_thinking( effort = effective_effort(
profile,
capability,
effective_effort(
request_effort=reasoning_effort, request_effort=reasoning_effort,
source_effort=source.reasoning_effort, source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking, enable_thinking=source.enable_thinking,
), )
resolution = resolve_thinking(
profile,
capability,
effort,
model=source.model, model=source.model,
# 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确 # 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确
# 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出) # 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出)
fallback=source.effort_fallback, fallback=source.effort_fallback,
warn_unregistered=first_time, 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) payload.update(resolution.payload)
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级 # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
# overlay(含结构化注入)在后覆盖之。两行不可调换 # overlay(含结构化注入)在后覆盖之。两行不可调换
+540
View File
@@ -0,0 +1,540 @@
"""测试侧独立 HTTP 取证装配;无环境自读取或成功 SSE 预读。"""
from collections.abc import AsyncIterator, Iterator, Mapping
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from uuid import uuid4
import httpx
import pytest
from polygateway import GatewayClient, GatewaySettings
from polygateway.client import (
_aclose_component,
_build_breaker,
_build_limiter,
_build_selector,
_build_structured,
)
from polygateway.providers import get_provider
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult
from tests.live_evidence import (
AttemptEvidence,
HttpEvidence,
LiveVerdict,
assess_model_identity,
classify_live_failure,
messages_digest,
request_is_valid,
safe_attempts,
strict_json,
write_live_round,
)
@dataclass
class _Exchange:
"""仅在 attempt 生命周期持有原始响应引用。"""
request: httpx.Request
checks: tuple[tuple[str, bool], ...]
response: httpx.Response | None = None
@dataclass
class _Attempt:
"""任务内可变收集器,结束时转换为冻结快照。"""
call_id: str
exchanges: list[_Exchange] = field(default_factory=list)
messages_digest: str | None = None
messages_valid: bool = False
class LiveCapture:
"""矩阵显式预期与按逻辑轮次关联的独立证据。"""
def __init__(self, *, expectations: Mapping[str, Mapping[str, Any]]) -> None:
"""预期缺项即配置错误,不从实发 payload 补齐。"""
for expected in expectations.values():
required = {"model", "origin", "path", "control", "messages_digest"}
if not required <= expected.keys() or ("stream" in expected) == (
"input_shape" in expected
):
raise ValueError("取证矩阵缺少必需预期或混用 chat/embed")
if not isinstance(expected["control"], dict):
raise ValueError("control 必须是显式对象")
if "structured_max_retries" in expected and (
"stream" not in expected
or type(expected["structured_max_retries"]) is not int
or expected["structured_max_retries"] < 0
or type(expected.get("messages_prefix_length")) is not int
or expected["messages_prefix_length"] < 1
):
raise ValueError("结构化预期缺少合法前缀长度或重问预算")
self._expectations = {name: dict(value) for name, value in expectations.items()}
self._round: ContextVar[tuple[str, str]] = ContextVar("live_round")
self._attempt: ContextVar[_Attempt] = ContextVar("live_attempt")
self._records: dict[tuple[str, str], list[AttemptEvidence]] = {}
self._owners: dict[str, tuple[str, str]] = {}
self._notes: dict[tuple[str, str], list[str]] = {}
@contextmanager
def round_context(self, *, session_id: str, parent_call_id: str) -> Iterator[None]:
"""外围逻辑轮次绑定,异常和取消均复位。"""
key = session_id, parent_call_id
token = self._round.set(key)
self._records.setdefault(key, [])
self._notes.setdefault(key, [])
try:
yield
finally:
self._round.reset(token)
@contextmanager
def attempt_context(self, call_id: str) -> Iterator[None]:
"""零 HTTP 尝试也有快照;重复/跨轮 UUID 是契约错误。"""
key = self._round.get()
if call_id in self._owners:
raise ValueError("重复或跨轮 call_id")
self._owners[call_id] = key
attempt = _Attempt(call_id)
token = self._attempt.set(attempt)
error = None
try:
yield
except Exception as exc:
error = exc
raise
finally:
try:
events = tuple(
self._snapshot(exchange, call_id, key) for exchange in attempt.exchanges
)
self._records[key].append(AttemptEvidence(call_id, events, error))
finally:
self._attempt.reset(token)
def _snapshot(self, exchange: _Exchange, call_id: str, key: tuple[str, str]) -> HttpEvidence:
"""complete 结束后只读已缓冲内容,拒绝截断和歧义身份。"""
response = exchange.response
identity: tuple[bool, str | None] = (False, None)
body = None
status = response.status_code if response is not None else 0
if response is None:
self._notes[key].append("无可配对响应")
else:
try:
content = response.content
except httpx.ResponseNotRead:
self._notes[key].append("响应未缓冲,证据不足")
else:
if status >= 400:
if len(content) <= 65536:
body = content
else:
self._notes[key].append("错误体超过 64 KiB,不接受截断证据")
elif 200 <= status < 300 and dict(exchange.checks).get("stream") is not None:
try:
payload = strict_json(exchange.request.content)
except (ValueError, UnicodeError):
payload = {}
if isinstance(payload, dict) and payload.get("stream") is False:
try:
data = strict_json(content)
if not isinstance(data, dict):
raise ValueError("原始 JSON 非对象")
model = data.get("model")
if model is not None and not isinstance(model, str):
raise ValueError("原始 model 类型非法")
identity = (True, model)
except (ValueError, UnicodeError):
self._notes[key].append("原始 JSON 身份无法独立解析")
return HttpEvidence(call_id, exchange.checks, status, body, identity)
def observe_messages(self, source: SourceConfig, messages: list[dict[str, Any]]) -> None:
"""先验前缀/反馈契约与委托摘要分开;摘要仅验证 HTTP 序列化保真。"""
expected = self._expectations[source.name]
if "structured_max_retries" not in expected:
return
attempt = self._attempt.get()
prefix_length = expected["messages_prefix_length"]
feedback = messages[prefix_length:]
attempt.messages_digest = messages_digest(messages)
attempt.messages_valid = (
(not feedback or bool(self._records[self._round.get()]))
and messages_digest(messages[:prefix_length]) == expected["messages_digest"]
and len(feedback) % 2 == 0
and len(feedback) <= 2 * expected["structured_max_retries"]
and all(
isinstance(message, dict)
and set(message) == {"role", "content"}
and message["role"] == ("assistant" if index % 2 == 0 else "user")
and isinstance(message["content"], str)
for index, message in enumerate(feedback)
)
)
def client_factory(self, source: SourceConfig) -> httpx.AsyncClient:
"""鉴权仅内存比较;沿已校验源 timeout/trust_env。"""
expected = self._expectations[source.name]
async def request_hook(request: httpx.Request) -> None:
"""校验实发请求而不修正它。"""
attempt = self._attempt.get()
try:
payload = strict_json(request.content)
except (ValueError, UnicodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
url = request.url
origin = str(
url.copy_with(path="", query=None, fragment=None, username=None, password=None)
).rstrip("/")
# control 是本轮完整附加字段;基础键以外均比较,漏/多键都失败。
basic = {"model", "messages", "stream", "stream_options", "input"}
control = {k: v for k, v in payload.items() if k not in basic}
checks = {
"method": request.method == "POST",
"origin": origin == expected["origin"]
and not url.username
and not url.password
and not url.query,
"path": url.path == expected["path"],
"model": payload.get("model") == expected["model"],
"authorization": request.headers.get("Authorization") == f"Bearer {source.api_key}",
"control": control == expected["control"],
"messages_digest": (
attempt.messages_valid
and messages_digest(payload.get("messages")) == attempt.messages_digest
if "structured_max_retries" in expected
else messages_digest(payload.get("messages", payload.get("input")))
== expected["messages_digest"]
),
}
if "stream" in expected:
checks["stream"] = payload.get("stream") is expected["stream"] and (
payload.get("stream_options") == {"include_usage": True}
if expected["stream"]
else "stream_options" not in payload
)
else:
texts = payload.get("input")
checks["input_shape"] = (
isinstance(texts, list)
and all(isinstance(text, str) for text in texts)
and len(texts) == expected["input_shape"]
)
attempt.exchanges.append(_Exchange(request, tuple(checks.items())))
async def response_hook(response: httpx.Response) -> None:
"""只持有引用,绝不提前读取成功 SSE。"""
attempt = self._attempt.get()
matching = [event for event in attempt.exchanges if event.request is response.request]
if len(matching) != 1 or matching[0].response is not None:
raise ValueError("响应无法唯一配对")
matching[0].response = response
return httpx.AsyncClient(
headers={"Authorization": f"Bearer {source.api_key}"},
timeout=source.timeout_s,
trust_env=source.trust_env,
event_hooks={"request": [request_hook], "response": [response_hook]},
)
def attempts(self, *, session_id: str, parent_call_id: str) -> tuple[AttemptEvidence, ...]:
"""按逻辑轮次返回不可变快照。"""
return tuple(self._records.get((session_id, parent_call_id), ()))
def notes(self, *, session_id: str, parent_call_id: str) -> tuple[str, ...]:
"""只含固定安全原因,不包含响应正文。"""
return tuple(self._notes.get((session_id, parent_call_id), ()))
def raw_identity(
self, *, session_id: str, parent_call_id: str, call_id: str
) -> tuple[bool, str | None]:
"""精确取最终成功 attempt,不猜本轮最后一条响应。"""
key = session_id, parent_call_id
if call_id in self._owners and self._owners[call_id] != key:
raise ValueError("跨轮 call_id 身份查询")
matches = [attempt for attempt in self._records.get(key, []) if attempt.call_id == call_id]
if len(matches) > 1:
raise ValueError("重复 call_id 身份查询")
if not matches:
return False, None
events = [event for event in matches[0].http if 200 <= event.status_code < 300]
if len(events) > 1:
raise ValueError("多个成功 HTTP 身份候选")
return events[0].raw_identity if events else (False, None)
class ObservedTransport:
"""原样委托同一个真实 transport,无重试、payload 修正或异常翻译。"""
def __init__(self, transport: OpenAICompatTransport, capture: LiveCapture) -> None:
"""资源所有权留给装配者。"""
self._transport = transport
self._capture = capture
async def complete(
self,
*,
messages: list[dict[str, Any]],
source: SourceConfig,
stream: bool,
overlay: dict[str, Any],
call_id: str,
reasoning_effort: Effort | None,
) -> TransportResult:
"""与生产端口逐参数同签名。"""
with self._capture.attempt_context(call_id):
self._capture.observe_messages(source, messages)
return await self._transport.complete(
messages=messages,
source=source,
stream=stream,
overlay=overlay,
call_id=call_id,
reasoning_effort=reasoning_effort,
)
async def embed(
self, *, texts: list[str], source: SourceConfig, call_id: str
) -> EmbeddingTransportResult:
"""embedding 使用同一取证关联,不套 chat 推理判据。"""
with self._capture.attempt_context(call_id):
return await self._transport.embed(texts=texts, source=source, call_id=call_id)
@asynccontextmanager
async def observed_client(
settings: GatewaySettings, capture: LiveCapture, *, capabilities=None
) -> AsyncIterator[GatewayClient]:
"""全量注入复用生产装配函数;自建组件显式关闭,不启用响应缓存。"""
async with AsyncExitStack() as stack:
sources = list(settings.sources)
limiter = _build_limiter(settings, sources)
stack.push_async_callback(_aclose_component, limiter)
breaker = _build_breaker(settings)
stack.push_async_callback(_aclose_component, breaker)
real = OpenAICompatTransport(
client_factory=capture.client_factory, capabilities=capabilities
)
stack.push_async_callback(real.aclose)
strategy, escalation = _build_structured(
[get_provider(source.provider) for source in sources]
)
client = GatewayClient(
scope=settings.scope,
sources=sources,
selector=_build_selector(settings.selector),
limiter=limiter,
breaker=breaker,
transport=ObservedTransport(real, capture),
retry=settings.retry,
backpressure=settings.backpressure,
quota_full=settings.quota_full,
circuit_open=settings.circuit_open,
structured_strategy=strategy,
structured_escalation=escalation,
structured_max_retries=settings.structured_max_retries,
)
stack.push_async_callback(client.aclose)
yield client
def chat_expectations(
settings: GatewaySettings,
*,
messages: list[dict[str, Any]],
stream: bool,
controls: Mapping[str, dict[str, Any]],
structured_max_retries: int | None = None,
) -> dict[str, dict[str, Any]]:
"""URL 从源配置声明,控制片段必须由矩阵独立给出。"""
result = {}
for source in settings.sources:
url = httpx.URL(source.base_url)
result[source.name] = {
"model": source.model,
"origin": str(
url.copy_with(path="", query=None, fragment=None, username=None, password=None)
).rstrip("/"),
"path": url.path.rstrip("/") + "/chat/completions",
"stream": stream,
"control": controls[source.name],
"messages_digest": messages_digest(messages),
}
if structured_max_retries is not None:
result[source.name].update(
messages_prefix_length=len(messages), structured_max_retries=structured_max_retries
)
return result
def enforce_verdict(verdict: LiveVerdict) -> None:
"""仅在报告已写入后调用;默认失败,不打印上游异常正文。"""
if verdict.status == "UNCOVERED":
pytest.skip(verdict.reason)
assert verdict.status == "PASS", verdict.reason
async def captured_chat_round(
client: GatewayClient,
capture: LiveCapture,
*,
run_id: str,
matrix_id: str,
round_index: int,
output_dir: Path,
messages: list[dict[str, Any]],
models: Mapping[str, str],
aliases: Mapping[str, frozenset[str]],
validate=None,
providers: Mapping[str, str] | None = None,
source_efforts: Mapping[str, Effort | None] | None = None,
**kwargs: Any,
) -> tuple[Any, LiveVerdict]:
"""请求、身份与行为断言均先记逐轮证据;异常不漏轮。"""
parent = uuid4().hex
response = None
error = None
verdict = LiveVerdict("FAIL", "轮次未完成")
with capture.round_context(session_id=run_id, parent_call_id=parent):
try:
response = await client.chat(
messages, session_id=run_id, parent_call_id=parent, **kwargs
)
attempts = capture.attempts(session_id=run_id, parent_call_id=parent)
events = [event for attempt in attempts for event in attempt.http]
successful = [attempt for attempt in attempts if attempt.call_id == response.call_id]
success_paired = (
len(successful) == 1
and successful[0].error is None
and len(successful[0].http) == 1
and 200 <= successful[0].http[0].status_code < 300
)
verdict = assess_model_identity(
requested=models[response.source_name],
aliases=aliases.get(models[response.source_name], frozenset()),
reported=response.model_reported,
raw_identity=capture.raw_identity(
session_id=run_id, parent_call_id=parent, call_id=response.call_id
),
request_valid=success_paired
and bool(events)
and all(request_is_valid(event) for event in events),
)
if verdict.status == "PASS" and validate is not None:
validate(response)
except Exception as exc:
error = exc
verdict = classify_live_failure(
exc, capture.attempts(session_id=run_id, parent_call_id=parent)
)
finally:
attempts = capture.attempts(session_id=run_id, parent_call_id=parent)
# 上游 model 可能回显提示词;仅输出允许集合内的名字,其他统一省略。
allowed = set(models.values()) | {
alias for values in aliases.values() for alias in values
}
write_live_round(
output_dir,
run_id=run_id,
matrix_id=matrix_id,
round_index=round_index,
safe_fields={
"session_id": run_id,
"parent_call_id": parent,
"requested_model": list(models.values()),
"provider": list(providers.values()) if providers is not None else None,
"attempts": safe_attempts(attempts),
"status": verdict.status,
"reason": verdict.reason,
"stream": kwargs.get("stream", True),
"requested_effort": kwargs.get("reasoning_effort")
or (list(source_efforts.values()) if source_efforts is not None else None),
"completed_rounds": 1,
"prompt_tokens": response.prompt_tokens if response else None,
"completion_tokens": response.completion_tokens if response else None,
"reasoning_tokens": response.reasoning_tokens if response else None,
"thinking_chars": len(response.thinking) if response else None,
"evidence_notes": (
"原始异常/正文摘要省略以避免回显泄露",
*capture.notes(session_id=run_id, parent_call_id=parent),
),
"reported_model": response.model_reported
if response and response.model_reported in allowed
else None,
"applied_effort": response.applied_effort if response else None,
"thinking_observation": response.thinking_observation if response else None,
"error_type": type(error).__name__ if error else None,
"error_status": getattr(error, "status_code", None),
},
)
return response, verdict
def declared_control(provider: str, effort: Effort | None) -> dict[str, Any]:
"""测试矩阵的独立 wire 声明;不调用 resolver 或生产 payload 构造器。"""
if effort is None:
return {}
if provider == "qwen":
if effort not in (Effort.AUTO, Effort.NONE):
raise ValueError("测试矩阵未声明 qwen 强度映射")
return {"enable_thinking": effort is not Effort.NONE}
if provider in {"deepseek", "zhipu", "moonshot"}:
result: dict[str, Any] = {
"thinking": {"type": "disabled" if effort is Effort.NONE else "enabled"}
}
if effort not in (Effort.AUTO, Effort.NONE):
result["reasoning_effort"] = effort.value
return result
if provider not in {"minimax", "openai", "anthropic", "google"}:
raise ValueError("测试矩阵没有该 provider 的控制声明")
return {} if effort is Effort.AUTO else {"reasoning_effort": effort.value}
def source_controls(settings: GatewaySettings) -> dict[str, dict[str, Any]]:
"""源级矩阵预期独立表达;受管 raw 冲突由生产路径拒绝。"""
result = {}
for source in settings.sources:
effort = source.reasoning_effort
if effort is None and source.enable_thinking is not None:
effort = Effort.AUTO if source.enable_thinking else Effort.NONE
control = declared_control(source.provider, effort)
if effort is None:
control.update(source.extra_body)
else:
# 不用 update 覆盖控制声明,否则会掩盖所有权回归。
for key, value in source.extra_body.items():
if key in control:
raise ValueError("取证矩阵有双来源控制")
control[key] = value
result[source.name] = control
return result
# pytest 用例终态补充网络前缺配置、装配失败及未完成轮次;不代替逐轮报告。
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
"""仅记录安全矩阵标识和阶段结果,不序列化 pytest 异常长文本。"""
report = yield
if report.skipped or report.failed:
write_live_round(
Path("tests/outputs/134/live"),
run_id=uuid4().hex,
matrix_id="pytest-" + messages_digest(item.nodeid)[:16],
round_index=0,
safe_fields={
"status": "UNCOVERED" if report.skipped else "FAIL",
"reason": "用例阶段未覆盖或失败;详情按逐轮安全证据核验,不能视为能力通过",
"evidence_notes": [report.when],
},
)
return report
+87 -66
View File
@@ -1,98 +1,119 @@
"""GovDoc 与 Video-Tree 最小接入冒烟(2026-07-20 拍板: 两个项目都做)。 """历史接入调用形态的真实冒烟;不能替代缺失下游的现行配置验收。"""
复刻两项目的真实调用点形态,对真实网关跑一次治理调用,证明"调用点零改动
迁移"成立;并验证 VT 现有平铺键名(LLM_TIMEOUT 等)可直接装配。
reference/ 只读本文件只 import Protocol,绝不修改
"""
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
from dotenv import dotenv_values from dotenv import dotenv_values
from polygateway import GatewayClient from polygateway import Effort, GatewayClient, GatewaySettings
from tests.e2e.conftest import (
LiveCapture,
captured_chat_round,
chat_expectations,
enforce_verdict,
observed_client,
source_controls,
)
from tests.live_evidence import write_live_round
_REPO = Path(__file__).resolve().parents[2] _REPO = Path(__file__).resolve().parents[2]
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} _ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV) _HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由是这些用例的成败取决于网关此刻快不快,而
# pre-commit 关卡跑全套件——网关一抖就挡住与之无关的提交,久了会把"测试红了
# 先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉。发版清单负责让它们真跑。
pytestmark = [ pytestmark = [
pytest.mark.slow, pytest.mark.slow,
pytest.mark.skipif( pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*"
),
] ]
_OUT = Path("tests/outputs/134/live")
@pytest.fixture async def _call_shape(matrix, **kwargs):
async def client(): """session/parent 总由逐轮 UUID 传入;保留 cache_salt 调用形态。"""
c = GatewayClient.from_env("LLM", env=_ENV) settings = GatewaySettings.from_env("LLM", env=_ENV)
yield c messages = [{"role": "user", "content": "Reply with exactly: compatibility-ok"}]
await c.aclose() capture = LiveCapture(
expectations=chat_expectations(
settings, messages=messages, stream=True, controls=source_controls(settings)
)
)
def validate(response):
assert response.content.strip() and response.call_id
async with observed_client(settings, capture) as client:
_, verdict = await captured_chat_round(
client,
capture,
run_id=uuid4().hex,
matrix_id=matrix,
round_index=1,
output_dir=_OUT,
messages=messages,
models={s.name: s.model for s in settings.sources},
providers={s.name: s.provider for s in settings.sources},
source_efforts={
s.name: s.reasoning_effort
if s.reasoning_effort is not None
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
if s.enable_thinking is not None
else None
for s in settings.sources
},
aliases={},
validate=validate,
**kwargs,
)
enforce_verdict(verdict)
class TestGovDocOnboarding: class TestGovDocOnboarding:
"""GovDoc agent/loop.py:377 调用形态: session_id + parent_call_id。""" """历史 session_idparent_call_id 调用点契约"""
async def test_call_site_shape_runs_governed(self, client): async def test_call_site_shape_runs_governed(self):
response = await client.chat( await _call_shape("compat-parent")
[{"role": "user", "content": "Reply with exactly: govdoc-ok"}],
session_id="govdoc-e2e",
parent_call_id="step-1",
)
assert response.content.strip()
assert response.call_id # GovernedLLMClient 契约字段全在
async def test_structural_protocol_match(self, client): async def test_structural_protocol_match(self):
"""外部 Protocol 缺包单列未覆盖;合成契约另在 unit 跑。"""
run_id = uuid4().hex
sys.path.insert(0, str(_REPO / "reference/GovDoc-SaaS/packages/docagent-core/src")) sys.path.insert(0, str(_REPO / "reference/GovDoc-SaaS/packages/docagent-core/src"))
try: try:
from docagent_core.protocols import LLMProvider from docagent_core.protocols import LLMProvider
except ImportError: except ImportError:
pytest.skip("GovDoc protocols 依赖不可导入(结构断言已由单测兜底覆盖)") write_live_round(
_OUT,
run_id=run_id,
matrix_id="external-protocol",
round_index=0,
safe_fields={
"status": "UNCOVERED",
"reason": "外部 Protocol 包缺失;未验证真实下游",
},
)
pytest.skip("外部 Protocol 包缺失,未覆盖")
finally: finally:
sys.path.pop(0) sys.path.pop(0)
status = "FAIL"
client = None
try:
client = GatewayClient.from_env("LLM", env=_ENV)
assert isinstance(client, LLMProvider) assert isinstance(client, LLMProvider)
status = "PASS"
finally:
if client is not None:
await client.aclose()
write_live_round(
_OUT,
run_id=run_id,
matrix_id="external-protocol",
round_index=0,
safe_fields={"status": status, "reason": "外部 Protocol 结构契约,不是模型能力"},
)
class TestVideoTreeOnboarding: class TestVideoTreeOnboarding:
"""VT loop.py:336 调用形态: session_id + cache_salt(跨 epoch 重采样)""" """历史 cache_salt 调用点契约,平铺键装配已移至 unit"""
async def test_call_site_shape_with_cache_salt(self, client): async def test_call_site_shape_with_cache_salt(self):
response = await client.chat( await _call_shape("compat-salt", cache_salt="epoch-1")
[{"role": "user", "content": "Reply with exactly: vt-ok"}],
session_id="vt-e2e",
cache_salt="epoch-1",
)
assert response.content.strip()
async def test_flat_legacy_keys_assemble(self):
"""VT 现有键名(LLM_TIMEOUT/LLM_MAX_RETRIES 等)零改名装配成功。"""
source_keys = {k: v for k, v in _ENV.items() if k.split("__")[0] == "LLM" and "__" in k}
flat_env = {
**source_keys,
# 与 .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()
+77 -68
View File
@@ -1,89 +1,98 @@
"""真实网关 /embeddings 端点探测(M2 设计 §11.6;人类默认口径: 实现时探测)。 """真实 embedding 探测;404 仅证明请求型号不可用,不外推端点能力。"""
.env LLM 源网关发一次真实 embeddings 请求: 支持则记录向量证据,
不支持(404/翻译为领域错误) skip 并把响应记录进 tests/outputs/
(降级证据) EMBED scope 配置时复用 LLM 源的 base_url/api_key
"""
from __future__ import annotations
import dataclasses import dataclasses
import os import os
from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import uuid4
import httpx
import pytest import pytest
from dotenv import dotenv_values from dotenv import dotenv_values
from polygateway.errors import PolyGatewayError from polygateway import GatewaySettings
from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import SourceConfig from tests.e2e.conftest import LiveCapture, ObservedTransport, enforce_verdict
from tests.live_evidence import (
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} _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 = [ pytestmark = [
pytest.mark.slow, pytest.mark.slow,
pytest.mark.skipif( pytest.mark.skipif("LLM__MINIMAX__1__BASE_URL" not in _ENV, reason="缺少矩阵必需配置,未覆盖"),
"LLM__MINIMAX__1__BASE_URL" not in _ENV,
reason="缺真实网关配置(.env)",
),
] ]
_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(): async def test_probe_real_gateway_embeddings():
source = SourceConfig( """沿已校验源的 timeout/trust_env,所有路径 finally 关闭。"""
name="probe_1", settings = GatewaySettings.from_env("LLM", env=_ENV)
provider="minimax", configured = next(s for s in settings.sources if s.name == "minimax_1")
base_url=_ENV["LLM__MINIMAX__1__BASE_URL"], source = dataclasses.replace(
api_key=_ENV["LLM__MINIMAX__1__API_KEY"], configured, model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1")
model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1"),
timeout_s=30.0,
est_tokens=8,
) )
transport = OpenAICompatTransport() texts = ["polygateway embedding probe"]
url = httpx.URL(source.base_url)
capture = LiveCapture(
expectations={
source.name: {
"model": source.model,
"origin": str(url.copy_with(path="", query=None)).rstrip("/"),
"path": url.path.rstrip("/") + "/embeddings",
"input_shape": 1,
"control": {},
"messages_digest": messages_digest(texts),
}
}
)
real = OpenAICompatTransport(client_factory=capture.client_factory)
transport = ObservedTransport(real, capture)
run_id, parent, call_id = uuid4().hex, uuid4().hex, uuid4().hex
verdict = LiveVerdict("FAIL", "轮次未完成")
completed_rounds = 0
try: try:
result = await transport.embed( with capture.round_context(session_id=run_id, parent_call_id=parent):
texts=["polygateway embedding probe"], source=source, call_id="probe" try:
) result = await transport.embed(texts=texts, source=source, call_id=call_id)
except PolyGatewayError as exc: events = [
path = _record( e
"probe_unsupported", for a in capture.attempts(session_id=run_id, parent_call_id=parent)
[ for e in a.http
"# Embedding 端点探测: 网关不支持", ]
f"- base_url: {source.base_url}", assert len(events) == 1 and request_is_valid(events[0])
f"- model: {source.model}",
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 assert result.dim > 0 and len(result.vectors) == 1
_record( verdict = LiveVerdict("PASS", "向量形状与实发请求合格")
"probe_supported", completed_rounds = 1
[ except Exception as error:
"# Embedding 端点探测: 网关支持", verdict = classify_live_failure(
f"- base_url: {source.base_url}", error, capture.attempts(session_id=run_id, parent_call_id=parent)
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']}",
],
) )
completed_rounds = 1
finally:
write_live_round(
Path("tests/outputs/134/live"),
run_id=run_id,
matrix_id="embedding",
round_index=1,
safe_fields={
"requested_model": source.model,
"provider": source.provider,
"planned_rounds": 1,
"completed_rounds": completed_rounds,
"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)
+86 -93
View File
@@ -1,123 +1,116 @@
"""真实网关端到端冒烟(M1 验收第 7 步)。 """真实网关冒烟:逐轮独立取证,行为断言失败也必须留档。"""
前置: `.env` 配置至少一个 `LLM__{PROVIDER}__1__*` 真实源 + 韧性键
缺配置时 skip(验收前必须真跑)输出结构化 Markdown
`tests/outputs/e2e/`(CLAUDE.md §4.6,不提交 git)
"""
import json
import os import os
from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
from dotenv import dotenv_values from dotenv import dotenv_values
from pydantic import BaseModel from pydantic import BaseModel
from polygateway import GatewayClient from polygateway import Effort, GatewaySettings
from tests.e2e.conftest import (
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} _ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV) _HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
pytestmark = [ pytestmark = [
pytest.mark.slow, pytest.mark.slow,
pytest.mark.skipif( pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
not _HAS_SOURCE,
reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)",
),
] ]
_OUT_DIR = Path("tests/outputs/e2e")
class MiniAnswer(BaseModel): class MiniAnswer(BaseModel):
"""最小结构化响应契约。"""
answer: int answer: int
reason: str reason: str
def _report(name: str, sections: list[tuple[str, str]]) -> Path: async def _smoke(matrix, prompt, validate, *, stream=True, structured=None):
_OUT_DIR.mkdir(parents=True, exist_ok=True) """全量注入仅替换取证装配,仍调用生产结构化策略。"""
ts = datetime.now().strftime("%Y%m%d_%H%M%S") settings = GatewaySettings.from_env("LLM", env=_ENV)
path = _OUT_DIR / f"{name}_{ts}.md" messages = [{"role": "user", "content": prompt}]
body = [f"# e2e 冒烟: {name}", ""] controls = source_controls(settings)
for title, content in sections: capture = LiveCapture(
body += [f"## {title}", "", "```", content, "```", ""] expectations=chat_expectations(
path.write_text("\n".join(body), encoding="utf-8") settings,
return path messages=messages,
stream=stream,
controls=controls,
@pytest.fixture structured_max_retries=(
async def client(): settings.structured_max_retries if isinstance(structured, type) else None
c = GatewayClient.from_env("LLM", env=_ENV) ),
yield c )
await c.aclose() )
async with observed_client(settings, capture) as client:
_, verdict = await captured_chat_round(
client,
capture,
run_id=uuid4().hex,
matrix_id=matrix,
round_index=1,
output_dir=Path("tests/outputs/134/live"),
messages=messages,
models={s.name: s.model for s in settings.sources},
providers={s.name: s.provider for s in settings.sources},
source_efforts={
s.name: s.reasoning_effort
if s.reasoning_effort is not None
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
if s.enable_thinking is not None
else None
for s in settings.sources
},
aliases={},
validate=validate,
stream=stream,
structured=structured,
)
enforce_verdict(verdict)
class TestRealGatewaySmoke: class TestRealGatewaySmoke:
async def test_stream_chat(self, client): """保留流/非流和结构化真实行为断言。"""
resp = await client.chat(
[{"role": "user", "content": "Reply with exactly: pong"}], session_id="e2e-smoke"
)
path = _report(
"stream_chat",
[
("响应", resp.content),
(
"元数据",
json.dumps(
{
"model": resp.model,
"source": resp.source_name,
"usage_source": resp.usage_source,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"latency_ms": resp.latency_ms,
"ttft_ms": resp.ttft_ms,
},
ensure_ascii=False,
indent=2,
),
),
],
)
assert resp.content.strip()
assert resp.ttft_ms is not None and resp.latency_ms > 0
print(f"输出: {path}")
async def test_non_stream_fast_path(self, client): async def test_stream_chat(self):
resp = await client.chat( def validate(response):
[{"role": "user", "content": "Reply with exactly: pong"}], stream=False assert response.content.strip()
) assert response.ttft_ms is not None and response.latency_ms > 0
_report("non_stream", [("响应", resp.content)])
assert resp.content.strip() and resp.ttft_ms is None
async def test_structured_json_tier(self, client): await _smoke("smoke-stream", "Reply with exactly: pong", validate)
resp = await client.chat(
[{"role": "user", "content": 'Reply ONLY with JSON: {"ok": true}'}], async def test_non_stream_fast_path(self):
def validate(response):
assert response.content.strip() and response.ttft_ms is None
await _smoke("smoke-json", "Reply with exactly: pong", validate, stream=False)
async def test_structured_json_tier(self):
def validate(response):
assert isinstance(response.structured_data, dict | list)
await _smoke(
"smoke-structured-json",
'Reply ONLY with JSON: {"ok": true}',
validate,
structured="json", structured="json",
) )
_report("structured_json", [("解析产物", repr(resp.structured_data))])
assert isinstance(resp.structured_data, dict | list)
async def test_structured_model_ladder(self, client): async def test_structured_model_ladder(self):
resp = await client.chat( def validate(response):
[ assert isinstance(response.structured_data, MiniAnswer)
{ assert response.structured_data.answer == 5
"role": "user",
"content": "What is 2+3? Reply ONLY with JSON matching " await _smoke(
'{"answer": <int>, "reason": <short string>}', "smoke-structured-model",
} 'What is 2+3? Reply ONLY with JSON matching {"answer": <int>, "reason": <short string>}',
], validate,
structured=MiniAnswer, structured=MiniAnswer,
) )
_report(
"structured_model",
[
("原始响应", resp.content),
("校验产物", resp.structured_data.model_dump_json()),
],
)
assert isinstance(resp.structured_data, MiniAnswer)
assert resp.structured_data.answer == 5
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
"""真实测试的有限证据判定;不读取环境、不请求网络、不记录原始正文。"""
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from uuid import uuid4
from polygateway.errors import RequestRejectedError
from polygateway.types import ThinkingObservation
@dataclass(frozen=True)
class HttpEvidence:
"""一次 HTTP 的内存证据,禁止直接序列化。"""
call_id: str
request_checks: tuple[tuple[str, bool], ...]
status_code: int
error_body: bytes | None
raw_identity: tuple[bool, str | None]
@dataclass(frozen=True)
class AttemptEvidence:
"""一次 transport 尝试,可以没有 HTTP。"""
call_id: str
http: tuple[HttpEvidence, ...]
error: Exception | None
@dataclass(frozen=True)
class LiveVerdict:
"""测试命题的结论,不等同于 pytest 退出码。"""
status: Literal["PASS", "FAIL", "UNCOVERED"]
reason: str
def strict_json(body: bytes) -> Any:
"""独立解析完整 UTF-8 JSON,拒绝重复键及非标准常量。"""
def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
"""重复键不允许被后值掩盖。"""
result = {}
for key, value in items:
if key in result:
raise ValueError("重复 JSON 键")
result[key] = value
return result
def invalid(value: str) -> None:
"""拒绝非标准数值常量。"""
raise ValueError("非法 JSON 常量")
return json.loads(body.decode("utf-8"), object_pairs_hook=pairs, parse_constant=invalid)
def messages_digest(messages: Any) -> str:
"""只在内存比较提示词摘要,不写提示词。"""
return hashlib.sha256(
json.dumps(messages, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
def request_is_valid(event: HttpEvidence) -> bool:
"""完整且无重复的显式检查才能作为请求资格。"""
checks = dict(event.request_checks)
common = {"method", "origin", "path", "model", "authorization", "control", "messages_digest"}
return (
len(checks) == len(event.request_checks)
and set(checks) in (common | {"stream"}, common | {"input_shape"})
and all(value is True for value in checks.values())
)
def _single_error(error: Exception, attempts: Sequence[AttemptEvidence]) -> HttpEvidence | None:
"""仅接受可与最终异常精确配对的独立单次错误。"""
if not isinstance(error, RequestRejectedError) or len(attempts) != 1:
return None
attempt = attempts[0]
if attempt.error is not error or len(attempt.http) != 1:
return None
event = attempt.http[0]
if event.call_id != attempt.call_id or not request_is_valid(event):
return None
if event.status_code != error.status_code:
return None
return event
def error_machine_type(event: HttpEvidence) -> str | None:
"""完整错误体中的唯一机器字段;不检查正文子串。"""
body = event.error_body
if body is None or not 0 < len(body) <= 65536:
return None
try:
data = strict_json(body)
except (ValueError, UnicodeError):
return None
if not isinstance(data, dict) or not isinstance(data.get("error"), dict):
return None
value = data["error"].get("type")
return value if isinstance(value, str) else None
def classify_live_failure(error: Exception, attempts: Sequence[AttemptEvidence]) -> LiveVerdict:
"""默认失败;唯一自动外因是完整证据支持的 404 model_not_found。"""
event = _single_error(error, attempts)
if (
event is not None
and event.status_code == 404
and error_machine_type(event) == "model_not_found"
):
return LiveVerdict("UNCOVERED", "端点回报该请求型号不可用")
return LiveVerdict("FAIL", "无满足窄外因契约的完整独立证据")
def assess_expected_rejection(
error: Exception, attempts: Sequence[AttemptEvidence], *, status_code: int, machine_type: str
) -> LiveVerdict:
"""预先声明的拒绝命题,不把一般 400 当不支持档位。"""
event = _single_error(error, attempts)
if (
event is not None
and event.status_code == status_code
and error_machine_type(event) == machine_type
):
return LiveVerdict("PASS", "符合预声明的拒绝类型、状态和机器字段")
return LiveVerdict("FAIL", "不符合预声明拒绝证据")
def assess_model_identity(
*,
requested: str,
aliases: frozenset[str],
reported: str | None,
raw_identity: tuple[bool, str | None],
request_valid: bool,
) -> LiveVerdict:
"""公共身份异常只有原始独立证据才能归因上游。"""
if not request_valid:
return LiveVerdict("FAIL", "实发请求校验不完整或不符")
allowed = {requested, *aliases}
captured, raw = raw_identity
if captured and raw != reported:
return LiveVerdict("FAIL", "公共身份与独立原始身份不一致")
if reported in allowed:
return LiveVerdict("PASS", "身份合格")
if not captured:
return LiveVerdict("FAIL", "身份来源无法区分")
return LiveVerdict("UNCOVERED", "独立原始响应身份缺失或不属于显式别名")
def assess_thinking_coverage(
observations: Sequence[ThinkingObservation],
*,
planned_rounds: int,
proposition: Literal["enabled", "disabled", "cannot_disable"],
) -> LiveVerdict:
"""按独立命题裁定;缺轮不减分母,UNKNOWN 不证明关闭。"""
if planned_rounds < 1 or len(observations) != planned_rounds:
return LiveVerdict("FAIL", "计划轮次不完整")
if any(not isinstance(item, ThinkingObservation) for item in observations):
return LiveVerdict("FAIL", "观测类型不符")
observed = observations.count(ThinkingObservation.OBSERVED)
unknown = observations.count(ThinkingObservation.UNKNOWN)
if proposition == "enabled":
if observed > planned_rounds / 2:
return LiveVerdict("PASS", "完整轮次多数观测到推理")
return LiveVerdict("UNCOVERED" if unknown else "FAIL", "开启证据未达多数")
if proposition == "disabled":
if observed:
return LiveVerdict("FAIL", "观测到推理,证伪关闭声明")
return LiveVerdict(
"UNCOVERED" if unknown else "PASS",
"UNKNOWN 不证明关闭" if unknown else "每轮明确 ABSENT",
)
if proposition == "cannot_disable":
if observed:
return LiveVerdict("PASS", "本条件下仍推理;不外推所有私有参数")
return LiveVerdict("UNCOVERED" if unknown else "FAIL", "缺少仍推理的证据")
raise ValueError("未知测试命题")
def summarize_verdicts(verdicts: Sequence[LiveVerdict], *, planned_rounds: int) -> dict[str, int]:
"""保留失败、未覆盖与缺轮的独立计数。"""
if planned_rounds < len(verdicts):
raise ValueError("实际轮次超出计划")
return {
**{
status: sum(v.status == status for v in verdicts)
for status in ("PASS", "FAIL", "UNCOVERED")
},
"missing": planned_rounds - len(verdicts),
}
_SAFE_FIELDS = frozenset(
{
"provider",
"requested_model",
"reported_model",
"stream",
"requested_effort",
"applied_effort",
"session_id",
"parent_call_id",
"attempts",
"status",
"reason",
"completed_rounds",
"planned_rounds",
"thinking_observation",
"prompt_tokens",
"completion_tokens",
"reasoning_tokens",
"thinking_chars",
"counts",
"proposition",
"error_type",
"error_status",
"evidence_notes",
"subruns",
}
)
_ATTEMPT_FIELDS = frozenset({"call_id", "error_type", "http"})
_HTTP_FIELDS = frozenset(
{"status_code", "request_checks", "identity_captured", "error_body_complete", "machine_type"}
)
def _validate_safe(fields: Mapping[str, Any]) -> None:
"""拒绝原始异常和证据对象,嵌套字段也有白名单。"""
if set(fields) - _SAFE_FIELDS:
raise ValueError("报告含非白名单字段")
for attempt in fields.get("attempts", []):
if not isinstance(attempt, dict) or set(attempt) != _ATTEMPT_FIELDS:
raise ValueError("非法 attempt 报告")
for event in attempt["http"]:
if not isinstance(event, dict) or set(event) != _HTTP_FIELDS:
raise ValueError("非法 HTTP 报告")
if event["machine_type"] not in ("model_not_found", "omitted"):
raise ValueError("报告机器字段不是认可枚举")
if not isinstance(event["request_checks"], dict) or any(
type(v) is not bool for v in event["request_checks"].values()
):
raise ValueError("请求校验报告只允许布尔值")
# 不提供 default=str:原始异常、bytes、dataclass 均必须失败。
json.dumps(fields, ensure_ascii=False, allow_nan=False)
def write_live_round(
output_dir: Path,
*,
run_id: str,
matrix_id: str,
round_index: int,
safe_fields: Mapping[str, Any],
) -> Path:
"""仅接收已脱敏字段;独占文件写入失败必须冒泡。"""
_validate_safe(safe_fields)
if round_index < 0 or not re.fullmatch(r"[a-zA-Z0-9_-]+", run_id + matrix_id):
raise ValueError("报告路径标识非法")
directory = output_dir / run_id
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{matrix_id}-{round_index}-{uuid4().hex}.md"
text = json.dumps(dict(safe_fields), ensure_ascii=False, indent=2, allow_nan=False)
with path.open("x", encoding="utf-8") as handle:
handle.write(f"# {matrix_id} · 轮次 {round_index}\n\n```json\n{text}\n```\n")
return path
def safe_attempts(attempts: Sequence[AttemptEvidence]) -> list[dict[str, Any]]:
"""只导出事实、异常类与认可机器枚举;任意上游字符串一律省略。"""
return [
{
"call_id": attempt.call_id,
"error_type": type(attempt.error).__name__ if attempt.error else None,
"http": [
{
"status_code": event.status_code,
"request_checks": dict(event.request_checks),
"identity_captured": event.raw_identity[0],
"error_body_complete": event.error_body is not None,
"machine_type": (
"model_not_found"
if error_machine_type(event) == "model_not_found"
else "omitted"
),
}
for event in attempt.http
],
}
for attempt in attempts
]
def combine_live_verdicts(verdicts: Sequence[LiveVerdict]) -> LiveVerdict:
"""部分失败优先于未覆盖;部分未覆盖不得汇总全 PASS。"""
if any(verdict.status == "FAIL" for verdict in verdicts):
return LiveVerdict("FAIL", "至少一个必需命题失败")
if not verdicts or any(verdict.status == "UNCOVERED" for verdict in verdicts):
return LiveVerdict("UNCOVERED", "至少一个必需命题未覆盖")
return LiveVerdict("PASS", "所有必需命题通过")
def qualify_live_rounds(verdicts: Sequence[LiveVerdict], *, planned_rounds: int) -> LiveVerdict:
"""汇总请求/身份资格,缺轮绝不缩小分母。"""
if planned_rounds < 1 or len(verdicts) != planned_rounds:
return LiveVerdict("FAIL", "计划轮次缺失")
return combine_live_verdicts(verdicts)
+226
View File
@@ -592,3 +592,229 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
assert "(略 112 字)" in logged[1]["content"][0]["text"] assert "(略 112 字)" in logged[1]["content"][0]["text"]
assert build_cache_key("m", messages, "proj", None) == before assert build_cache_key("m", messages, "proj", None) == before
class TestExplicitCacheMigration:
"""相同模型身份不代表相同推理策略,隔离必须由调用方显式选择。"""
def _client(self, cache, source, *, capabilities=None, registry=None):
import httpx
from polygateway.transports.openai_compat import OpenAICompatTransport
from tests.unit.test_client import _client, _sse
sent = []
def handler(request):
payload = json.loads(request.content)
sent.append(payload)
return _sse(json.dumps(payload, sort_keys=True))
transport = OpenAICompatTransport(
client_factory=lambda src: httpx.AsyncClient(transport=httpx.MockTransport(handler)),
capabilities=capabilities,
registry=registry,
)
client = _client(
sources=[source],
transport=transport,
cache=cache,
cache_namespace="tenant-a",
cache_ttl_s=60,
)
return client, transport, sent
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
@pytest.mark.parametrize("change", ["capability", "fallback"])
async def test_capability_change_requires_explicit_identity(self, isolation, change):
from polygateway.client import build_model_fingerprint
from polygateway.errors import RequestRejectedError
from polygateway.thinking import ThinkingCapability
from tests.unit.test_client import _source
cache = InMemoryCache()
source = _source(provider="openai", model="migration-model")
if change == "capability":
old_cap = ThinkingCapability((Effort.AUTO, Effort.HIGH), "本地旧声明")
new_cap = ThinkingCapability((Effort.HIGH,), "本地新声明")
tier = Effort.AUTO
new_source = source
else:
old_cap = new_cap = ThinkingCapability((Effort.LOW, Effort.HIGH), "本地映射声明")
source = dataclasses.replace(source, effort_fallback="nearest")
new_source = dataclasses.replace(source, effort_fallback="error")
tier = Effort.MEDIUM
assert build_model_fingerprint([source]) == build_model_fingerprint([new_source])
old, old_transport, old_sent = self._client(
cache, source, capabilities={source.model: old_cap}
)
new, new_transport, new_sent = self._client(
cache, new_source, capabilities={source.model: new_cap}
)
identity = (
{"cache_namespace": "tenant-a:migrated"}
if isolation == "namespace"
else {"cache_salt": "migrated"}
)
try:
original = await old.chat(_MSGS, reasoning_effort=tier)
replay = await new.chat(_MSGS, reasoning_effort=tier)
assert replay.cache_hit and replay.content == original.content
assert len(old_sent) == 1 and not new_sent
with pytest.raises(RequestRejectedError):
await new.chat(_MSGS, reasoning_effort=tier, **identity)
assert not new_sent
assert (await old.chat(_MSGS, reasoning_effort=tier)).cache_hit
finally:
await old_transport.aclose()
await new_transport.aclose()
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
async def test_custom_wire_change_requires_explicit_identity(self, isolation):
from polygateway.client import build_model_fingerprint
from polygateway.providers import ProviderProfile, ThinkingWire
from polygateway.thinking import ThinkingCapability
from tests.unit.test_client import _source
source = _source(provider="custom", model="migration-model")
caps = {source.model: ThinkingCapability((Effort.HIGH,), "本地声明")}
def profile(key):
return {
"custom": ProviderProfile(
name="custom",
thinking=ThinkingWire(off=None, on_base={}, effort_key=key),
strip_think_tags=False,
)
}
cache = InMemoryCache()
old, t1, sent1 = self._client(cache, source, capabilities=caps, registry=profile("depth_a"))
new, t2, sent2 = self._client(cache, source, capabilities=caps, registry=profile("depth_b"))
assert build_model_fingerprint(old._terminal._sources) == build_model_fingerprint(
new._terminal._sources
)
identity = (
{"cache_namespace": "tenant-a:migrated"}
if isolation == "namespace"
else {"cache_salt": "migrated"}
)
try:
original = await old.chat(_MSGS, reasoning_effort=Effort.HIGH)
assert (await new.chat(_MSGS, reasoning_effort=Effort.HIGH)).cache_hit
migrated = await new.chat(_MSGS, reasoning_effort=Effort.HIGH, **identity)
assert not migrated.cache_hit and migrated.content != original.content
assert len(sent1) == len(sent2) == 1
assert sent2[0]["depth_b"] == "high" and "depth_a" not in sent2[0]
assert (await old.chat(_MSGS, reasoning_effort=Effort.HIGH)).content == original.content
finally:
await t1.aclose()
await t2.aclose()
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
async def test_legacy_raw_override_requires_explicit_identity(self, isolation):
from polygateway.client import build_model_fingerprint
from polygateway.errors import RequestRejectedError
from tests.unit.test_client import _source
source = _source(
provider="openai", reasoning_effort="high", extra_body={"reasoning_effort": "low"}
)
cache = InMemoryCache()
key = build_cache_key(build_model_fingerprint([source]), _MSGS, "tenant-a", None)
legacy = dataclasses.asdict(_resp(content="legacy-raw-low", applied_effort=Effort.HIGH))
legacy.pop("structured_data", None)
await cache.set(key, json.dumps(legacy), 60)
client, transport, sent = self._client(cache, source)
identity = (
{"cache_namespace": "tenant-a:migrated"}
if isolation == "namespace"
else {"cache_salt": "migrated"}
)
try:
assert (await client.chat(_MSGS)).content == "legacy-raw-low"
with pytest.raises(RequestRejectedError, match="冲突"):
await client.chat(_MSGS, **identity)
assert sent == []
assert await cache.get(key) is not None
finally:
await transport.aclose()
async def test_per_call_namespace_survives_a_changed_factory_default(self):
from polygateway import GatewayClient, GatewaySettings
from tests.unit.test_client import _ENV, _source
cache = InMemoryCache()
source = _source()
old, transport, sent = self._client(cache, source)
try:
await old.chat(_MSGS, cache_namespace="tenant-a")
# 工厂路径和全量注入配置同模型身份;只改默认不能改变显式租户覆盖。
settings = GatewaySettings.from_env(
env={
**_ENV,
"PGW_CACHE_BACKEND": "memory",
"PGW_CACHE_NAMESPACE": "changed-default",
"PGW_CACHE_TTL_S": "60",
}
)
new = GatewayClient.from_settings(settings, cache=cache)
try:
assert (await new.chat(_MSGS, cache_namespace="tenant-a")).cache_hit
assert len(sent) == 1
finally:
await new.aclose()
finally:
await transport.aclose()
async def test_shared_source_pool_migration_preserves_tenant_boundaries(self):
import httpx
from polygateway.errors import RequestRejectedError
from polygateway.thinking import ThinkingCapability
from polygateway.transports.openai_compat import OpenAICompatTransport
from tests.unit.test_client import _client, _source, _sse
sources = [
_source(name=name, provider="openai", model="shared-model") for name in ("a", "b")
]
cache = InMemoryCache()
sent = []
def handler(request):
sent.append(request)
return _sse()
transports = [
OpenAICompatTransport(
client_factory=lambda src: httpx.AsyncClient(
transport=httpx.MockTransport(handler)
),
capabilities={"shared-model": ThinkingCapability(choices, "本地声明")},
)
for choices in ((Effort.AUTO, Effort.HIGH), (Effort.HIGH,))
]
clients = [
_client(
sources=sources, transport=t, cache=cache, cache_namespace="default", cache_ttl_s=60
)
for t in transports
]
try:
for tenant in ("tenant-a", "tenant-b"):
await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
assert (
await clients[1].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
).cache_hit
with pytest.raises(RequestRejectedError):
await clients[1].chat(
_MSGS, reasoning_effort="auto", cache_namespace=tenant + ":new"
)
assert len(sent) == 2
for tenant in ("tenant-a", "tenant-b"):
assert (
await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
).cache_hit
finally:
for transport in transports:
await transport.aclose()
+122 -40
View File
@@ -247,52 +247,35 @@ class TestReasoningEffortPriority:
assert "reasoning_effort" not in captured[0] assert "reasoning_effort" not in captured[0]
@pytest.mark.parametrize( @pytest.mark.parametrize(
("provider", "model", "fragment"), "provider,model,tier",
[ [
("qwen", "qwen-max", {"enable_thinking": True}), ("deepseek", "deepseek-v4-pro", "high"),
("deepseek", "deepseek-v4-pro", {"thinking": {"type": "enabled"}}), ("zhipu", "glm-5.3", "low"),
("zhipu", "glm-5.3", {"thinking": {"type": "enabled"}}), ("moonshot", "kimi-k3", "low"),
("moonshot", "kimi-k3", {"thinking": {"type": "enabled"}}), ("minimax", "MiniMax-M3", "medium"),
], ],
) )
async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment): async def test_legacy_auto_requires_explicit_migration(self, provider, model, tier):
"""存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。 """旧糖配置明确拒绝,显式选择才能恢复可执行请求。"""
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 = [] captured = []
source = _source(provider=provider, model=model, enable_thinking=True) source = _source(provider=provider, model=model, enable_thinking=True)
async with self._capturing_client(captured, sources=[source]) as client: client = self._capturing_client(captured, sources=[source])
await client.chat([{"role": "user", "content": "hi"}]) try:
body = captured[0] with pytest.raises(RequestRejectedError):
assert {k: body[k] for k in fragment} == fragment await client.chat([])
# `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档 assert captured == []
assert "reasoning_effort" not in body finally:
await client._transport.aclose()
async def test_legacy_minimax_on_tier_actually_turns_reasoning_on(self): client = self._capturing_client(
"""回归门(issue #21): minimax 段的存量 `ENABLE_THINKING=true` 必须真开推理。 captured, sources=[replace(source, enable_thinking=None, reasoning_effort=tier)]
)
本次换代一度把这段的开启形态改成 `on_base={}`(什么参数都不注入),依据是 try:
"这些模型默认就推理,不注入也仍是''"T10 真实网关实测推翻了该前提: await client.chat([])
MiniMax-M3 不带任何推理参数时 5/5 **不推理**(六个强度值则全部生效) assert captured[0]["reasoning_effort"] == tier
于是存量下游从"真开推理"静默变成"不推理", `resolve_thinking` Phase 5 finally:
无条件放行 `auto`能力表也堵不住这条路 await client._transport.aclose()
断言落在**发出去的字节**上而非中间态: 静默不推理这件事只有在请求体里才看得见
"""
captured = []
source = _source(provider="minimax", model="MiniMax-M3", enable_thinking=True)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
assert captured[0]["reasoning_effort"] == "medium"
class TestEffortFallbackWiring: class TestEffortFallbackWiring:
@@ -1280,3 +1263,102 @@ class TestTelemetryStatusExposure:
assert _ocr_client().telemetry_status is None assert _ocr_client().telemetry_status is None
assert _ocr_client(telemetry=_Closable()).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) 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()
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()
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()
+44
View File
@@ -997,3 +997,47 @@ class TestCrossFieldInvariants:
) )
client = GatewayClient.from_settings(settings) client = GatewayClient.from_settings(settings)
assert client is not None assert client is not None
async def test_flat_legacy_keys_assemble_without_source_timeout_or_network():
"""完整合成 env 验证平铺回落,不借真实配置或 Redis/PG。"""
env = dict(_BASE_ENV)
del env["LLM__QWEN__1__TIMEOUT_S"]
env.update({"LLM_TIMEOUT": "317", "LLM_TTFT_TIMEOUT": "41", "LLM_INTER_TOKEN_TIMEOUT": "19"})
settings = GatewaySettings.from_env("LLM", env=env)
assert settings.sources[0].timeout_s == 317
assert settings.sources[0].ttft_timeout_s == 41
assert settings.sources[0].inter_token_timeout_s == 19
assert settings.retry.max_attempts == 3
client = GatewayClient.from_settings(settings)
try:
assert client._transport._clients == {}
finally:
await client.aclose()
@pytest.mark.parametrize("model", ["MiniMax-M2.7", "MiniMax-M3"])
def test_live_assembly_rejection_is_local_only(model):
"""M2.7 NONE 与 M3 AUTO 的旧 live 装配断言离线执行。"""
env = _env(**{"LLM__QWEN__1__MODEL": model})
settings = GatewaySettings.from_env("LLM", env=env)
source = dataclasses.replace(
settings.sources[0], provider="minimax", enable_thinking=model == "MiniMax-M3"
)
with pytest.raises(ValueError, match=model):
GatewayClient.from_settings(dataclasses.replace(settings, sources=(source,)))
def test_live_unknown_wire_assembly_is_local_only():
"""L9 明确全 None profile,不从当前默认注册表猜未知形态。"""
mystery = ProviderProfile(
name="mystery",
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
strip_think_tags=False,
)
settings = GatewaySettings.from_env("LLM", env=_BASE_ENV)
source = dataclasses.replace(settings.sources[0], provider="mystery", enable_thinking=False)
with pytest.raises(ValueError, match="register_provider"):
GatewayClient.from_settings(
dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery)
)
+67
View File
@@ -533,3 +533,70 @@ class TestEmbeddingSettings:
s = EmbeddingSettings.from_env("EMBED", env=self._ENV) s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
client = EmbeddingClient.from_settings(s) client = EmbeddingClient.from_settings(s)
assert isinstance(client, EmbeddingClient) assert isinstance(client, EmbeddingClient)
class TestReasonlessTelemetryContract:
"""从真实客户端到落库,误配推理配置也不能产生推理档。"""
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
async def test_failed_then_successful_attempts_have_null_effort(
self, config, backend, tmp_path
):
import sqlite3
from polygateway.telemetry.sqlite import SQLiteRecorder
path = tmp_path / "embed.sqlite"
recorder = (
_MemoryRecorder() if backend == "memory" else SQLiteRecorder(path, auto_migrate=True)
)
client, _ = _embed_client(
[_src(**config)], [TransientError("retry"), "ok"], telemetry=recorder
)
try:
await client.embed(["text"], session_id="run", parent_call_id="embed")
if backend == "memory":
rows = [(r["error"], r["reasoning_effort"]) for r in recorder.rows]
else:
with sqlite3.connect(path) as db:
rows = db.execute("SELECT error, reasoning_effort FROM llm_calls").fetchall()
assert len(rows) == 2
assert sum(bool(error) for error, _ in rows) == 1
assert [tier for _, tier in rows] == [None, None]
finally:
await client.aclose()
if backend == "sqlite":
recorder.close()
@pytest.mark.parametrize("exhausted", [False, True])
async def test_failed_attempts_still_have_null_effort(self, exhausted):
from polygateway.errors import AllSourcesExhausted
script = (
[TransientError("retry")] * 3 if exhausted else [RequestRejectedError("bad request")]
)
recorder = _MemoryRecorder()
client, _ = _embed_client([_src(enable_thinking=True)], script, telemetry=recorder)
with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError):
await client.embed(["text"])
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()
File diff suppressed because it is too large Load Diff
+28
View File
@@ -405,3 +405,31 @@ class TestLifecycle:
await t.check_health(source=_source()) await t.check_health(source=_source())
await t.aclose() await t.aclose()
await t.aclose() await t.aclose()
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
async def test_ocr_wire_does_not_send_reasoning_configuration(method):
"""真实 multipart 与 ZIP 下载两段均不发送源级推理配置。"""
sent = []
def handler(request):
sent.append(request)
if request.method == "GET":
return httpx.Response(200, content=_zip_bytes())
return httpx.Response(
200, json=_text_body() if method == "recognize_text" else _parse_body()
)
transport = _transport_for(handler)
try:
await getattr(transport, method)(
image=b"image",
source=_source(enable_thinking=True, reasoning_effort="high"),
call_id="wire",
)
assert len(sent) == (1 if method == "recognize_text" else 2)
for request in sent:
for key in (b"reasoning_effort", b"enable_thinking", b"thinking_budget"):
assert key not in request.content
finally:
await transport.aclose()
+51
View File
@@ -564,3 +564,54 @@ class TestAssembly:
client = OcrClient.from_env("OCR", env=dict(self._ENV)) client = OcrClient.from_env("OCR", env=dict(self._ENV))
await client.aclose() await client.aclose()
await client.aclose() await client.aclose()
class TestReasonlessTelemetryContract:
"""text/layout 两个入口分别验证错误行不受源级推理配置污染。"""
@pytest.mark.parametrize(
"method,action", [("recognize_text", "text"), ("parse_layout", "layout")]
)
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
async def test_failed_then_successful_attempts_have_null_effort(
self, method, action, config, backend, tmp_path
):
import sqlite3
from polygateway.telemetry.sqlite import SQLiteRecorder
path = tmp_path / "ocr.sqlite"
recorder = (
_MemoryRecorder() if backend == "memory" else SQLiteRecorder(path, auto_migrate=True)
)
client, _, _ = _client(
[_src(**config)], [TransientError("retry"), action], telemetry=recorder
)
try:
await getattr(client, method)(b"image", session_id="run", parent_call_id=method)
if backend == "memory":
rows = [(r["error"], r["reasoning_effort"]) for r in recorder.rows]
else:
with sqlite3.connect(path) as db:
rows = db.execute("SELECT error, reasoning_effort FROM llm_calls").fetchall()
assert len(rows) == 2
assert sum(bool(error) for error, _ in rows) == 1
assert [tier for _, tier in rows] == [None, None]
finally:
await client.aclose()
if backend == "sqlite":
recorder.close()
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
@pytest.mark.parametrize("exhausted", [False, True])
async def test_failed_attempts_still_have_null_effort(self, method, exhausted):
script = (
[TransientError("retry")] * 3 if exhausted else [RequestRejectedError("bad request")]
)
recorder = _MemoryRecorder()
client, _, _ = _client([_src(enable_thinking=True)], script, telemetry=recorder)
with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError):
await getattr(client, method)(b"image")
assert len(recorder.rows) == len(script)
assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows)
+142 -55
View File
@@ -623,8 +623,8 @@ class TestThinkingReconciliation:
try: try:
await _complete(transport, self._minimax(False)) await _complete(transport, self._minimax(False))
await _complete(transport, self._minimax(False)) await _complete(transport, self._minimax(False))
await _complete(transport, self._minimax(True)) await _complete(transport, self._minimax(True), reasoning_effort=Effort.MEDIUM)
await _complete(transport, self._minimax(True)) await _complete(transport, self._minimax(True), reasoning_effort=Effort.MEDIUM)
finally: finally:
logger.remove(sink_id) logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m] hits = [m for m in messages if "MiniMax-M3" in m]
@@ -705,75 +705,44 @@ class TestNonStreamFastPath:
class TestRequestShaping: class TestRequestShaping:
@pytest.mark.parametrize( @pytest.mark.parametrize("tier", [Effort.MEDIUM, Effort.NONE])
("enable_thinking", "expected"), async def test_minimax_explicit_tier_is_sent(self, tier):
[(True, {"enable_thinking": True}), (False, {"enable_thinking": False}), (None, {})],
)
async def test_thinking_tri_state_injection(self, enable_thinking, expected):
seen = {} seen = {}
def handler(request): def handler(request):
seen.update(json.loads(request.content)) seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
await _complete(_transport_for(handler), _source(enable_thinking=enable_thinking)) transport = _transport_for(handler)
assert {k: seen[k] for k in expected} == expected try:
if enable_thinking is None: result = await _complete(
transport, _source(provider="minimax", model="MiniMax-M3"), reasoning_effort=tier
)
assert seen["reasoning_effort"] == tier.value
assert result.applied_effort is tier
assert "enable_thinking" not in seen assert "enable_thinking" not in seen
assert seen["stream_options"] == {"include_usage": True} finally:
await transport.aclose()
@pytest.mark.parametrize( async def test_raw_only_keeps_source_then_request_priority(self):
("enable_thinking", "expected"),
# 本条断言反复过一次,记下原委以免第三次改回去:
# 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。"""
seen = {} seen = {}
def handler(request): def handler(request):
seen.update(json.loads(request.content)) seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source( transport = _transport_for(handler)
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking try:
result = await _complete(
transport,
_source(extra_body={"reasoning_effort": "low", "temperature": 0}),
overlay={"reasoning_effort": "high", "temperature": 1},
) )
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["reasoning_effort"] == "high"
assert seen["thinking"] == {"type": "enabled"} # profile 独有的那半边仍在 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): async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。 """M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
@@ -1056,3 +1025,121 @@ class TestLifecycle:
await _complete(transport, _source()) await _complete(transport, _source())
await transport.aclose() await transport.aclose()
await transport.aclose() await transport.aclose()
class TestManagedReasoningOwnership:
"""通过真实 transport 验证拒绝发生在 HTTP 之前。"""
@pytest.mark.parametrize(
"raw",
[
{"reasoning_effort": "high"},
{"enable_thinking": True},
{"thinking": {}},
{"thinking_budget": 100},
{"reasoning": {}},
{"thinkingConfig": {}},
{"output_config": {"effort": "low"}},
],
)
@pytest.mark.parametrize("layer", ["source", "request", "shadowed"])
async def test_raw_control_is_rejected_before_http(self, raw, layer):
sent = []
def handler(request):
sent.append(request)
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
source = _source(
provider="openai",
reasoning_effort=Effort.HIGH,
extra_body=raw if layer != "request" else {},
)
overlay = raw if layer != "source" else {}
transport = _transport_for(handler)
try:
with pytest.raises(RequestRejectedError):
await _complete(transport, source, overlay=overlay)
assert sent == []
finally:
await transport.aclose()
class TestDefaultClientFactory:
"""直接检查生产 factory,不用取证测试的另一套 factory 代替。"""
@pytest.fixture(autouse=True)
def isolate_proxy_environment(self, monkeypatch):
"""离线构造测试不继承开发机代理;仍真实验证 trust_env 传递。"""
for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"):
monkeypatch.delenv(key, raising=False)
monkeypatch.delenv(key.lower(), raising=False)
@pytest.mark.parametrize("key", ["fake-a", "fake-b"])
async def test_authorization_uses_source_api_key(self, key):
from polygateway.transports.openai_compat import _default_client_factory
client = _default_client_factory(_source(api_key=key))
try:
assert client.headers["Authorization"] == f"Bearer {key}"
assert (
client.build_request("POST", "https://gw.example/v1/chat/completions").headers[
"Authorization"
]
== f"Bearer {key}"
)
finally:
await client.aclose()
@pytest.mark.parametrize("timeout", [17.0, 53.0])
async def test_timeout_uses_source_timeout_for_all_phases(self, timeout):
from polygateway.transports.openai_compat import _default_client_factory
client = _default_client_factory(_source(timeout_s=timeout))
try:
assert [
client.timeout.connect,
client.timeout.read,
client.timeout.write,
client.timeout.pool,
] == [timeout] * 4
finally:
await client.aclose()
@pytest.mark.parametrize("trust_env", [True, False])
async def test_trust_env_uses_source_setting(self, trust_env):
from polygateway.transports.openai_compat import _default_client_factory
client = _default_client_factory(_source(trust_env=trust_env))
try:
assert client.trust_env is trust_env
finally:
await client.aclose()
@pytest.mark.parametrize("key", ["depth.key", "off_control", "switch"])
async def test_custom_profile_raw_roots_cannot_override_managed_intent(key):
registry = {
"custom": ProviderProfile(
name="custom",
thinking=ThinkingWire(
off={"off_control": False}, on_base={"switch": True}, effort_key="depth.key"
),
strip_think_tags=False,
)
}
sent = []
transport = _transport_for(
lambda request: sent.append(request) or _sse_stream(_chunk(content="x")), registry=registry
)
try:
with pytest.raises(RequestRejectedError, match="冲突"):
await _complete(
transport,
_source(provider="custom"),
reasoning_effort=Effort.HIGH,
overlay={key: True},
)
assert sent == []
finally:
await transport.aclose()
+3 -7
View File
@@ -57,14 +57,10 @@ class TestDefaultProfiles:
assert w.off == {"reasoning_effort": "none"}, name assert w.off == {"reasoning_effort": "none"}, name
assert w.effort_key == "reasoning_effort", name assert w.effort_key == "reasoning_effort", name
def test_minimax_on_tier_carries_a_tier_value(self): def test_minimax_on_does_not_select_a_tier(self):
"""issue #21 的权宜之计: minimax 的""必须真写一个档位值,不能是空片段。 """形态不代替模型能力,也不替调用者选择付费档位。"""
断言反复过一次: T2 "这些模型默认就推理"的推定把它改成 `{}`,T10 真实
网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium
"""
w = get_provider("minimax").thinking w = get_provider("minimax").thinking
assert w.on_base == {"reasoning_effort": "medium"} assert w.on_base == {}
assert w.off == {"reasoning_effort": "none"} assert w.off == {"reasoning_effort": "none"}
assert w.effort_key == "reasoning_effort" assert w.effort_key == "reasoning_effort"
+87
View File
@@ -2839,3 +2839,90 @@ class TestPostgresFailureClassification:
assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")] assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]
assert recorder.telemetry_status.degraded is False 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()
+116 -19
View File
@@ -285,16 +285,13 @@ class TestResolveThinking:
get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest" get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest"
) )
def test_phase4_only_blocks_the_off_direction(self): @pytest.mark.parametrize("model", ["MiniMax-M2.5", "MiniMax-M2.7"])
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。 def test_phase4_only_blocks_the_off_direction(self, model):
"""已登记 AUTO 只发开启片段,不由库代选 medium。"""
期望片段 2026-09-05 `{}` 改成 minimax `on_base` 实际值: issue #21 把 got = resolve_thinking(
该段的""改回带 medium(T2 "开档不注入"是推定,T10 实测推翻)本用例守的 get_provider("minimax"), get_capability(model), Effort.AUTO, model=model
Phase 4 只拦关闭方向,注入什么由 wire 决定,故随 wire )
""" assert got.payload == {}
cap = get_capability("MiniMax-M2.7")
got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7")
assert got.payload == {"reasoning_effort": "medium"}
assert got.applied_effort is Effort.AUTO assert got.applied_effort is Effort.AUTO
def test_phase4_passes_when_none_is_registered(self): 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.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "max"}
assert got.applied_effort is Effort.MAX assert got.applied_effort is Effort.MAX
def test_auto_never_trips_phase5(self): @pytest.mark.parametrize(
"""`auto` = 不指定档位,可满足性只取决于 wire 有没有 on_base。 "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` 的取值,故不受档位清单约束反过来判会让存量的 def test_minimax_explicit_medium_restores_old_wire(self):
`ENABLE_THINKING=true`(T5 起等价于 auto) deepseek/glm-5.3 这类清单里 got = resolve_thinking(
没有 auto 的模型上当场报错设计 §12 明确承诺存量配置继续可跑 get_provider("minimax"), get_capability("MiniMax-M3"), Effort.MEDIUM, model="MiniMax-M3"
""" )
cap = get_capability("deepseek-v4-pro") # (none, high, max),清单里没有 auto assert got.payload == {"reasoning_effort": "medium"}
got = resolve_thinking(get_provider("deepseek"), cap, Effort.AUTO, model="deepseek-v4-pro") assert got.applied_effort is Effort.MEDIUM
assert got.payload == {"thinking": {"type": "enabled"}}
@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 got.applied_effort is Effort.AUTO
assert any("不保证" in str(message) for message in messages)
# —— nearest 映射(fallback 的逃生口)—— # —— nearest 映射(fallback 的逃生口)——
@@ -825,3 +847,78 @@ class TestEffectiveEffort:
assert ( assert (
effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None
) )
class TestThinkingWireOwnership:
"""开启片段只能表达开启,不能携带隐式强度。"""
@pytest.mark.parametrize(
"base",
[
{"reasoning_effort": "high"},
{"reasoning_effort": None},
{"depth": "auto"},
{"output_config": {"effort": "low"}},
],
)
@pytest.mark.parametrize("effort", [None, Effort.AUTO, Effort.HIGH])
def test_on_base_cannot_hide_a_tier(self, base, effort):
profile = ProviderProfile(
name="custom",
thinking=ThinkingWire(off={"depth": "none"}, on_base=base, effort_key="depth"),
strip_think_tags=False,
)
with pytest.raises(ThinkingUnsupportedError, match="on_base"):
resolve_thinking(profile, None, effort, model="custom-model")
class TestThinkingRawOwnership:
"""纯规则覆盖标准、自定义根与无意图逃生口。"""
@pytest.mark.parametrize(
"raw",
[
{"reasoning_effort": "high"},
{"enable_thinking": True},
{"thinking": {}},
{"thinking_budget": 100},
{"reasoning": {}},
{"thinkingConfig": {}},
{"output_config": {"effort": None}},
{"depth.key": None},
{"off_control": {}},
],
)
@pytest.mark.parametrize("effort", [Effort.NONE, Effort.AUTO, Effort.HIGH])
def test_control_roots_rejected_without_mutating_input(self, raw, effort):
from copy import deepcopy
from polygateway.thinking import validate_thinking_raw
wire = ThinkingWire(off={"off_control": False}, on_base={}, effort_key="depth.key")
before = deepcopy(raw)
with pytest.raises(ThinkingUnsupportedError):
validate_thinking_raw(raw, effort=effort, wire=wire, origin="test")
assert raw == before
validate_thinking_raw(raw, effort=None, wire=wire, origin="test")
assert raw == before
def test_output_format_is_not_effort_unless_wire_owns_root(self):
from polygateway.thinking import validate_thinking_raw
raw = {"output_config": {"format": "json"}, "temperature": 0, "seed": 7}
validate_thinking_raw(raw, effort=Effort.AUTO, wire=None, origin="test")
wire = ThinkingWire(off=None, on_base={"output_config": {"enabled": True}}, effort_key=None)
with pytest.raises(ThinkingUnsupportedError):
validate_thinking_raw(raw, effort=Effort.AUTO, wire=wire, origin="test")
def test_auto_rejection_explains_how_to_choose_explicitly():
"""可执行配置是指路,不由库自动应用其建议。"""
with pytest.raises(ThinkingUnsupportedError) as error:
resolve_thinking(
get_provider("minimax"), get_capability("MiniMax-M3"), Effort.AUTO, model="MiniMax-M3"
)
assert "REASONING_EFFORT=" in str(error.value)
assert "reasoning_effort=Effort." in str(error.value)
assert "EFFORT_FALLBACK=nearest" not in str(error.value)