32 Commits

Author SHA1 Message Date
iomgaa d2455e8fd4 docs: record 1.3.5 release completion and external verification
Merge-time gates, registry artifacts and anonymous page checks for 1.3.5
recorded as a separate finding; the validation finding gains a status
pointer so its historic 'not yet executed' section is not silently stale.
Released main/tag and uploaded artifacts are untouched.
2026-09-09 14:02:15 -04:00
iomgaa ab00aa4457 chore: merge release 1.3.5 call observability 2026-09-09 13:26:21 -04:00
iomgaa 433039be79 chore: prepare release 1.3.5
Version bump in pyproject and __init__, CHANGELOG dated 2026-09-09,
README install lower bound raised to >=1.3.5, and release-prep evidence
(remote check, gates, real gateway smoke and one bounded live probe)
recorded in the 1.3.5 validation finding.
2026-09-09 13:21:40 -04:00
iomgaa b4812e12c8 fix: degrade terminal telemetry failures instead of masking domain errors
终态出口 `emit_terminal_once` 此前只让 `_record` 内的 except 兜住落库,而
诊断字段的提取(`_error_fields` → `_structured_detail` → `format_bounded_errors`)
在降级 try **之外**求值。下游经公共端口(自实现 `StructuredOutputStrategy`
或 transport)构造出 `ResultInvalidError(validation_errors=(非 str,))` 时,提取期
抛的 `TypeError` 会顶替调用方本该收到的领域异常——错误四分类被击穿(下游
`except ResultInvalidError` 落空),且 `claim_terminal()` 已消耗故终态行照样丢,
同时违反"遥测写失败降级不冒泡"。

改法与 RetryMW 的 attempt 出口(`retry.py::_emit`)同款: 把快照冻结与 await
整段包进 try,`CancelledError` 原样上抛、其余落一条 warning。终态行按已批准的
best effort(兜底命中时该次逻辑调用 0 条终态行,不补写)。异常类型校验与
`ResultInvalidError` 的既有设计均未改动。

顺带同步审查报告的 Minor 项: SQLite recorder docstring 26 → 36 字段、
research-wiki 索引重建、ARCHITECTURE 的 `sampling` 段落终态调用点口径,
并删除 `TelemetryMW` 迁移后无读取点的 `self._now` 死字段(保留形参,
避免平白打断既有装配写法)。
2026-09-09 12:50:51 -04:00
iomgaa 067b15be48 docs: document logical call telemetry and migration impact
Field counts come from inspect, not memory: record_llm_call takes 36
parameters, COLUMNS has 36 entries, the physical table has 37.

- README: capability table says 36 fields and names the three row kinds;
  new section covers reading call_stats, the five SQL migration items,
  the attribution query and the storage-side upgrade
- README/.env.example/ARCHITECTURE: error_body follows the summarize_body
  limit and the structured-exhaustion error carries its own bounded
  explanation, so neither is inside PGW_TELEMETRY_TEXT_CAP coverage
- ARCHITECTURE 7.8: the ten columns with per-column semantics, the I3/I4
  invariants, operation versus exc.operation, and the assembly gate
- CHANGELOG: unreleased section listing the four public changes and what
  downstream must do, in particular counting failures by event_kind and
  the assembly-time error for custom recorders
- schemas/llm-calls: the ten columns plus a three-row-kind section
- metrics/call-telemetry-coverage: 1.3.5 coverage contract, real live
  baselines left unfilled rather than stating a fake percentage

Validation record records the T4 evidence: mechanical migration red then
green, the four PG acceptance cases, the seven-item mutation matrix with
all seven killed and the copy restored to an identical digest, plus the
PYTHONPATH pitfall that made the first mutation round silently test the
original source.

Version numbers and release steps are deliberately untouched.
2026-09-09 12:17:50 -04:00
iomgaa 7b2f6105f3 test: cover the ten call observability columns on real Postgres
Migrate the PG telemetry fixtures to the 36-field recorder and add the
storage compatibility acceptance the plan calls for.

Mechanical migration:
- _EXPECTED_COLUMNS 27 -> 37 physical columns
- _record_minimal gains the ten keys in the same shape as the unit suite
- _PRE_TENANT_COLUMNS now excludes 14 columns, derived from
  _CALL_OBSERVABILITY_COLUMNS instead of a second hand-written list, and
  the two manual-mode warnings assert a notice derived from COLUMNS order
  so a column that silently drops out of the warning turns the test red

New TestCallObservabilityColumnsAcceptance, all on a 27-column 1.3.4
shaped table built by the existing pg_sandbox factory:
- auto appends the ten columns in the same order as a fresh database and
  old rows keep NULL in every one of them (no backfill, no sentinel)
- manual sends no DDL, trims the INSERT, and still round-trips the other
  26 columns value by value
- an old-version writer using insert_sql with the 1.3.4 column set and a
  new-version writer share one table, and event_kind filtering counts
  neither the old rows as failures nor as successes

_minimal_fields is split out of _record_minimal so the simulated old
process reuses the same values rather than copying them.

Verified against the real lab Postgres: 30 passed.
2026-09-09 12:17:34 -04:00
iomgaa 393f2bf617 feat: record call observability columns and terminal failure rows
Grow the telemetry contract from 26 to 36 fields and give every logical
call a failure terminal row, so SQL can finally answer "how many calls
failed" and "why did the whole pool die".

Schema and port move together with the emitter writes in one commit:
splitting them would ship columns that nothing populates.

- schema: append 10 nullable columns (scope, operation, logical_call_id,
  event_kind, http_status_code, error_type, cause_type, error_body,
  attempts, total_latency_ms) to all five definition sites in one order
- ports: 10 keyword-only parameters without defaults; the protocol
  signature is now the single source the assembly gate derives from
- emitter: take domain exception objects instead of pre-flattened text
  and pin down the diagnostics in one helper; a relabelled 503 stays
  503 and success rows leave all five columns NULL
- emitter: reject recorders whose record_llm_call cannot accept the
  current field shape at assembly time, since _record would otherwise
  swallow the TypeError and drop every row while calls keep succeeding
- clients: write at most one terminal row per logical call through a
  single shared exit, deduplicated by the call context; TelemetryMW
  stops writing terminals so the two sites cannot double count
- clients: cancellation stays best effort and propagates, non-domain
  exceptions get no terminal row and keep their classification
- transports: give _status_to_error an explicit operation and fix the
  historically mislabelled embedding HTTP failures
- structured: promote the bounded error formatter so the reask feedback
  and the terminal explanation share one set of limits

Terminal rows carry no cost and no tokens, so cost aggregation is
unchanged; failure counts must now filter on event_kind.
2026-09-09 11:27:52 -04:00
iomgaa 87c261bf73 feat: track logical call statistics across governed calls 2026-09-09 10:04:39 -04:00
iomgaa 300ced5dbd docs: record approved call observability design and plan 2026-09-09 09:42:24 -04:00
iomgaa a81cc91124 docs: propose logical call statistics and failure diagnostics 2026-09-09 09:08:47 -04:00
iomgaa e71a623b04 docs: remove trailing blank line from release evidence 2026-09-09 07:28:02 -04:00
iomgaa 0c1165965f docs: record verified 1.3.4 publication and release checks 2026-09-09 07:27:19 -04:00
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
57 changed files with 8947 additions and 1702 deletions
+13 -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__INTER_TOKEN_TIMEOUT_S=15
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不表态 / true=要求开启 / false=要求关闭
# 本键是 REASONING_EFFORT 的语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态
# "要求开启"注入什么随 provider 段而定: openai/anthropic/google 三段的开启形态是
# on_base={}——一个字节都不注入,走模型自己的默认档(该默认档若不推理,本键不会报错
# 也不会开推理,见 CHANGELOG 1.3.3「已知限制」/ issue #21);要确保开启请配 REASONING_EFFORT
# LLM__QWEN__1__REASONING_EFFORT=low # 本源默认推理档位;缺省=不表态(随模型自己的默认档)
# 本键是语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态
# 已登记模型须清单含 AUTO 才接受 true;nearest 不代选强度。
# 未登记仍尽力+warning,空 wire 可能零推理字节,不保证开启。
# M3 删除糖并选 medium 等表内档;M2.5/M2.7 AUTO 不再偷带 medium。
# 完整 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 = 要求推理但不指定强度
# 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low),
@@ -111,9 +113,15 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
# # 1.3.5 补充: error_body(网关响应正文摘要)沿用库内 summarize_body 上限,
# # 结构化耗尽终态行的 error 说明自带有界限长(不含模型原始正文);
# # **两者都不在本键的覆盖面内**,估算留存面时要单独计。
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
# 推理语义/fallback/能力表/wire 变化前须换从未使用的新 namespace 或 salt。
# 保留租户前缀与 epoch;per-call 覆盖也要迁移,只改此处无效。
# 未迁移仍可回放旧语义并绕过新拒绝;回滚旧身份会重见旧值,库不自动隔离。
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
+47 -3
View File
@@ -1,5 +1,52 @@
# Changelog
## 1.3.5(2026-09-09)
把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。
### 公共面四项变更
| # | 位置 | 变更 | 谁会当场断 |
| --- | --- | --- | --- |
| 1 | `polygateway.CallStats` | 新导出的 frozen dataclass(`logical_call_id` / `attempts` / `total_latency_ms`) | 无(纯新增) |
| 2 | `LLMResponse` / `EmbeddingResponse` / `OcrTextResult` / `OcrLayoutResult` | 各追加**末尾**字段 `call_stats: CallStats \| None = None` | 按位置解包这四个类型的代码 |
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增 **10 个无默认值 keyword-only 参数**(26 → 36 参) | 任何自建 recorder——且**在装配期当场报错**,不再是运行期静默丢行 |
| 4 | 遥测表 `llm_calls` | 追加 10 列(INSERT 字段 26 → 36,物理列 27 → 37);新增 `event_kind='terminal_failure'` 行 | 按旧口径计失败调用数的 SQL |
第 3 条的装配期报错是有意的:`_record``except Exception` 会把旧签名 recorder 的 `TypeError` 吞成 warning,后果是下游升级后 **100% 丢遥测且调用照常成功**。降级方向的铁律管的是运行期写失败,不是装配错误。`**fields` 形态的 recorder 不受影响。
### 下游必须做的事
| 动作 | 说明 |
| --- | --- |
| **计失败调用改 `WHERE event_kind = 'terminal_failure'`** | 一次逻辑调用恰好一条终态行。`error IS NOT NULL` 跨尝试行与终态行,升级后计数会变大 |
| `AVG(latency_ms)``event_kind` 分组 | 终态行的 `latency_ms` 是整个逻辑调用的总耗时,与单次尝试不同量纲 |
| 自建 recorder 补齐 10 参 | 或改成 `**fields`;同时同步自己的 schema 与 INSERT 字段 |
| PG manual 档下游补列 | `telemetry_schema_sql("postgres")` 自取;不补则这 10 个维度按现有列裁剪后静默不落库(库发一条点名警告) |
| 注意失败行可能带 `http_status_code = 200` | MonkeyOCR 的 `success != true` 就是 200 下的失败,该列不可当成败判据 |
费用口径**不变**:终态行 `cost IS NULL``usage_source='unavailable'`、token 为 0,不参与 `SUM(cost)`。400 密集负载下错误行会翻倍(既有 attempt 错误行 + 新增终态行),这是已批准的下游可见变化。
### 其他
- 新列全部可空、无默认值、追加在现有末列之后;**旧行不回填**(NULL 表达「补列之前根本没记过这件事」),滚动升级期新旧进程可写同一张表。
- `error_body` 沿用 `summarize_body` 上限,结构化耗尽终态行的 `error` 带有界说明(不含模型原始正文);**两者都不在 `PGW_TELEMETRY_TEXT_CAP` 覆盖面内**。
- 修正历史误标:`embed()` 非 200 的失败现在报 `operation="embedding"`。遥测新列 `operation` 由调用点给定,与 `exc.operation` 是两个语义。
- 重试预算与退避、429 免预算与 stall 算法、取消结算、限流/熔断语义、缓存 key 公式一字未改。
## 1.3.4(2026-09-09)
> [!WARNING]
> **patch 版号不代表无迁移成本。** 已登记但不含 AUTO 的 True/auto 配置及受管+raw 双来源现在明确拒绝;受影响缓存必须在首次新语义读写前显式换 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)
推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。
@@ -196,7 +243,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了
- `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。
- 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。
## 1.3.0(2026-08-24)
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
@@ -265,7 +311,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了
- SQLite 遥测初始化失败后终于有日志了。此前 `sqlite.py` 初始化失败直接 `return`,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版**只做可见性**,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。
- 写入路径不再用 `async with pool.acquire(...)``Pool.release()` 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。
## 1.2.4(2026-08-20)
熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。
@@ -287,7 +332,6 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
## 1.2.3(2026-08-19)
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
+2
View File
@@ -103,6 +103,8 @@ make ci # 只读验证(check + test)
Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。
发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。
### 4.5 配置管理
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
+83 -2
View File
@@ -20,7 +20,8 @@
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 26 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 36 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:四种响应(chat / embedding / OCR 两种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断) |
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded``dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
@@ -30,13 +31,91 @@
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `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)。
## 1.3.5 逻辑调用统计与失败诊断
> [!WARNING]
> **升级后失败行会变多,旧的"失败调用数" SQL 会多数。** 每次领域失败除逐次尝试行外另写一条 `terminal_failure` 行;自建 recorder 若未跟进 10 个新参数,会在**装配期**就报错(不是运行期静默丢行)。
本版把治理单位从"一次尝试"补齐到"一次逻辑调用"。两件事: 四种响应带上 `call_stats`;遥测表补 10 列并新增一类行。
### 读 `call_stats`
```python
resp = await client.chat([{"role": "user", "content": "hi"}])
stats = resp.call_stats # EmbeddingResponse / OcrTextResult / OcrLayoutResult 同名字段
stats.logical_call_id # 一次逻辑调用一个 ID:重试/换源/重问/分批共享
stats.attempts # 真实打出去的尝试次数(免预算 429 也计;缓存命中为 0)
stats.total_latency_ms # 含缓存 IO、退避、准入等待、重问与内联遥测收尾的墙钟
```
`call_stats``None` 意为**未知**(如第三方合成的响应、或旧缓存条目回放),不得读成 0。失败时异常对象上**不挂任何统计字段**——要归因请查遥测表。
### 下游 SQL 迁移五项
| # | 改什么 | 理由 |
| --- | --- | --- |
| 1 | 计失败调用改成 `WHERE event_kind = 'terminal_failure'` | 一次逻辑调用恰好一条终态行 |
| 2 | `error IS NOT NULL` 不再是失败调用的判据 | 它同时命中尝试错误行与终态行,升级后计数变大 |
| 3 | `AVG(latency_ms)` 须按 `event_kind` 分组 | 终态行的 `latency_ms` 是**整个逻辑调用**的总耗时,与单次尝试不同量纲 |
| 4 | 费用口径**不变** | 终态行 `cost IS NULL``usage_source='unavailable'`、token 为 0,不参与 `SUM(cost)` |
| 5 | 失败行可能带 `http_status_code = 200` | MonkeyOCR 的 `success != true` 就是 200 下的失败,该列不可当成败判据 |
归因查询的典型形态: 一条 `WHERE logical_call_id = :lcid` 同时拿到整池终态原因(`terminal_failure` 行的 `error` / `error_type`)与逐源现场(`attempt` 行的 `source_name` / `http_status_code` / `cause_type` / `error_body`)。**终态行的 `http_status_code`/`cause_type`/`error_body` 恒为 NULL**: 拿最后一个源的现场冒充整池归因是错的。
### 存储侧升级
新增 10 列全部可空、无默认值、追加在现有末列之后(`scope``operation``logical_call_id``event_kind``http_status_code``error_type``cause_type``error_body``attempts``total_latency_ms`),INSERT 字段 26 → 36、物理列 27 → 37。auto 档自动追加;manual 档一条 DDL 不发、按现有列裁剪写入并点名缺列(补列 SQL 由 `telemetry_schema_sql("postgres")` 自取)。**旧行的新列一律 NULL 且不回填**——NULL 表达的是"补列之前根本没记过这件事";滚动升级期新旧进程可写同一张表。
自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。
## 安装
发布在实验室 Gitea PyPI(公开包,匿名可装):
```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]>=1.3.0,<2"
"polygateway[redis,postgres,structured]>=1.3.5,<2"
```
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
@@ -357,6 +436,8 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
**1.3.5 新增的两处诊断文本同样不在 cap 覆盖内**:`error_body` 记网关响应正文,沿用库内既有的 `summarize_body` 上限(与 cap 无关,也不随它变化);`error` 在结构化耗尽的终态行上带一段有界说明(修复原因至多 200 字符 + 至多 3 条校验错误 × 200 字符,**不含模型原始正文**),自带独立限长。判断留存面时这两列按各自上限估算,不能算进 cap 的四处。
### 7. SQLite 侧的保留期
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db``runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "polygateway"
version = "1.3.3"
version = "1.3.5"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
+36 -4
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》,是否有更好的指导思想"。结论:那本书为应用程序而写,库没有"用例层",硬套四层会造出空转抽象。对库更适配的思想来源:
- **Hexagonal / Ports & Adapters**(Cockburn):三项目已在实践的本质。
- **《A Philosophy of Software Design》(Ousterhout)的"深模块、窄接口"**:接口复杂度是用户付的成本。落地为——90% 用户三行起步(`from_env()``chat()`),全部可配置性经构造函数暴露给需要的人,但绝不强迫简单用户理解。
- **中间件洋葱**:与治理栈天然同构。反面证据:三项目的 `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,而深路径引用正是模块重组会打断下游的原因。
**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 铁律)
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
@@ -391,6 +396,8 @@ flowchart TB
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
| `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 在字段设计上的落法。
判据取 `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 响应缓存
**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:`
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
@@ -553,15 +562,38 @@ flowchart TB
### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范;当前 26 个 INSERT 字段,物理表列 27 = 26 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**。
**必录字段**(继承三项目 15 字段规范;当前 36 个 INSERT 字段,物理表列 37 = 36 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(前者由 TelemetryMW 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
**`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。
**`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 不变。
**逻辑调用十列(2026-09-09,issue #19/#23,端口 26 → 36)**: 本版把治理单位从"一次尝试"补齐到"一次逻辑调用"。十列按同一顺序追加在 `reasoning_effort` 之后,全部可空、无默认值、不回填旧行(NULL 表达的是"补列之前根本没记过这件事",与任何哨兵值不同;这一点与 `tenant_id` 故意相反——后者是 RLS 可见性需要哨兵,前者是归因需要区分真实缺口)。
| 列 | 语义 |
| --- | --- |
| `scope` | 池名,`TelemetryEmitter` 构造期注入(三个 client 各一行),三类行都带;**不拿 `source_name` 顶替**(终态失败可能根本没选出源) |
| `operation` | `chat` / `embed` / `recognize_text` / `parse_layout`,**由调用点给定**。它与 `PolyGatewayError.operation` 是两个语义: 后者是异常自报的出错环节(可为 `download_result` 这类子步骤),链路上任何位置不得读它来填本列 |
| `logical_call_id` | 一次逻辑调用一个 ID;上下文缺席(库内现场构造的请求)落 NULL,**不造 ID** |
| `event_kind` | 三态: `attempt` / `cache_hit` / `terminal_failure`,三类行的唯一机械判据 |
| `http_status_code` / `cause_type` / `error_body` | 只在失败的 `attempt` 行上非空(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了");终态行三列恒 NULL(**C1 红线**) |
| `error_type` | 该行自身错误的类名;取消路径传字符串,故为 NULL |
| `attempts` / `total_latency_ms` | 只属终态行(同一份冻结快照,与该行 `latency_ms` 同值),其余两类行 NULL |
两条不变量: **I3** —— 每次领域失败至多一条终态行(`_CallContext.claim_terminal()` 去重;recorder 写失败仍只 warning,故 SQL 可见 ≤ 1);**I4** —— 非领域异常(编程错)**零条**终态行、原样传播、分类不被改写。终态行仍 `cost=NULL``usage_source='unavailable'`、token 0,费用聚合口径不变。诊断值的提取收敛在 Emitter 内一个纯 helper(`_error_fields`),只读领域异常的既有属性,**不遍历任意对象、不解析字符串猜诊断**。另立**装配期形状闸**: 旧签名 recorder 在构造 `TelemetryEmitter` 时当场 `ValueError`——降级方向的铁律管的是**运行期写失败**,装配错误放行的后果是下游 100% 丢遥测且调用照常成功。
**`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。
@@ -571,7 +603,7 @@ flowchart TB
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"``text``response``thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"``text``response``thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。**1.3.5 新增的两处诊断文本同样不在 cap 覆盖内**: `error_body` 沿用 `summarize_body` 的既有上限,结构化耗尽终态行的 `error` 说明自带有界限长(`repair=` 至多 200 字符 + `validation=` 至多 3 条 × 200 字符,**不含 `raw_text`**——模型正文的预算已由 attempt 行的 `response` 列承担)。两段限长常量与 `StructuredMW` 重问反馈共用同一份(`format_bounded_errors`),数值只有一份定义。
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
@@ -1,5 +1,7 @@
# 推理档位一等化设计(issue #20 及其一般形式)
> **替代指针(2026-09-09**:§3–6/8/12 的 AUTO 无条件放行、MiniMax 内置 medium、受管 raw 覆盖与缓存迁移/遥测总括语义,以[1.3.4 已批准设计](2026-09-09-134-thinking-contracts-design.md) §4–8 为准。历史调研与实验事实保留,不倒改为新语义已验证。
- **日期**: 2026-09-04
- **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门)
- **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过
@@ -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,202 @@
# 1.3.5:逻辑调用统计与结构化失败诊断
> 状态:独立审查及定向复审通过,**人类已于 2026-09-09 正式批准**(§10 六项批准项全数获批,可据此实施公共 API)。实施计划见 `research-wiki/plans/2026-09-09-135-call-observability.md`
> 日期:2026-09-09。范围:issue #19#23。基线源码 HEAD e71a623。
> 复用现有 Emitter / schema / 三条治理循环,不引入追踪平台、不做 deadline / hedging、不在库内复制上下游关联状态。
## 1. 问题与边界
治理单位是一次逻辑调用,但当前 `latency_ms` / `call_id` 只描述**单次尝试**:重试几次、等了多久、缓存有没有参与,SQL 答不出来。诊断侧,transport 抛出的领域错误已带 `status_code` / `operation` / `body_text`(errors.py:52),进 emitter 前却被 `str()` 压平成一列自由文本。终态失败可能没有选中源,但 **scope 始终已知**
中转把供应商 529 改写成 503 后,库只能如实记 503,不能猜回 529;未知成本、未知状态一律 NULL。本版不改重试预算、429 语义、stall、推理能力表与取消结算算法,不新增核心依赖。
## 2. 方案
| 方案 | 收益 | 代价 |
| --- | --- | --- |
| A:只在 RetryMW 加两个计数、错误列细分 | 补丁最小 | 漏缓存命中、结构化重问、embedding 分批与三类失败终态,回答不了"整次调用" |
| B:每调用局部统计 + 领域异常下沉到单一出口,复用现有遥测行 | 统一边界、字段保真、不默认加成功行、不双计费用 | 增加内部上下文、响应字段与 recorder 字段;须补齐缺失的失败终态行 |
| C:新增独立逻辑调用表 / 通用事件端口 | 完整追踪与任意事件分析 | 新存储与运维面,超出当前需求 |
推荐 **B**。显式传递局部对象,不用 client 共享可变计数、不用模块级 ContextVar;不统一三条治理循环,只统一计数与诊断出口。
## 3. 逻辑边界与公开统计
四种响应(`LLMResponse``EmbeddingResponse``OcrTextResult``OcrLayoutResult`)追加 `call_stats: CallStats | None = None`。新增 frozen `CallStats` 并由包根导出——四份平铺字段会立刻漂移。
| CallStats 字段 | 语义 |
| --- | --- |
| `logical_call_id: str` | 每次公开调用一个 UUID;重试、重问、分批共享;不占用既有 `parent_call_id` |
| `attempts: int` | 准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝;**不是 HTTP 请求条数** |
| `total_latency_ms: int` | 从输入校验通过到返回/异常传播前的单调时钟快照;含缓存、等待、重问、分批、内联记账与资源收尾 |
输入校验异常发生在统计边界之外,保持原行为。第三方合成响应的 `None` 表示未知,不得默认伪造 0。
- **缓存命中**:`attempts=0`、新 logical ID、本次缓存路径耗时;不回放历史统计。缓存持久化排除 `call_stats`(`_serialize` 显式剔除),`_rehydrate` 显式覆盖为 `None`——`_RESPONSE_FIELDS` 过滤会放行历史 dict,不覆盖就会有 dict 冒充 `CallStats`。缓存 key 白名单不变。
- **embed 空输入**(embedding.py:180-190):合法零尝试,返回真实统计(`attempts=0`),**不写任何遥测行**——与 cache_hit 不同,不要按"必录"推断它有台账行。
- **OCR layout** 的 POST + ZIP GET 在同一 transport 调用内(monkey_ocr.py:262-296),计 1 次尝试。
- **chat 重问 / embedding 多批**计入同一上下文,不重置计数(重问经 `call_next` 重入 RetryMW,已核对)。
`latency_ms` / `call_id` / `parent_call_id` 语义不变;"总耗时减最后一次尝试耗时"不等于纯等待(含其他本地工作)。不在响应里挂每次尝试的明细列表,避免公共响应无界增长。
### 3.1 失败与取消
本版**不向异常对象附加可变 `call_stats`**:第三方可能复用同一异常实例,first-write-wins 会把首次调用的统计误读成本次,覆盖写则串扰;复制任意异常又保证不了构造签名与自定义属性。异常类型与分类原样保留,失败侧的统计走 §6 的终态行。
- 取消:`CancelledError` 保持原类型与语义,不在其上加字段;取消路径只**尽力**写一条终态,不 shield、不开新后台任务。
- 任意内部非领域异常(编程错)原样传播,本版**不承诺**为其提供任何统计或终态行,也不偷偷改分类。
## 4. 最小内部接缝
`CallStats` 与私有可变 `_CallContext` 都落 `types.py`:`types.py` 不反向依赖实现层,不产生循环,也不动 import-linter 分层(`ports : types : errors` 并列最内层)。私有上下文只持计数、单调时钟与必要去重状态,不做 I/O。
`ChatRequest` 追加内部上下文字段(`default=None, compare=False, repr=False`);`StructuredMW``dataclasses.replace` 保留同一引用(structured.py:96-110,已核对)。响应统计只在公共出口经 `replace` 附加。`GatewayClient` 需自存注入的 `now`(client.py 现未保存),**冻结快照是同步动作,不 await**。
- 上下文创建/冻结:`GatewayClient` 在公开入口创建、`finally` 冻结;`RetryMW` 只在 transport 调用前登记一次尝试。
- **OCR 例外(M1)**:`image` 的类型/空校验在 `_call` 内(ocr.py:238-241)而非公开方法,故上下文在该校验**通过之后**创建,§3 的"校验在边界外"对 OCR 才成立。
- `EmbeddingClient` / `OcrClient` 显式把同一上下文传到每批/每次尝试;统计生效与否**不由 telemetry 是否启用决定**。
- 每次尝试 ID 仍在当前循环产生,与上下文的 logical ID 一起交给 Emitter。不持久化断点,不引入任务恢复。
## 5. 诊断字段与归因方式
保留现有 `error` 字符串供人阅读;Emitter 改为接受**领域异常对象**而非调用方先 `str()`,由单一 helper 提取有限诊断字段。普通超时/网络翻译在 transport 侧保留直接 `__cause__` 类型,空 `str()` 退回类名;**不遍历任意异常对象、不猜测正文**。
| 新增 INSERT 列 | 值域 / 来源 |
| --- | --- |
| `scope` | 配置池名,构造期注入 Emitter(见下);不拿 `source_name` 顶替 |
| `operation` | **公开方法固定四值**:`chat` / `embed` / `recognize_text` / `parse_layout`;由三个 client 在调用点给定 |
| `logical_call_id` | 当前调用上下文的 UUID;上下文缺席(库内现场构造的 `ChatRequest`)→ **NULL,不造 ID** |
| `event_kind` | `attempt` / `cache_hit` / `terminal_failure`;旧行 NULL,不回填 |
| `http_status_code` | **仅 attempt 行**:失败异常实收状态;无 HTTP 或未知 NULL;成功行不统一填 200 |
| `error_type` | 该行自身错误的领域类名;成功行 NULL |
| `cause_type` | **仅 attempt 行**:transport 直接捕获的底层异常类名,未知 NULL |
| `error_body` | **仅 attempt 行**:既有 `summarize_body` 有界摘要,未知 NULL;不存 `raw_text`、不存全量原文 |
| `attempts` / `total_latency_ms` | **仅 terminal_failure 行**填逻辑快照,其余行 NULL;快照在写入前冻结 |
全部可空,追加到物理列末尾(与旧表 ALTER 追加位置一致,`schema.py` 的 DDL / BACKFILL / COLUMNS 三处同改)。
**归因方式(C1,决定性)**:`GatewayUnavailableError` 家族从不携带 `status_code` / `body_text`(errors.py:118-166),终态行的 `http_status_code` / `cause_type` / `error_body` 因此**保持 NULL,这是它自身的真实状态**——不把最后一次 attempt 的状态码与正文搬上来伪装成整池诊断(那正是"不拿最后一个源冒充整池归因"的同一条红线)。终态行的 `error_type` 落它自己的类名(`AllSourcesExhausted` / `CircuitOpenError` / …),scope 级 reason 沿用**已有 error 文案**(`str(exc)` 已是 `"{scope} 网关暂时不可用: {reason}"`,不新增列)。逐源现场由同一 `logical_call_id` 的 attempt 行给出。
```sql
-- #19 验收:一次逻辑失败调用的完整现场(终态 + 各次尝试)
SELECT event_kind, source_name, http_status_code, error_type, cause_type, error_body, error
FROM llm_calls WHERE logical_call_id = :lcid ORDER BY created_at;
```
该查询必须同时给出"整池为何失败"(终态行 error 文案)与"每个源怎么死的"(attempt 行状态码/正文),测试按它断言。因此**本版不再为 `reason` / `per_source_reasons` 扩列**。
**结构化耗尽的可定位性(C2)**:`ResultInvalidError("结构化输出阶梯耗尽")` 的 message 不含 `validation_errors` / `repair_error`(structured.py:80-86),而该失败发生在 StructuredMW 之上——RetryMW 侧的 attempt 行全是成功行,终态行是唯一记录。做法:**不写 `error_body`**(该列只属 attempt),由 Emitter 的单一 helper 对 `ResultInvalidError` 生成**有界结构化说明**并入现有 `error` 字符串,复用 structured.py 已有的取材口径(至多 3 条、每条 200 字符,与 `_format_errors` 同参数,拼装函数收敛在 helper 一处)。`raw_text` **不重复落库**(它是模型正文,attempt 行的 `response` 列已按 `text_cap` 记过一份;再存一份等于绕过既有正文预算)。该说明可能包含模型输出片段,故遵循与 `error` 现有正文相同的隐私边界,不额外扩大保留范围。
**operation 的数据源(I1/I2)**:openai_compat.py:169 的 `_status_to_error` 硬编码 `operation="chat"`,而 `embed()` 的非 200 分支(:512)也走它 → 现存所有 embedding HTTP 失败的 `exc.operation` 都是错的。(行号勘误 2026-09-09:本句原写 "(:512、:527)",实测 `:527``_complete_stream`即流式 chat,`embed` 只有 `:512` 一处;归属以实施计划 §2 表为准,决策未变。)修正:`_status_to_error` 增 keyword `operation`,`embed`**`"embedding"`**(沿用该异常侧既有词表,不改 `chat` / `ocr_text` / `parse` / `download_result` 四值)。新列 `operation``exc.operation` 是**两个语义**:前者是公开方法,后者是 HTTP 子操作;新列由调用点给定,**绝不读 `exc.operation`**,两者不做自动转换。OCR 两个公开方法各自在调用级给定自己的值。
**scope 注入(M6)**:`TelemetryEmitter` 现在不知道 scope,`CacheMW` / `TelemetryMW` 自己也拿不到。构造期注入(三个 client 各一行),使 attempt / cache_hit / terminal_failure 三类行都带 scope,避免改三条调用链。model / provider / source 未选出时仍留原空值。
**成功侧不加承诺**:成功行不承诺 HTTP 状态与错误体;**本版不宣称 SQL 可直接统计所有成功逻辑调用的总耗时**(成功不加终态行)。`error_body` 沿用 `summarize_body` 的既有上限,**不纳入 `PGW_TELEMETRY_TEXT_CAP` 覆盖面**(该键现覆盖四处,详见 §8)。
## 6. 行语义与终态:只补确实缺失的失败
保留每次 attempt 与 cache_hit 的既有行,**不为成功新增终态行**。终态行不得复制已有 attempt 的 token 与成本。所有统计边界内的领域失败均尝试写终态,**包括已有 attempt 错误行的直接 RequestRejectedErrorResultInvalidError**;不再沿用“该类错误已录所以外层不录”的旧假设,400 密集负载的错误行可能翻倍,调用失败计数必须只取 terminal_failure。
**不变量(I3,统一措辞)**:
| 结束形态 | 终态行数 |
| --- | --- |
| 以**领域错误**结束的逻辑调用 | 每次调用**尝试写一条**;持久化 best effort(recorder 写失败按既有降级只落 warning),故 SQL 可见行数 ≤ 1 |
| 取消 | 三个 client **同策略尽力写一条**,允许 0 条 |
| 非领域异常(编程错) | **0 条**,原样传播,本版无统计保证 |
需要补的路径:chat 结构化耗尽(发生在 transport 成功之后,现无任何失败行)、embedding / OCR 的无源、准入拒绝、重试耗尽与尝试外取消。**取消口径统一(I4)**:chat 现由 TelemetryMW 对任何取消补终态(telemetry.py:487-495,含尝试内取消),embedding / OCR 按同一口径尽力补,避免下游按 `event_kind` 统计取消时拿到路径相关的结果。
终态与 attempt **不是重复事实**(前者描述逻辑终态,后者描述尝试),用 `event_kind` 区分;**禁止按 `error IS NOT NULL` 跨两类直接计失败调用次数**。chat 现有 TelemetryMW 终态路径收敛到公开边界的单一 helper,避免两处同时写;embedding / OCR 复用该 helper。
**终态行的请求摘要(M4)**:复用既有 200 字符输入摘要口径,描述**本次调用的整体输入**,但不扩大单行正文预算——embedding 终态取 `<embed texts=N batches=M>` 计数占位 + 第一批(至多 `batch_size` 条、每条 200 字符,与逐批行同款构造);OCR 终态沿用 `<ocr:{kind} image_bytes=…>` 占位,图像 bytes 永不入库。**失败批的具体文本由同 `logical_call_id` 的 attempt 行给出**,终态行不保存全量原输入。
**错误文本口径(I7)**:OCR 现落 `"类名: msg"`(ocr.py:445-449,按类名归组的既有 metric 口径),chat / embed 落裸 `str(exc)`。改成"Emitter 收异常对象"后,该差异由统一出口的**显式文本策略参数**保留(OCR 保留类名前缀),**不再在三处复制参数列表**。
**取消时的终态写(残余风险,显式定策)**:该 `await` 本身是新的取消点。策略是**取消优先、不屏蔽**:外部取消落在这一 await 上时,`CancelledError` 照常传播(调用方可能因此看到 `CancelledError` 而非领域错误,与 TelemetryMW 现有行为同款);不 shield、不建新后台任务。冻结统计快照是同步动作,不 await。
终态行成本 NULL、usage `unavailable`;聚合费用仍只由 attempt / cache_hit 行决定。终态快照在写入前冻结,故不含自身写入耗时;成功响应快照包含其返回前已完成的内联遥测耗时。失败异常不附快照,不为对齐再 UPDATE 旧行。
取消 attempt 既有字符串 `"cancelled"` 保留:Emitter接收 `PolyGatewayError | str | None`,字符串不解析猜测诊断,error_type/cause_type/http_status_code/error_body均NULL;终态取消同样使用明确取消文案。终态既有latency_ms与新增total_latency_ms取同一冻结快照,避免双时钟微差。
## 7. recorder 兼容:装配期机械闸(C3)
`TelemetryEmitter._record``except Exception` 会把旧 recorder 的 `TypeError` 吞成 warning(telemetry.py:463),后果是自定义 recorder 在下游升级后**100% 丢遥测且调用照常成功**——正是"遥测必录"要防的形态。文档级迁移清单挡不住它。
机械闸:在 `TelemetryEmitter.__init__`(三个 client 的唯一汇流点,与 `text_cap` 值域校验同处)对 `recorder.record_llm_call` 做**一次** `inspect.signature(...).bind(**<完整新 kwargs 形状>)`,**不执行写入**;含 `**kwargs`(`VAR_KEYWORD`)者自动通过。校验失败 → 装配期抛错。若目标不可 inspect(C 实现等),同样按**配置错误**当场报错,不进入"运行期静默丢行"。
边界诚实声明:签名 bind 只证明该形状能被接受,**不能证明函数体真的落这些列**;这是一道装配闸,不是行为验证。它既不是被否掉的 `runtime_checkable` 判定,也不是捕 `TypeError` 重试写入。是否扩主 `TelemetryRecorder` Protocol(备选:独立扩展端口保留旧实现)是 §10 的人类批准项;本草案不同时实现两套接口,自带两个 recorder 的 `**fields` 签名可接受新增参数,但其 schema、INSERT 字段与契约测试仍须同步,不能称后端完全不受影响。
校验所用参数名从 `TelemetryRecorder.record_llm_call` 的协议签名派生,不手抄第四份字段清单;只用哨兵值做bind形状校验,不读取真实请求数据。结构化说明的限条数/限长复用现有常量,若需命名常量则在既有规则所有者中定义并由两个消费者引用,不复制数值。
## 8. 下游可见变更与文档同步
**SQL 迁移清单(I6,批准项须按此逐条看)**:
| 影响面 | 变化 | 下游动作 |
| --- | --- | --- |
| 失败行数 | 新增 `terminal_failure` 行(每失败调用至多 1) | 计失败调用改 `WHERE event_kind = 'terminal_failure'` |
| `error IS NOT NULL` | 同时命中 attempt 与 terminal 两类 | 不再作为"失败调用数"的判据 |
| `AVG(latency_ms)` | terminal 行携带**逻辑总耗时**,量级大于单次尝试 | 时延看板一律按 `event_kind` 分组或过滤 |
| 费用聚合 | terminal 行 `cost` 恒 NULL、usage `unavailable` | 费用仍只由 attempt / cache_hit 行决定,口径不变 |
| 成功侧 | **不加**任何成功汇总行 | 成功逻辑调用总耗时仍从响应 `call_stats` 读,不从 SQL 读 |
| `http_status_code` | 失败行上可能是 200(monkey_ocr.py:265-270 的 `success != true` 带 200 上抛) | 该列不可作失败判据 |
**文档同步(M5,发布前必须同批)**:README:23 的"必录 26 字段"、README:399 与 ARCHITECTURE.md:592 的 `PGW_TELEMETRY_TEXT_CAP` 覆盖面四处枚举(须明确:`error_body` 沿用 `summarize_body` 上限,`error` 保留既有文本口径并仅对新增结构化说明限长;二者**不在 cap 覆盖内**)、ARCHITECTURE.md:565 的必录字段清单与 §7.8 补列一节、`.env.example` 相关注释、CHANGELOG 与 wiki(docs-convention §2)。**字段数与物理列数一律以 `inspect.signature` / `len(COLUMNS)` 实测改写,不凭记忆**(现状:26 个 INSERT 字段 + `created_at` = 27 物理列;本版新增 10 列)。
## 9. 非功能与测试矩阵
| 维度 | 要求 |
| --- | --- |
| 并发 | 每调用独立对象;同一 client 并发不串 logical ID / 计数 / 统计;无全局状态 |
| 取消 | 各等待点穿透;`finally` 释放既有资源;终态写取消优先;不新增 shield 与后台任务 |
| 降级 | recorder 写失败不改统计与主结果;准入后端仍 fail-closed;收尾失败仍原 warning |
| 持久化 | schema 单一事实源;SQLite auto / PG manual 裁剪 INSERT 保持;不 ALTER 默认生产 PG、不改旧列、不回填旧行 |
| 幂等 | attempt ID 唯一,终态独立 ID,不重复写同一终态;缓存命中不复制历史统计;不 UPDATE 计费 |
验收优先离线:真实 client + 内存后端 + FakeClock + MockTransport + 临时 SQLite,复用 1.3.4 设施;不重跑未变的模型能力矩阵。
| 测试族 | 必须证明 |
| --- | --- |
| logical 计数 | 一次成功、失败重试、免预算 429、多源拒绝、缓存命中、结构化重问、embedding 多批、OCR 双 HTTP、空输入(0 尝试且 0 遥测行) |
| 计时 | 缓存 IO、退避、准入等待、重问、收尾均计入;关闭 recorder 仍正确;毫秒/秒不混用 |
| 失败与取消 | 领域失败恰一次尝试写终态;取消三条路径同策略(允许 0 行);**终态写 await 上被取消 → `CancelledError` 传播**;permit / 探针释放不变;非领域异常 0 行且原样传播 |
| 保真诊断 | 503 包装不改回 529;直接 529 记 529;空 Connect/Read/Write/PoolTimeout 文案有类型;embedding HTTP 失败的 `exc.operation``embedding`;新列 `operation` 恒为四值之一且不随异常变化 |
| 归因 SQL | §5 那条按 `logical_call_id` 的查询同时给出终态 reason 文案与逐源状态码/正文;终态行三列为 NULL;结构化耗尽的 `error` 含有界 validation/repair 说明且不含 `raw_text` |
| 行语义 | 三类行均带 scope;每失败调用至多一条终态;attempt 与 terminal 区分;费用不重复;`AVG(latency_ms)``event_kind` 分组的断言 |
| 装配闸 | 旧签名 recorder → 装配期报错(非 warning);`**kwargs` recorder 通过;不可 inspect → 配置错误 |
| 存储兼容 | SQLite 新旧表、PG manual 缺列裁剪 / auto 追加、旧行 NULL、新旧进程混写 |
| 变异 | 计数位置、上下文复制、缓存历史回放、提前 `str()` 压平、终态双计费用分别红→绿;PG 真实集成 + 常规全套 + 独立验证 |
## 10. 集中人类批准项
| # | 决策 | 推荐 |
| --- | --- | --- |
| 1 | 公开响应结构 | 一个 `CallStats` 对象而非四类响应各铺三字段;需确认命名与消费便利性 |
| 2 | 失败侧统计范围 | 不改/不复制异常;失败统计只落终态行,调用方仅在成功响应读 `call_stats`;需确认该取舍可接受 |
| 3 | recorder 兼容路线 | 扩主 `TelemetryRecorder` Protocol + 装配期 bind 闸;备选独立扩展端口保留旧实现;需确认是否存在必须兼容的自定义 recorder |
| 4 | 新增失败终态行 | 补漏但不加成功汇总;须批准 §8 表中**全部五项**下游可见变化(不止行数) |
| 5 | 取消时终态写取消优先 | 调用方可能看到 `CancelledError` 而非领域错误(同 TelemetryMW 现状);需确认接受 |
| 6 | `operation` 值域与异常侧修正 | 新列固定四值;`_status_to_error``operation` 参数、`embed``embedding`(修正现存误标,属下游可见的历史数据口径变化) |
## 11. 独立审查处理表
| 项 | 结论 | 落点 |
| --- | --- | --- |
| C1 终态缺诊断来源 | **不采纳"定向读 `__cause__` / `per_source_reasons` 扩列"**;终态保留自身 NULL 状态,归因由 `logical_call_id` 关联 attempt 行 + 既有 reason 文案完成,并写死 SQL 验收 | §5 |
| C2 结构化耗尽可定位 | 采纳(变形):有界说明并入现有 `error` 字符串,不写 `error_body`、不重复存 `raw_text` | §5 |
| C3 recorder 静默失败 | 采纳:装配期一次 `signature.bind` 形状校验,含 `**kwargs`,不可 inspect 即配置错误;明确不验证函数体 | §7 |
| I1 / I2 operation 污染与未归一 | 采纳:新列固定四值由调用点给定,绝不读 `exc.operation`;只修 embedding 误标,其余异常侧词表不动 | §5 |
| I3 / I4 终态不变量与取消口径 | 采纳(定稿措辞):领域失败每调用尝试写一条、持久化 best effort;取消三路同策略尽力允许 0;非领域异常 0 条 | §6 |
| I5 上下文缺席语义 | 采纳:`None` → NULL,不造 ID | §5 |
| I6 迁移影响不止行数 | 采纳:列全五项 SQL 影响并进批准项 | §8、§10 |
| I7 OCR error 文本口径 | 采纳:保留类名前缀,由统一出口的显式文本策略参数承载,不复制参数列表 | §6 |
| M1 OCR 校验与统计边界 | 采纳:上下文在 `image` 校验通过后创建 | §4 |
| M2 embed 空输入 | 采纳:0 尝试且不写任何遥测行 | §3 |
| M3 失败行可能带 200 | 采纳:写明该列不可作失败判据 | §8 |
| M4 终态请求摘要未定 | 采纳:复用 200 字符口径,终态描述整体输入,不扩预算,不存全量原输入 | §6 |
| M5 文档同步缺项 | 采纳:列全六处并要求实测改数字 | §8 |
| M6 Emitter 不知 scope | 采纳:构造期注入 | §5 |
| 残余风险(终态写成新取消点) | 采纳:显式定策"取消优先不屏蔽",并进测试族与批准项 | §6、§9、§10 |
自审:方案 B 复用既有 Emitter / schema 与三条循环,不需要 #22 / #24 的新调度。未采纳的两项(异常 first-write-wins、终态搬运最后一次 attempt 的状态与正文)理由已写在正文,不是遗漏。本文档无代码实现与测试通过声明;**§10 六项已于 2026-09-09 获人类批准**,实施边界与红绿证据要求以上述实施计划为准。
@@ -0,0 +1,268 @@
---
type: finding
node_id: finding:2026-09-09-134-thinking-contracts-validation
title: "1.3.4 推理契约验证与发布准备"
date: 2026-09-09
---
# 1.3.4 推理契约验证与发布准备
> 最新状态(2026-09-09):**1.3.4 已发布并完成外部验收,#21/#25/#26 已评论关闭**。main/tag 指向 `af57f93adce24b43fd10b6d8e1281ab8ee43c0a8`;合并后门、下载独立安装及页面结果见文末。用户批准的未补全矩阵、FAIL/UNKNOWN/不可达/缺轮及缺下游配置例外保持原结论,不冒充 PASS。以下为分阶段历史,不追改当时结论;原始输出在 `tests/outputs/134/`,不提交。
## 基线与修改边界
| 项目 | 实际证据 |
| --- | --- |
| 起点 | `6a09054`,保留既有两个本地测试提交,工作区仅原 `.pi/` 与待提交设计/计划 |
| T0 静态 | `t0-check.log``.exit`make check,退出 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页面检查均尚未执行;只能在实际完成后记录。不得覆盖已有同版本不同字节。
## 1.3.4 发布完成与外部验收(2026-09-09)
父会话已审 `dae12f9` 发布准备 diff,用户授权发布及指定例外;本轮无新生产/测试改动、不派子代理。已有红绿/变异与独立报告按适用范围复用,716份历史证据 SHA-256 复核不变。以下日志均相对 `tests/outputs/134/publish-20260909/`,命令无掩盖退出码管道;长跑由 tmux `pgw134-publish` 串行执行,`PYTHONUNBUFFERED=1`,每门独立 `.exit`
| 步骤 | 实际结果与证据 |
| --- | --- |
| 远端核验/合并 | fetch后 origin/main=`a716f12` 无新变更;精确文件名检查 registry 无1.3.4tag无占用、Release404。`--no-ff` 合并为 `af57f93adce24b43fd10b6d8e1281ab8ee43c0a8`,与批准 `dae12f9` 树零差异;`merge.log``merge-summary.log` |
| 合并后 `make lint` | exit0ruffimport-linter 1 kept、0 broken,无自动修改;`lint.log/.exit` |
| 合并后 `make test` | **1518 passed、23 skipped、108 deselected95%覆盖率,276.42秒,exit0**`daily.log/.exit`。skip与deselected不计通过 |
| M2 AUTO定向 slow | **2 passed、80 deselected46.85秒,exit0**;各5轮、并发1,空wire流式开启命题通过;`m2-auto.log/.exit`。M2.5 run `d4b158162ad543e5a4745d21b29ab03e`M2.7 run `07a388cafa9f4fcf95dc0c6876fd0751`,均 planned=completed=5、无缺轮;实际逐轮/命题报告已读取 |
| Redis时间语义 slow | contracts/integration选择 **18 passed、156 deselected1109.45秒,exit0**`redis-time.log/.exit``gates.exit=0`,未重启全型号矩阵 |
| push与tag | main推送成功;注释tag对象 `0bdb0f70e4667a2fe50a567f0809a5a421c5442a`,解引用同上述main`push-main.log``push-tag.log``remote-refs-after.log` |
| 构建/上传 | 核实项目绝对dist路径且不是链接后清旧1.3.3产物;conda `python -m build``twine check dist/*`、twine上传均exit0wheel/sdist各一份。凭据只从tea内存读取进入TWINE_PASSWORD,无token argv/日志;`build``twine-check``upload``.log/.exit` |
| 独立下载/解包 | registry下载wheel与sdist,与本地构建SHA-256逐字匹配;包内五个关键生产文件与发布提交相同,wheel METADATA版本/Markdown正文、sdist README均与已发布README相符;`download-final.log/.exit``download-verification.json` |
| 独立安装与行为 | `/tmp/pgw134-registry-eyges3d7/installed/polygateway/__init__.py`,仓库外 `python -I` 并显式target来源验证;M2空AUTO、M3 AUTO在error/nearest均拒绝、M3 medium、同值/新增raw冲突与raw-only保留全部通过 |
| Release/包页面 | Release POST201,正文逐字来自CHANGELOG本节。匿名实际读取包页面、单版Release及Releases列表,均HTTP200、正文含迁移/例外;包页面有目标仓库链接,认证包API的 repository.full_name=`iomgaa/PolyGateway``pages-final.log/.exit``external-verification.json`及HTML/可见文本原件 |
| issue收尾 | 仅#21/#25/#26评论后关闭,GET各自核实closed;评论明确验证范围、失败例外、缓存迁移及“不识别所有渠道故障”;`issues.log/.exit``issue*-after.json`。未改#19/#22/#23/#24 |
产物 SHA-256wheel `86ad5cc025bfda127c4f0e4bbe1252d1bb8943b7678f9ff5561200bac71fe2a2`sdist `85620f993ff9d19845088a071d251049e3dfae8d035c05652bdb62b4340a1a66`
外部亲查地址:[包页面](https://gitea.iomgaa.online/iomgaa/-/packages/pypi/polygateway/1.3.4)、[v1.3.4 Release](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.3.4)、[Releases列表](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases)。结果补记在独立 `docs/1.3.4-release-evidence` 分支,不改变已发布main/tag或覆盖同版本产物。
### 真实失败与恢复(原件保留,不改写成功)
| 检查失败 | 根因/处置与边界 |
| --- | --- |
| 最初索引grep误报占用 | `1.3.4` 被当正则匹配历史sha片段;改用完整字面 `polygateway-1.3.4`,并核对tag/Release均不存在。没有实际同版占用,没有覆盖 |
| 首次sdist下载exit1 | 私有索引无setuptoolspip即使 `--no-deps` 仍启构建隔离;`download.log/.exit`保留。复用已下载wheel,sdist加 `--no-build-isolation` 使用现有conda构建工具后exit0;不新增依赖、不重传包 |
| 包link POST400 | 返回 `invalid argument``release-create.log/.exit`保留exit1;不把400当成功/不猜根因。匿名页面已有仓库链接,认证GET包API证实本版正确关联,故无需解绑重挂;Release创建201不重复执行 |
| 首次页面验证exit1 | 三个匿名页面及Release正文均通过,但额外包API匿名GET401;`pages.log/.exit`保留。仅该API沿现有tea认证GET后200,最终所有页面/API核对exit0;不扩大为发布包匿名不可下载 |
上述操作差异已补入CLAUDE发布清单。conda启动器既有RequestsDependencyWarning仍保留。用户验收例外完全沿前节:embedding503与claude命题仍FAIL、M3非流UNKNOWN及其余未覆盖/缺下游资料未变;本轮成功不外推全矩阵或所有渠道。1.3.4发布任务到此停止,1.3.5由父会话继续。
@@ -0,0 +1,293 @@
---
type: finding
node_id: finding:2026-09-09-135-call-observability-validation
title: "1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵"
date: 2026-09-09
---
# 1.3.5 T2/T3 验收证据
> 最新状态(2026-09-09):**1.3.5 已发布**main 与 `v1.3.5` 同指 `ab00aa4`;§8.6 所列「尚未执行」已由
> [发布完成与外部验收](2026-09-09-135-release-completion.md)逐项关闭。以下为分阶段历史,不追改当时结论。
>
> 范围:**仅 T2(遥测 10 列 / 诊断保真 / scope+operation / 装配闸)与 T3(终态行 / 取消 / 统一出口)**,
> 外加计划 T4 中"签名与列数机械迁移"那一片(与 schema 同批完成,避免先提交 schema 却留写入缺键)。
> **不含** T4 的 PG 集成、变异矩阵与文档同步——另任务承接,缺口见 §5。
> 设计:`designs/2026-09-09-135-call-observability-design.md`;计划:`plans/2026-09-09-135-call-observability.md`
> 基线 HEAD `87c261b`T1 已提交,1419 unit 全绿)。全部命令在 `PolyGateway` conda 环境执行。
## 1. 红绿证据链
TDD 纪律要求"先失败后通过",且红必须是**行为红**而非 import 红。下表每行都对应本会话的真实工具输出。
| # | 阶段 | 命令 | 结果 |
| --- | --- | --- | --- |
| 0 | 基线 | `pytest tests/unit -q` | **1419 passed**(对照底) |
| 1 | 红(T2/T3 目标行为) | `pytest tests/unit/test_telemetry.py -k "RowLevelObservability or RecorderShapeGate"` | **26 failed**,证据 `tests/outputs/135/red-01-emitter-rows.txt` |
| 2 | 实现后全量红面 | `pytest tests/unit -q` | **137 failed / 1308 passed**(机械迁移面暴露),`red-02-after-impl.txt` |
| 3 | 迁移中 | 同上 | 60 → 25 → 8 → 4 failed`red-03`/`red-04`/`red-05` |
| 4 | 绿 | `pytest tests/unit -q` | **1467 passed, 0 failed** |
| 5 | 静态门 | `make check`ruff lint+format + import-linter | **Contracts: 1 kept, 0 broken** |
第 1 步的红是行为红而非 import 红:新用例调用的是**已存在**的 `TelemetryEmitter`
失败形态是"缺 `scope=`/`operation=`/`stats=` 关键字"与"断言的列不存在",不是模块导不进来。
补测阶段另有两次真实红(均由 conda 实跑暴露、当场修正,非噪音):
`test_client.py``NameError: ResultInvalidError / ChatRequest`(漏 import)、
`TestTerminalRowSqlSemantics``'coroutine' object has no attribute 'execute'`async helper 漏 await)。
## 2. 核心要求逐条对应
### 2.1 36 列与 Emitter 完整一致(不留"schema 有列、写入缺键"
一次性同批改完五处列定义 + 端口签名 + Emitter 写入,故不存在中间态。
| 判据 | 证据 |
| --- | --- |
| `len(COLUMNS) == 36`、物理列 37 | `test_telemetry.py::TestSchemaModule::test_columns_and_ddl_are_frozen``TestSQLiteSchemaMode` 断言 23 → 37 |
| 五处列序一致(DDL/BACKFILL×2/COLUMNS | 新建库与 ALTER 追加列序同为 `_EXPECTED_COLUMNS` |
| 端口实测 36 字段、10 新参 keyword-only 且无默认值 | `test_ports.py::TestTelemetryRecorderSignature``inspect.signature` 实测,不凭记忆) |
| **Emitter 实参键集合 == `schema.COLUMNS`** | `TestEmitterRecorderContract` 三入口逐个断言 `set(rows[0]) == set(COLUMNS)` |
| 1.2.1 冻结 INSERT 未被改写 | 按 `_PRE_135_COLUMNS`(26 列)重现原文;全量 36 列另按占位符个数断言 |
装配闸(C3):`_assert_recorder_shape``TelemetryEmitter.__init__` 做一次 `signature.bind`
参数名**从协议签名派生**而非手抄第四份清单——`test_gate_derives_parameter_names_from_the_protocol`
用 monkeypatch 换掉协议后闸自动跟随,证明没有硬编码。旧签名 recorder / 无该方法 / 不可 inspect
一律装配期 `ValueError`(不是 warning:降级铁律管的是运行期写失败,不是配置错误)。
### 2.2 三个 client 的**真实链路**失败终态
不是只测 Emitter,而是驱动真实 client + MockTransport 到落库。
| 链路 | 用例 | 断言 |
| --- | --- | --- |
| chat 重试耗尽 | `TestChatTerminalFailureRows::test_retry_exhaustion_writes_exactly_one_terminal_row` | 3 条 attempt + **恰 1 条**终态;终态 `attempts == 3` |
| chat 结构化耗尽 | `test_structured_exhaustion_writes_the_only_failure_row` | attempt 行**全是成功行**,终态是唯一失败记录;error 含有界 `validation=`/`repair=` 且 < 1200 字符、不含 raw_text |
| chat 400 直拒 | `test_request_rejected_now_has_both_an_attempt_and_a_terminal_row` | 尝试行 + 终态行**各 1**(已批准的行数翻倍);两行共享同一 `logical_call_id` |
| embedding | `test_embedding.py::TestReasonlessTelemetryContract::test_failed_attempts_still_have_null_effort` | attempt 数 == 脚本长度,终态恰 1 |
| OCR 两方法 | `test_ocr_client.py` 同名用例(参数化 `recognize_text`/`parse_layout` | 终态恰 1,且 `operation` 为**公开方法名** |
去重:`test_terminal_row_is_written_once_per_logical_call` 连调出口 3 次,SQL 可见仍 1 条(`claim_terminal`)。
非领域异常:`test_non_domain_exception_writes_no_terminal_row` —— `KeyError` 原样传播、**0 条**终态、分类不被改写。
**双写已消除**`TelemetryMW` 的两个终态分支删除,改由三个 client 的公开边界经
`emit_terminal_once` 统一写出;`TestTelemetryMW::test_scope_level_failure_is_not_written_here_anymore`
`test_cancellation_is_not_written_here_anymore` 锁死"本层不再写终态",防回归双计。
### 2.3 取消口径
- chat 取消:`test_cancellation_writes_at_most_one_terminal_row` —— 恰 1 条,`error == "cancelled"`
`error_type` 为 NULL(**字符串不解析猜诊断**)。
- 三链路同策略"尽力写一条、允许 0"`emit_terminal_once` **不 shield、不开后台任务**
快照冻结是同步动作;写入 await 上再被取消则 `CancelledError` 原样传播(与 TelemetryMW 历史行为同款)。
### 2.4 SQL 不双计(按真实 SQLite 落库断言)
`TestTerminalRowSqlSemantics` 用真实 `SQLiteRecorder` 驱动一次失败 chat 后直接查表:
| 迁移影响 | 断言 |
| --- | --- |
| 失败计数判据 | `error IS NOT NULL`**3**2 尝试 + 1 终态),`event_kind='terminal_failure'`**1** |
| 费用不双计 | 终态行 `cost IS NOT NULL` 计数为 **0**;且 `usage_source='unavailable'`、token 全 0 |
| 时延分组 | 终态 `latency_ms` ≥ 任何单次尝试(含退避),故看板必须按 `event_kind` 分组 |
| 双时钟微差 | 终态 `latency_ms == total_latency_ms`(同一份冻结快照) |
| 逻辑两列归属 | 尝试行 `attempts`/`total_latency_ms` 恒 NULL |
| §5 归因查询 | 同一 `logical_call_id` 同时给出整池 reason 文案与逐源 503 现场;终态 `http_status_code` 为 NULLC1 不冒充) |
### 2.5 诊断保真与 operation 修正
- 中转把 529 改写成 503 → **记 503 不猜回 529**;直接 529 记 529。
- 空 `str()` 的 Connect/Read/Write/PoolTimeout → `cause_type` 落对应 httpx 类名,`error` 退回类名。
- 成功行五列全 NULL(**不统一填 200**)。
- `_status_to_error` 增 keyword `operation``embed()` 非 200 改传 `"embedding"`(修正历史误标),
流式与非流式 chat 两处仍 `"chat"`(按实施计划 §2 归属表,未按设计行号误标)。
- 新列 `operation` 恒为公开方法四值,**绝不读 `exc.operation`**
`test_operation_is_given_by_the_call_site_not_the_exception``exc.operation="download_result"` 反证。
- OCR `"类名: msg"` 前缀由出口的显式策略参数 `class_prefixed_error` 承载,不再三处各拼一遍。
## 3. 有界 validation 说明的单一所有者
`structured.py``_MAX_FEEDBACK_ERRORS`/`_MAX_ERROR_CHARS`/`_format_errors` 改名为
公开的 `MAX_FEEDBACK_ERRORS`/`MAX_ERROR_CHARS`/`format_bounded_errors`
由"重问反馈"与"终态结构化说明"两个消费者共同引用,**数值只有一份**。
行为逐字不变(`test_structured.py` 22 项全绿,含反馈文案用例)。
## 4. 未改动确认(防越界)
`errors.py``admission.py``ratelimit.py``breaker.py``sources.py``thinking.py`
`providers.py``telemetry/sqlite.py``telemetry/postgres.py``transports/monkey_ocr.py`
一字未动——两个 recorder 靠 `**fields` + `schema.COLUMNS` 自动吃到新列。
缓存 key 公式、重试预算与退避、429 免预算、stall 算法、取消结算、推理能力表均未触碰。
唯一顺带修正:`RetryMW.__init__``emitter` 注解由 `object | None` 收紧为
`TelemetryEmitter | None`TYPE_CHECKING 导入,同层不破分层契约),因本轮改了它的 `_emit`
## 5. T4 验收(PG 存储兼容、变异矩阵、文档同步)
基线 HEAD `393f2bf`(1467 unit 全绿),全部命令在 `PolyGateway` conda 环境执行。
### 5.1 PG 存储兼容(真实实验室 Postgres,复用 `pg_sandbox`
机械迁移:`_EXPECTED_COLUMNS` 27 → 37 列、`_record_minimal` 字段字典补 10 键(与单测同款)、
`_PRE_TENANT_COLUMNS` 的缺列集由4 列扩到 14 列。新十列一律由 `_CALL_OBSERVABILITY_COLUMNS` 派生,
两处 manual 档告警的逐字断言改成按 `COLUMNS` 序派生的 `_PRE_TENANT_MISSING_NOTICE`——
另抄一份列名必然漂移,而漂移的表现是“manual 档没补列”这条断言假绿。
| 阶段 | 命令 | 结果 |
| --- | --- | --- |
| 机械迁移前(已知必红) | `pytest tests/integration/test_postgres_telemetry.py -q -x` | **1 failed / 18 passed**manual 档列序断言 `Right contains 10 more items, first extra item: 'scope'` |
| 机械迁移后 | 同上(无 `-x` | **27 passed**EXIT=0`tests/outputs/135/pg-telemetry-after-mechanical.log` |
| 新增四项验收后 | 同上 | **30 passed**EXIT=0`tests/outputs/135/pg-telemetry-new-cases.log` |
新增 `TestCallObservabilityColumnsAcceptance`(对应计划 T4 的 PG 四项),共用一张 27 列的 1.3.4 形态旧表(`pre_135_schema`):
| 验收项 | 用例与关键断言 |
| --- | --- |
| auto 追加 10 列 + 旧行 NULL | `test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null`:物理列 27 → 37 且列序 == `_EXPECTED_COLUMNS`attempt 行与 terminal 行十列取值**整体比对**(不是逐条 in);历史行 `dict.fromkeys(...)` 十列全 NULL |
| manual 缺列裁剪 | `test_manual_trims_the_insert_on_a_pre_135_table`:表结构逐字不动(== `_PRE_135_COLUMNS`);裁剪后 26 列逐列等于提交值(防整体错位);恰 1 条告警且含可直接粘贴的首/末列 ALTER;无“写入失败”/“补列失败”,`degraded is False` |
| 新旧进程混写 | `test_old_and_new_writers_share_one_table`:新版建表写 36 列 → 旧版进程用 `insert_sql("postgres", 26 列集)` 写入 → 新版再写;三行共存、表结构不变、旧行新列全 NULL、无写入失败 warning |
| 下游口径验收 | 同上用例尾部:`WHERE event_kind = 'terminal_failure'` 计得 1`event_kind IS NULL` 计得 1(混写期旧行既不误计成失败也不误计成成功) |
纪律:未引用 `assert_no_leftovers`(它是 `test_pg_sandbox.py` 的模块级 fixture,对本文件不可见,上提它要改 `conftest.py`);
未新建沙箱设施、未碰共享表 `llm_calls`、未读或打印 DSN。新增的只有一个 `_execute_args`(带参数单语句)与
`_minimal_fields`(从 `_record_minimal` 拆出的字段字典,供“旧进程”复用同一份取值)。
### 5.2 变异矩阵(仓库外副本,主工作区生产代码零改动)
驱动脚本 `/tmp/pgw-135-mut/run_mutations.py`;每项“还原副本 → 施加单点变异(断言替换命中)→ 跑指定节点 → 还原”。
完整日志 `tests/outputs/135/mutations.log`,脚本总退出码 **EXIT=0**
| # | 变异 | exit | 被杀断言(节选) |
| --- | --- | --- | --- |
| M1 | `register_attempt()` 挪到 transport 成功之后 | 1 | `test_failed_retries_are_counted``test_budget_free_429_still_counts_as_an_attempt``test_retry_exhausted_counts_every_attempt` |
| M2 | `ChatRequest` 每次 `replace` 复制出新上下文 | 1 | `TestLogicalCallStats` 4 项 + `test_retry_exhaustion_writes_exactly_one_terminal_row` |
| M3 | `_rehydrate` 去掉 `call_stats=None` 覆盖 | 1 | `test_historic_dict_never_impersonates_call_stats` |
| M4 | Emitter 入口提前 `str(exc)` 压平 | 1 | 529/503 保真、4 个空超时文案 `cause_type`、类名前缀策略、结构化有界说明等 9 项 |
| M5 | 终态行复制最后一次 attempt 的 token/cost | 1 | `test_terminal_rows_never_contribute_to_cost``test_terminal_row_costs_nothing` |
| M6 | 去掉 `claim_terminal` 去重 | 1 | `test_terminal_row_is_written_once_per_logical_call``test_claim_terminal_is_true_once` |
| M7 | 装配闸改为捕获 `TypeError` 后 warning | 1 | `test_old_signature_recorder_is_refused_at_assembly``test_uninspectable_recorder_is_a_configuration_error` |
还原校验:副本 `.py` 文件集合散列与纯净态**逐字一致**(`66b58b9a…`),还原后同一批节点 **172 passed**
> **方法论陷阱(影响本仓所有变异证据的有效性)**:只设 `PYTHONPATH=<副本>/src` 是**无效的**——
> `pyproject.toml``[tool.pytest.ini_options] pythonpath = ["src"]` 会把**仓库内**的 `src` 抢先塞进 `sys.path[0]`
> 于是测的仍是原代码。首轮实跑七项变异**全部“存活”(exit=0)**就是这个坑;
> 改用 `-o pythonpath=<副本>` 覆盖后七项全部被杀。今后做变异必须先证“副本真的被导入”,
> 否则“变异存活”会被误读成“测试不够强”,而真相是变异根本没生效。
### 5.3 文档同步(字段数一律 `inspect` 实测,不凭记忆)
实测值:`len(inspect.signature(TelemetryRecorder.record_llm_call).parameters) - 1 == 36``len(COLUMNS) == 36`,物理列 37。
| 位置 | 改了什么 |
| --- | --- |
| `README.md` 能力表 | “必录 26 字段” → 36 字段 + 三类行与诊断列;新增“逻辑调用统计”一行 |
| `README.md` 新小节 | 《1.3.5 逻辑调用统计与失败诊断》:`call_stats` 读法、**SQL 迁移五项**、归因查询、存储侧升级 |
| `README.md` cap 覆盖面 | 补“`error_body` 沿用 `summarize_body` 上限、结构化说明自带限长,**两者都不在 cap 覆盖内**” |
| `ARCHITECTURE.md` §7.8 | 必录字段 26 → 36(十列逐个列出);新增“逻辑调用十列”段(列语义表 + 不变量 I3/I4 + `operation` vs `exc.operation` 两个语义 + 装配闸);cap 段补两处诊断文本不在覆盖面 |
| `CHANGELOG.md` | 新建《未发布》:公共面四项变更表 + 下游必须做的事(点名 `WHERE event_kind` 与装配期报错) |
| `.env.example` | `PGW_TELEMETRY_TEXT_CAP` 块补 1.3.5 覆盖面例外 |
| `schemas/llm-calls.md` | 标题 26 → 36 字段;十列逐行登记;新增《三类行与失败归因口径》(含归因 SQL 与迁移四条) |
| `metrics/call-telemetry-coverage.md` | 新增《1.3.5 三类行与逻辑调用覆盖》;live 基线列**不写伪百分比** |
版本号与发布步骤**未动**(任务边界);`pyproject.toml` / `__init__.py` 仍为 1.3.4。
### 5.4 本轮收口验证
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 静态 | `make check`ruff format+lint + import-linter | 见 §7 实跑记录 |
| 全量单测 | `pytest tests/unit -q` | 见 §7 |
| 目标集成 | `pytest tests/integration/test_postgres_telemetry.py -q` | **30 passed** |
## 6. 剩余缺口(不归本任务,不自称已关)
| 缺口 | 说明 |
| --- | --- |
| 独立验证 | 未派全新上下文 verifier(合并前硬门,由父会话前台派) |
| slow / e2e | 未跑,属发布清单第 4 步(CLAUDE §4.4.1 |
| live 覆盖基线 | `metrics/call-telemetry-coverage.md` 的实际基线列仍待首次生产运行填入 |
| 下游自建 recorder | 装配闸只能证明形状可被接受,证不了函数体真的落这些列(已写进 README/CHANGELOG |
## 7. 环境噪音记录(不是缺陷)
自动检查器用**系统解释器 Python 3.13.9**(无 `redis`/`pydantic`/`httpx`/`loguru`/`asyncpg` 等依赖),
持续报 `test_client.py` 2 项失败与大量 "Import could not be resolved"、`StrEnum is unknown import symbol`
已核实为环境问题、非本轮引入:
- 失败根因是 `ModuleNotFoundError: No module named 'redis'`optional extra),本轮 diff 对 redis 零改动;
- 在**未改动的 HEAD** 上用同一系统解释器复跑,同样 2 failed / 90 passed
- 项目强制环境 `conda run -n PolyGateway`Python 3.12.13)下:`test_client.py` 98 passed、全量 1467 passed。
判据以 CLAUDE.md §2 规定的 conda 环境与 `make check` 为准。
**T4 轮次同样现象(已逐项复现并关闭)**:检查器报 `tests/integration/test_postgres_telemetry.py` “2/3 failed”
与 6 处 `Import "asyncpg" could not be resolved`。根因与上同:`asyncpg>=0.29` 是 optional extra
`pyproject.toml:25``postgres`),检查器解释器里没装。已做确定性复现:
- 那 3 条恰是本文件**仅有的不依赖真实 PG 的用例**,其中 2 条要造 `PostgresRecorder`
- 用 `/home/iomgaa/miniconda3/bin/python`(无 asyncpg)跑这 3 条:**2 failed / 1 passed / 0.09s**,与检查器报告逐字吹合;
- 同 3 条在 `conda run -n PolyGateway` 下:**3 passed**;整文件 **30 passed**(真实 PG);
- 被标记的 6 行均为**本轮未触及的旧行**,本轮只新增 1 处同款函数内 `import asyncpg`
未为此修改代码:给旧行加 type-ignore 属任务外改动(反 gold-plating),且并非真修复。
## 8. 1.3.5 发布准备(2026-09-09,唯一 writer 会话)
范围仅**发布准备文档与版本号**:README 安装下界、CHANGELOG 定版、两处版本、本节记录。生产代码与测试**零改动**(`git diff` 只有 `CHANGELOG.md` / `README.md` / `pyproject.toml` / `__init__.py` 各 1 行,外加本文件追加的这一节)。未 merge/push/tag/构建/上传。
### 8.1 先查远端占用(改文件之前)
| 检查 | 结果 |
| --- | --- |
| `git ls-remote --tags origin` | 最高 `refs/tags/v1.3.4`**无 v1.3.5**;远端分支只有 `main``af57f93`)与 `docs/1.3.4-release-evidence` |
| 本地 tag | 同样止于 `v1.3.4` |
远端未被占用是**本次时点**的事实,父会话真正 push/tag 前仍须复查以防竞态。
### 8.2 版本与文档数字(数字一律实测)
| 项 | 结果 |
| --- | --- |
| 两处版本 | `pyproject.toml``src/polygateway/__init__.py` 同为 `1.3.5`;由 `tests/unit/test_package.py` 机械断言一致(**6 passed** |
| README 安装下界 | `>=1.3.4,<2``>=1.3.5,<2`(本版含装配期形状闸与 SQL 口径迁移,旧下界会让下游装到不含新列的包) |
| README 迁移醒目度 | 《1.3.5 逻辑调用统计与失败诊断》为顶层小节且带 `[!WARNING]`(点名"失败行变多、旧失败 SQL 会多数、自建 recorder 装配期报错"),能力表"逻辑调用统计"一行直链该锚点;沿用 1.3.4 的同款版式,未重排既有 1.3.4 迁移节 |
| CHANGELOG 定版 | `## 未发布``## 1.3.5(2026-09-09)`,日期取自本机 `date +%F` 实际值,正文未改 |
| 字段数复核 | `inspect.signature(TelemetryRecorder.record_llm_call)` 去 self **36 参**`len(schema.COLUMNS) == 36`、探针实测物理列 **37**——READMECHANGELOG 的 3637 与实测吻合,非凭记忆 |
README 静态数字未发现错误,故未做任何顺带修改。
### 8.3 复用既有证据的适用边界(不拿旧证据顶替本版统计)
| 证据 | 是否复用 | 边界 |
| --- | --- | --- |
| 独立验证(全新上下文 verifier,父会话前台派) | 复用结论:**0 阻塞** | 审的是本分支 `b4812e1` 的 1.3.5 变更面;本会话未重复派子代理,也未把它当作 live 覆盖或发布后检查的替代 |
| 本版最终全套件 `make test` | **本版实跑,不复用** | `final-gates/make-test.log/.exit`**1605 passed / 23 skipped / 108 deselected / 95%exit 0** |
| 本版真实 PG 集成 | **本版实跑,不复用** | `final-gates/pg-telemetry-verbose.log/.exit`**30 passedexit 0**(含新增四项列兼容验收) |
| 本版 unit | **本版实跑** | 本轮版本号改动后复跑 `pytest tests/unit -q`**1469 passedexit 0**`release/unit.log/.exit` |
| 1.3.4 模型矩阵/逐型号推理证据 | **按适用条件复用** | 1.3.5 对 `thinking.py`、能力表、wire 片段、缓存 key 公式、重试/限流/熔断语义**一字未改**(§4 已逐文件确认),故 1.3.4 的型号级结论在其原有边界内继续成立——连同它的 FAIL/UNKNOWN/不可达/缺轮**一并继承**,不因本版而升格。它**不能**充当 1.3.5 新增 API/schema 的证据,也不提供本版的测试统计数字 |
| 1.3.4 的全模型矩阵重跑 | **不跑** | 本版未改推理路径;用户已批准不再补全模型矩阵。本会话不发全型号请求 |
本版**改变的公共面**`CallStats` 与四类响应新字段、端口 10 新参、遥测 10 列与 `terminal_failure` 行)全部由新写的离线真链路用例(三个 client + MockTransport 直到落库)、真实 SQLite 断言、真实 PG 30 项承担,见 §2、§2.4、§5.1。
### 8.4 本轮受影响路径的真实现场(并发 1、超时不压、无全模型请求)
| 亲跑 | 命令与证据 | 结果 |
| --- | --- | --- |
| 真实网关冒烟(复用既有 e2e,不另造工具) | `pytest tests/e2e/test_smoke_gateway.py -m slow -v`tmux `pgw135-smoke``release/smoke-gateway.log/.exit` | **4 passedexit 0**(流式/非流式/结构化 json/结构化模型四节点,9.55s) |
| 有界现场探针(**恰 1 次**真实调用,全程走库) | `release/probe_call_stats.py``release/probe-call-stats.log/.exit`tmux `pgw135-probe` | **exit 0**`MiniMax-M3`/源 `minimax_1``call_stats.attempts=1``total_latency_ms=2581` ≥ 单次尝试 `2562``logical_call_id` 与落库行逐字一致;临时 SQLite 物理列 **37**、写入面 **36**、恰 1 条 `event_kind='attempt'` 行、`operation='chat'``scope='LLM'`、逻辑两列与四个诊断列全 NULL |
探针脚本只落在 `tests/outputs/135/release/`(该目录 gitignore,不入库),不新增生产代码、测试或平台设施,未发裸 HTTP,沿用 `.env` 现有超时与单源配置。
### 8.5 提交前收口门
| 门 | 证据 | 结果 |
| --- | --- | --- |
| `make check` | `release/check.log/.exit` | **exit 0**:94 文件格式通过、ruff 通过、import-linter **1 kept / 0 broken** |
| 包单测(两处版本一致) | `release/package.log/.exit` | **6 passedexit 0** |
| 全量 unit | `release/unit.log/.exit` | **1469 passedexit 0** |
23 项 skip 的理由已逐条留档(`final-gates/skip-reasons.log`):17 项 redis 时间语义由 integration 变体覆盖、6 项需实验室语料 `data/soak/chs_images`**skip 不计通过**。conda 启动器既有 `RequestsDependencyWarning` 保留,不宣称零告警。
### 8.6 尚未执行(不得当成已完成)
merge、push、`git tag -a v1.3.5``python -m build``twine check``upload``pip download` 解包验证、Gitea Release 与包页面/仓库关联检查**全部未执行**;`pytest -m slow` 的其余 e2e(本轮只跑了 `test_smoke_gateway.py` 四节点)与 live 覆盖基线同样未跑。§6 的四项缺口除"独立验证"已由父会话关闭外,其余保持开启。
@@ -0,0 +1,98 @@
---
type: finding
node_id: finding:2026-09-09-135-release-completion
title: "1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查"
date: 2026-09-09
---
# 1.3.5 发布完成与外部验收
> 状态:**1.3.5 已发布并完成外部验收,#19 / #23 已评论关闭**。main 与 `v1.3.5` 同指
> `ab00aa44576bd4cd47d0680d107d5191679dc1ee`。发布准备阶段的证据见
> [1.3.5 验收证据](2026-09-09-135-call-observability-validation.md)(其 §8.6 列的"尚未执行"由本文逐项关闭,
> 该文当时结论不追改)。本轮无生产/测试代码改动、未派子代理;原始日志在
> `tests/outputs/135/publish-20260909/`gitignore,不入库)。
## 1. 合并与合并后门
长跑全部在 tmux `pgw135-publish` 串行执行,`PYTHONUNBUFFERED=1`,命令末尾**不接管道**,每门独立 `.exit`
| 步骤 | 结果与证据 |
| --- | --- |
| 远端占用复查(改动前) | `git ls-remote` 最高 `v1.3.4`registry simple 索引按完整字面量 `polygateway-1.3.5` 无命中;Release API `/releases/tags/v1.3.5` 与包页面 1.3.5 均 **404**。origin/main = 本地 main = `af57f93`,无未审变更 |
| 合并 | `--no-ff``ab00aa4``git diff HEAD feature/1.3.5-call-observability` **空**,即合并树与父会话已审的 `433039b` 逐字一致;`merge.log/.exit` |
| CLAUDE.md 纯格式 diff | 合并前仅对该文件 `git stash push`(未 stash 全部),合并后 `stash pop` 回原样;全程未提交、未覆盖 |
| `make lint` | **exit 0**ruff 无自动改动,import-linter **1 kept / 0 broken**`lint.log/.exit` |
| `make test` | **1605 passed / 23 skipped / 108 deselected95% 覆盖,282.48sexit 0**`daily.log/.exit`。skip 与 deselected **不计通过** |
| 真实网关 e2e 冒烟(显式 slow) | `pytest tests/e2e/test_smoke_gateway.py -m slow -v`**4 passed98.00sexit 0**(流式/非流式/结构化 json/结构化模型阶梯);`smoke-gateway.log/.exit` |
| Redis 时间语义(显式 slow | `pytest tests/contracts tests/integration -m slow -q`**18 passed / 159 deselected1138.67sexit 0**`redis-time.log/.exit` |
未补全模型矩阵(用户已批准):本版对 `thinking.py`、能力表、wire 片段、缓存 key 公式、重试/限流/熔断语义一字未改,
故 1.3.4 的型号级结论在其**原有边界**内继续成立,连同其 FAIL/UNKNOWN/不可达/缺轮一并继承,不因本版升格。
本版**已变更**的统计/SQL/PG 口径不复用旧统计,全部由 1.3.5 新测试承担(36 列契约、终态行、真实 PG 30 项)。
## 2. push / tag / 构建 / 上传
| 步骤 | 结果 |
| --- | --- |
| push main | `af57f93..ab00aa4`exit 0`push-main.log/.exit` |
| tag | `git tag -a v1.3.5`,注释 tag 对象 `ab780cf36a8c6b2de316c0b194dcaa9485e8172f`,解引用 = `ab00aa4`push exit 0`remote-refs-after.log` |
| 构建 | 确认 `dist` 是项目内真实目录(非符号链接)后清掉旧 1.3.3/1.3.4 产物;`python -m build` exit 0wheel + sdist 各一份 |
| `twine check` | 两份均 **PASSED**exit 0 |
| 上传 | exit 0。凭据只从 tea 配置读入 `TWINE_PASSWORD` 环境变量,不进 argv/日志;**未重传任何已存在版本的字节** |
产物 SHA-256
wheel `c017599ea6bef69a485e1d35eab2ca89154cd33180ebd6dc759647097221a6b4`
sdist `87fd4151b77f55829b5d3206c5b75ac6c1c6bb8c94faa194955bf0db33ad0211`
## 3. 独立下载、解包与安装后行为
全部在仓库外临时目录(`/tmp/pgw135-registry-*`)进行,不引入新依赖。
| 检查 | 结果 |
| --- | --- |
| registry 下载 | wheel exit 0sdist 因私有索引不镜像 setuptools 需 `--no-build-isolation`(沿用 1.3.4 已记录的处置)exit 0 |
| 字节一致 | 下载的两份 SHA-256 与本地构建**逐字相同** |
| 包内源码 | `__init__.py` / `types.py` / `ports.py` / `telemetry/schema.py` / `middleware/telemetry.py` / `client.py` / `embedding.py` / `ocr.py` 八个文件 wheel、sdist 与已发布提交**散列全等**sdist 内 README == 仓库 README |
| 元数据 | wheel METADATA `Version: 1.3.5``Description-Content-Type: text/markdown`、正文 30465 字符,含《1.3.5 逻辑调用统计与失败诊断》与安装下界 `>=1.3.5,<2`Project-URL 三项齐全 |
| 安装后公共面(`pip install --target` + 仅从该目录导入) | `CallStats``__all__`、frozen、字段 `logical_call_id/attempts/total_latency_ms`;四类响应 `call_stats` 均为**末尾**字段且默认 `None``record_llm_call` 实测 **36 参**10 个新参全为 keyword-only 且**无默认值**`len(schema.COLUMNS) == 36` |
| 安装后终态行行为(离线,httpx MockTransport 固定 503,不发真实请求) | 3 条 `attempt` + **恰 1 条** `terminal_failure`;终态 `attempts=3``total_latency_ms` 非空、`cost IS NULL``usage_source='unavailable'``http_status_code` 为 NULL;尝试行三条均记真实 **503**`error_body``error_type='TransientError'`;四行共享同一 `logical_call_id`;物理列 **37**36 写入列 + `created_at` |
## 4. 外部产物亲查(匿名,看到什么算什么)
| 目标 | 结果 |
| --- | --- |
| Release 创建 | POST **201**,id 22,正文 1898 字符逐字取自 CHANGELOG 1.3.5 段 |
| [包页面](https://gitea.iomgaa.online/iomgaa/-/packages/pypi/polygateway/1.3.5) | 匿名 **200**;可见正文含安装命令与《1.3.5 逻辑调用统计与失败诊断》;页面有指向 `iomgaa/PolyGateway` 的仓库锚点 |
| [v1.3.5 Release](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.3.5) | 匿名 **200**;可见文本含 `WHERE event_kind = 'terminal_failure'` 与「26 → 36」迁移说明 |
| [Releases 列表](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases) | 匿名 **200**,含 v1.3.5 |
| simple 索引 | 匿名 **200**1.3.5 的 wheel 与 sdist 均列出 |
## 5. issue 收尾
仅动 **#19 / #23**:各评论一条后关闭,随后**以 GET 读到的 state 为准**复核(不拿 PATCH 返回码推断),两者均 `closed`
评论内容点名:自建 recorder 需补 10 参或改 `**fields`**装配期报错**)、PG manual 档补列、
计失败改 `WHERE event_kind = 'terminal_failure'``AVG(latency_ms)``event_kind` 分组;
并明确「新增失败统计列与终态行是**常规口径变更、不是异常字段**」,`http_status_code` 不可当成败判据;
同时声明**未做全模型矩阵、不宣称所有模型或渠道可用**。#22 / #24 未触碰(复核仍 `open`、评论数 0)。
## 6. 真实失败与处置(原件保留,不改写成功)
| 失败 | 根因与处置 |
| --- | --- |
| 首次安装后终态验证 exit 1 | `sqlite3.OperationalError: no such column: id` —— 是**我的验证脚本**臆断了主键列名,实际第 37 个物理列是 `created_at`。改脚本 `ORDER BY rowid` 并把多出的物理列打印出来自证,**未改库代码**;失败日志保留 |
| 首次 Release 创建 exit 1 | `KeyError: 'TOKEN'` —— 变量未 export 到子进程,**未发出任何 API 请求**(不是服务端拒绝)。改为 export 后 201 |
| 包关联 POST **400** | 返回 `invalid argument`,与 1.3.4 同款。**不把 400 当成功、也不据此猜已关联**:另以认证 GET 包 API 读到 `repository.full_name = iomgaa/PolyGateway`,并匿名读到包页面上的仓库链接,两条实测证据才是关联成立的依据;既有正确关系不为重试而解绑 |
## 7. 残余缺口(不自称已关)
| 缺口 | 说明 |
| --- | --- |
| live 覆盖基线 | `metrics/call-telemetry-coverage.md` 的实际基线列仍待首次生产运行填入 |
| 下游自建 recorder | 装配闸只证形状可被接受,证不了函数体真的落这些列(已写进 READMECHANGELOGissue 评论) |
| 模型矩阵 | 未重跑;1.3.4 的 FAIL/UNKNOWN/不可达/缺轮例外原样继承,不外推为「全模型可用」 |
| 其余 e2e | 本轮 slow 只跑了 `test_smoke_gateway.py` 与 contracts/integration 的 Redis 时间语义,`tests/e2e/` 其余文件未跑 |
| 用户文档站(Gitea Wiki) | 本轮未同步;`docs-convention.md` §2 清单待另行执行 |
conda 启动器既有 `RequestsDependencyWarning` 保留,不宣称零告警。本记录落在独立
`docs/1.3.5-release-evidence` 分支,**不改动已发布的 maintagregistry 产物**。
+43
View File
@@ -210,6 +210,21 @@
"id": "plan:reasoning-effort",
"label": "实现计划: 推理档位一等化",
"type": "plan"
},
{
"id": "design:2026-09-09-134-thinking-contracts-design",
"label": "1.3.4 推理意图与测试证据设计",
"type": "design"
},
{
"id": "plan:2026-09-09-134-thinking-contracts",
"label": "1.3.4 推理契约实施计划",
"type": "plan"
},
{
"id": "finding:2026-09-09-134-thinking-contracts-validation",
"label": "1.3.4 T0T4 与 T7 确定性验证",
"type": "finding"
}
],
"links": [
@@ -429,6 +444,34 @@
"relation": "implements",
"evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定",
"added": "2026-09-05T04:07:17.723586+00:00"
},
{
"source": "plan:2026-09-09-134-thinking-contracts",
"target": "design:2026-09-09-134-thinking-contracts-design",
"relation": "implements",
"evidence": "已批准设计;T0基线660 passed、make check通过",
"added": "2026-09-09T04:48:57.560089+00:00"
},
{
"source": "plan:2026-09-09-134-thinking-contracts",
"target": "finding:2026-09-09-134-thinking-contracts-validation",
"relation": "tested_by",
"evidence": "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"
},
{
"source": "plan:2026-09-09-135-call-observability",
"target": "design:2026-09-09-135-call-observability-design",
"relation": "implements",
"evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计",
"added": "2026-09-09T13:42:00.298915+00:00"
}
]
}
+18 -5
View File
@@ -1,8 +1,10 @@
# Research Wiki 索引
> 自动生成,更新时间:2026-09-05 04:07 UTC
> 自动生成,更新时间:2026-09-09 18:01 UTC
## design (41)
## design (43)
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-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`
@@ -22,6 +24,7 @@
- [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design`
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
- [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
@@ -45,7 +48,11 @@
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (14)
## finding (17)
- [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
- [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation`
- [1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查](findings/2026-09-09-135-release-completion.md) `finding:2026-09-09-135-release-completion`
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
@@ -61,7 +68,10 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (36)
## plan (38)
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
- [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability`
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-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`
@@ -100,11 +110,14 @@
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## review (1)
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1)
- [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls`
- [表结构: llm_calls(遥测 36 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
- [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage`
+10
View File
@@ -150,3 +150,13 @@
- [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort)
- [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort
- [2026-09-05 04:07 UTC] 重建索引: 95 篇页面
- [2026-09-09 04:48 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --implements--> design:2026-09-09-134-thinking-contracts-design
- [2026-09-09 04:48 UTC] 重建索引: 97 篇页面
- [2026-09-09 05:39 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --tested_by--> finding:2026-09-09-134-thinking-contracts-validation
- [2026-09-09 05:39 UTC] 重建索引: 98 篇页面
- [2026-09-09 06:32 UTC] 新增边: schema:llm-calls --implements--> design:2026-09-09-134-thinking-contracts-design
- [2026-09-09 06:32 UTC] 重建索引: 98 篇页面
- [2026-09-09 13:42 UTC] 新增边: plan:2026-09-09-135-call-observability --implements--> design:2026-09-09-135-call-observability-design
- [2026-09-09 13:42 UTC] 重建索引: 100 篇页面
- [2026-09-09 16:49 UTC] 重建索引: 101 篇页面
- [2026-09-09 18:01 UTC] 重建索引: 102 篇页面
@@ -7,3 +7,27 @@ date: 2026-07-20
# 每次调用必录覆盖率(含缓存命中/失败/取消)
## 1.3.4 契约与基线
| 指标 | 确定性阈值/证据 | 实际 live 基线 |
| --- | --- | --- |
| 三个无推理入口成败行档位 NULL | embed、recognize_text、parse_layout 真实 client→emitter→临时 SQLite,非空失败/成功计数与 NULL 全部成立,契约要求100% | 待首次实际运行,不填伪百分比 |
| chat 阳性 | 糖失败 auto、显式意图失败、nearest 成功实际档均精确匹配,要求100%;防恒 NULL 假绿 | 待首次实际运行 |
| 四种行来源 | 真实成功=applied;失败=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 单点出口。
## 1.3.5 三类行与逻辑调用覆盖
本版把覆盖度的计量单位从"一次尝试"改成"一次逻辑调用"——此前结构化耗尽、embedding/OCR 的无源与准入拒绝根本没有任何行,"每次调用必录"在这几条路径上是不成立的。
| 指标 | 确定性阈值/证据 | 实际 live 基线 |
| --- | --- | --- |
| 领域失败的终态行覆盖 | chat 结构化耗尽、embeddingOCR 的 `no_sources`、准入拒绝、`retry_exhausted`、尝试外取消各恰有 **1** 条;单测以真实临时 SQLite 抓实 | 待首次实际运行,不填伪百分比 |
| 至多一条(I3) | 同一逻辑调用重复进出口不产生第二条(`claim_terminal`);recorder 写失败仅 warning,故 SQL 可见 ≤ 1 | 待首次实际运行 |
| 非领域异常 0 条(I4) | 编程错(如 `KeyError`)不写终态行、原样传播、分类不被改写 | 待首次实际运行 |
| 诊断列保真 | 中转把 529 改写成 503 则记 503(**不猜回**);空 `str()` 的 httpx 超时类落 `cause_type`;成功行五列 NULL | 待首次实际运行 |
| 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) |
本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。
@@ -0,0 +1,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”说明已标为被本次修复替代,不能再当成可接受限制。
@@ -0,0 +1,455 @@
---
type: plan
node_id: plan:2026-09-09-135-call-observability
title: "1.3.5 逻辑调用统计与结构化失败诊断实施计划"
date: 2026-09-09
---
# 1.3.5 逻辑调用统计与结构化失败诊断实施计划
> 设计:`research-wiki/designs/2026-09-09-135-call-observability-design.md`**人类于 2026-09-09 正式批准**(§10 六项批准项全数获批)。
> 计划审核门:Claude 自审 + **独立模型替代审查**(用户已同意以独立模型替代 Codex 那一道,结论仍须逐条核验后就地修订);plan 无人类门,审毕直接执行。
> 目标:把治理单位从"一次尝试"补齐到"一次逻辑调用"——四种响应带 `CallStats`,遥测补 10 列诊断/归因字段与 `terminal_failure` 行(issue #19#23)。
> 方案:设计方案 B——每调用一个显式传递的私有可变上下文 + 领域异常下沉到单一遥测出口;复用既有 Emitter / schema / 三条治理循环,不新增表、不新增依赖、不引入追踪平台。
> 技术:Python 3.12+、asyncio、frozen dataclass、`inspect.signature`、pytest + FakeClock + MockTransport、临时 SQLite、真实 PG 沙箱、ruff、import-linter。
> 基线 HEAD`a81cc91`(分支 `feature/1.3.5-call-observability`,工作区仅 `CLAUDE.md` 既有 markdown 格式差异与未跟踪 `.pi/`,两者本计划一律不动、不暂存)。
本计划不涉及 `reference/` 参考实现迁移(治理主循环、Lua 限流、熔断状态机、退避公式一字不改),**保真校验不适用**。不得变更:重试预算与退避、429 免预算与 stall 算法、取消结算、限流/熔断语义、推理能力表与档位解析、缓存 key 公式、`#22`/`#24` 的调度机制。
## 1. 边界、授权与执行纪律
| 项目 | 固定边界 |
| --- | --- |
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。用户已授权本轮无需逐步请示,1.3.X 合并/发布授权沿用;跨到 1.4、新公共决策或验证豁免须停下确认 |
| 公共承诺 | 只做设计已批准的四项公共面变更:`CallStats` 导出、四响应新增字段、`TelemetryRecorder` 扩 10 参、`terminal_failure` 行;**不新增其它公共 API、不改既有字段名与位置** |
| 依赖铁律 | `ports.py`/`types.py`/`errors.py` 保持最内层,`types.py` 不 import 任何实现层;`middleware/` 只依赖端口与内核;`transports/``telemetry/``backends/` 互不依赖(import-linter 执法) |
| 取消 | `CancelledError` 永不吞没;终态写**取消优先、不 shield、不开后台任务**;快照冻结是同步动作,不 await |
| 降级方向 | recorder 写失败仍只 warning;限流/熔断后端仍 fail-closed;装配期签名不符 → **当场报错**(不是 warning |
| 证据与秘密 | 不打印 `.env`、token、Authorization;不提交 `.pi/``tests/outputs/`、reference;命令输出只记安全路径、状态与退出码 |
| 证据复用 | 已发布 1.3.4 的有效证据(能力矩阵、live 轮次、发布外部验证)**直接引用不重建**;本版**不补跑未变更的模型能力矩阵**,无需新增付费调用 |
Skill 纪律:T0 已执行 `writing-plans`T1T3 行为变更执行 `test-driven-development`(先失败后通过的证据须落在本会话工具输出里);T1 触及运行时数据落库,执行 `structured-logging`;每次提交执行 `commit`(英文祈使标题、无 AI 签名、显式路径暂存);T4 前执行 `requesting-code-review``verification-before-completion`,收到意见执行 `receiving-code-review`;异常先 `systematic-debugging` 定根因。
## 2. 文件职责与不变接缝
| 动作 | 精确路径 | 职责 |
| --- | --- | --- |
| 修改 | `src/polygateway/types.py` | 新增 `CallStats`、私有 `_CallContext``CallOperation`/`EventKind` 词表;`ChatRequest` 追加内部上下文字段;四种响应追加 `call_stats` |
| 修改 | `src/polygateway/__init__.py` | 导出 `CallStats``__all__` 保序插入);版本号在 T4 统一 |
| 修改 | `src/polygateway/ports.py` | `TelemetryRecorder.record_llm_call` 追加 10 个 keyword-only 无默认值参数(唯一签名事实源) |
| 修改 | `src/polygateway/telemetry/schema.py` | `SQLITE_DDL` / `PG_DDL` / `SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` / `COLUMNS` **五处**同序同增 10 列,追加在物理列末尾(`PG_BACKFILL``_ALTER_BY_BACKEND``telemetry_schema_sql` 自动派生,不手改) |
| 修改 | `src/polygateway/middleware/telemetry.py` | Emitter 构造期注入 `scope` + 装配闸;三个 emit 入口收 `operation` 与领域异常对象;诊断字段单一提取 helper;`event_kind`;终态出口 helper`TelemetryMW` 只保留 cache_hit |
| 修改 | `src/polygateway/middleware/structured.py` | 有界错误说明常量与拼装函数改为库内可复用(重问反馈与遥测说明同一口径,数值只一份) |
| 修改 | `src/polygateway/middleware/retry.py` | transport 调用前登记一次尝试;`_emit` 传异常对象与 `operation="chat"` |
| 修改 | `src/polygateway/middleware/cache.py` | `_serialize` 剔除 `call_stats``_rehydrate` 显式覆盖为 `None`(防历史 dict 冒充) |
| 修改 | `src/polygateway/client.py` | 自存 `now`/`emitter``chat` 创建上下文、附加统计、领域失败与取消经单一终态出口 |
| 修改 | `src/polygateway/embedding.py` | `embed` 拥有上下文(空输入 0 尝试 0 遥测行);逐批传递;终态请求摘要按 §3.5 |
| 修改 | `src/polygateway/ocr.py` | 上下文在 `image` 校验通过后创建;两公开方法各自给 `operation`;错误文本保留类名前缀(经出口的显式策略参数) |
| 修改 | `src/polygateway/transports/openai_compat.py` | `_status_to_error` 增 keyword-only `operation`;三个调用点显式给值:`:512``embed` 非 200)传 `"embedding"`(修正历史误标)、`:527`(流式 chat)与 `:625`(非流式 chat)传 `"chat"``ocr_text`/`parse`/`download_result` 词表不动。**归属以本表为准**:设计 §5 把 `:527` 也叙述成 `embed()` 的分支,实测 `:527``_complete_stream`(流式 chat)、`embed` 只有 `:512` 一处;勿按设计行号把两条 chat 失败回退误标成 embedding |
| 修改 | `tests/unit/test_types.py``tests/unit/test_ports.py` | `CallStats`/上下文纯行为、端口签名实测(含无默认值与 keyword-only |
| 修改 | `tests/unit/test_telemetry.py` | 列序/列数、三类行语义、诊断保真、装配闸、SQLite 新旧表、`_record_minimal` 字段字典 |
| 修改 | `tests/unit/test_retry.py``test_client.py``test_cache.py``test_structured.py` | 计数/计时/缓存命中/结构化耗尽终态与历史 dict 防护 |
| 修改 | `tests/unit/test_embedding.py``test_ocr_client.py``test_openai_compat.py``test_monkey_ocr.py` | 多批、空输入、双 HTTP 一次尝试、`exc.operation` 修正、200 失败行 |
| 修改 | `tests/unit/test_pricing.py``tests/unit/test_usage_source_domain.py` | **仅 Emitter 调用点机械迁移**`TelemetryEmitter(``scope=``emit_*``operation=``emit_terminal_failure``latency_ms` 改传 `stats`);两文件的 `_MemoryRecorder``**fields` 形态,recorder 签名不动 |
| 修改 | `tests/integration/test_postgres_telemetry.py` | PG auto 追加 / manual 缺列裁剪 / 旧行 NULL / 新旧进程混写(真实 `pg_sandbox` |
| 修改 | `README.md``CHANGELOG.md``.env.example``research-wiki/ARCHITECTURE.md``research-wiki/schemas/llm-calls.md``research-wiki/metrics/call-telemetry-coverage.md` | 字段数实测改写、cap 覆盖面澄清、SQL 迁移五项、终态行语义 |
| 新建(验收时) | `research-wiki/findings/2026-09-09-135-call-observability-validation.md` | 红绿、变异、失败与豁免索引,≤300 行;原始输出留 `tests/outputs/135/` |
**不改**`errors.py`(异常类与分类逐字不动,不加可变字段)、`middleware/admission.py``middleware/ratelimit.py``middleware/breaker.py``sources.py``thinking.py``providers.py``telemetry/sqlite.py``telemetry/postgres.py`(两个 recorder 靠 `**fields` + `schema.COLUMNS` 自动吃到新列,逻辑零改动)、`transports/monkey_ocr.py`(双 HTTP 仍在同一 transport 调用内,不拆)。若实现时发现必须突破本清单,先说明设计依据与最小原因交父会话核定,不顺手改。
## 3. 跨任务接口(可执行定义,禁止占位)
### 3.1 统计内核(`types.py`
`types.py` 需新增 `import uuid``from collections.abc import Callable``from typing import Literal``Mapping`/`Any` 已在;全文现无 `Literal`)。
**运行时求值约束(`types.py``from __future__ import annotations`,实测 :1-17**`Callable``Literal` 必须是**运行时 import**,不得放进 `TYPE_CHECKING``_CallContext.__init__` 的函数注解与 `CallOperation` 别名都在 def/赋值时求值);且 `_CallContext` 必须**定义在 `ChatRequest`:339)之前**,否则 `call_context: _CallContext | None` 的类注解在类创建时即 `NameError`
上下文是**每调用一个实例**的单任务对象:chat 重试、结构化重问、embedding 分批都在同一任务内串行推进,故计数无需锁;严禁提升为 client 实例属性。
```python
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
EVENT_KINDS: tuple[EventKind, ...] = ("attempt", "cache_hit", "terminal_failure")
@dataclass(frozen=True)
class CallStats:
"""一次公开调用的统计快照;第三方合成响应的 `None` 表示未知,不得伪造 0。"""
logical_call_id: str
attempts: int
total_latency_ms: int
class _CallContext:
"""私有可变逻辑调用上下文:只持计数、单调时钟与终态去重位,不做 I/O。"""
__slots__ = ("logical_call_id", "_now", "_started", "_attempts", "_terminal_claimed")
def __init__(self, *, now: Callable[[], float]) -> None: ...
def register_attempt(self) -> None:
"""transport 调用前登记一次尝试(含免预算 429 与端口本地拒绝)。"""
def snapshot(self) -> CallStats:
"""同步冻结当前快照;绝不 await,可多次调用。"""
def claim_terminal(self) -> bool:
"""首次 True、其后 False:保证每逻辑调用至多写一条终态行。"""
```
`ChatRequest` 追加(末尾,`dataclasses.replace` 保留同一引用;`StructuredMW` 的 replace 已核对不重建它):
```python
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 logical_call_id 落 NULL。"""
```
`LLMResponse``EmbeddingResponse``OcrTextResult``OcrLayoutResult` 各追加**末尾**字段:
```python
call_stats: CallStats | None = None
```
### 3.2 遥测出口(`middleware/telemetry.py`
Emitter 是全库唯一 `record_llm_call` 调用点,本版新增的一切诊断/归因取值也只在这里定型。
```python
class TelemetryEmitter:
def __init__(
self,
recorder: TelemetryRecorder,
*,
scope: str,
pricing: PricingTable | None = None,
text_cap: int | None,
) -> None:
"""`scope` 构造期注入(三个 client 各一行),使三类行都带池名;不拿 source_name 顶替。
同处执行装配闸 `_assert_recorder_shape(recorder)`
"""
async def emit_attempt(
self,
*,
request: ChatRequest,
source: SourceConfig,
call_id: str,
latency_ms: int,
response: LLMResponse | None,
error: PolyGatewayError | str | None,
reasoning_applies: bool,
operation: CallOperation,
class_prefixed_error: bool = False,
) -> None:
"""`event_kind='attempt'`;失败异常对象在此提取 status/cause/body 四列。"""
async def emit_cache_hit(
self, *, request: ChatRequest, response: LLMResponse, operation: CallOperation
) -> None:
"""`event_kind='cache_hit'`;attempts/total_latency_ms 列恒 NULL。"""
async def emit_terminal_failure(
self,
*,
request: ChatRequest,
call_id: str,
error: PolyGatewayError | str,
operation: CallOperation,
stats: CallStats,
class_prefixed_error: bool = False,
) -> None:
"""`event_kind='terminal_failure'`;`latency_ms``total_latency_ms` 同取 `stats`。"""
```
`emit_terminal_failure` **不再收 `latency_ms`**(同一冻结快照供两列,避免双时钟微差)。诊断提取收敛为一个纯 helper,只读领域异常的既有属性,**不遍历任意对象、不猜正文**:
```python
@dataclass(frozen=True)
class _ErrorFields:
"""一行遥测的错误列;未知一律 None。"""
error: str | None
error_type: str | None
cause_type: str | None
http_status_code: int | None
error_body: str | None
def _error_fields(
error: PolyGatewayError | str | None,
*,
event_kind: EventKind,
class_prefixed: bool,
) -> _ErrorFields:
"""三种入参形态的唯一定型点(设计 §5/§6)。"""
```
判定规则(测试逐条钉死):
| 入参 | error | error_type | cause_type / http_status_code / error_body |
| --- | --- | --- | --- |
| `None` | None | None | 全 None |
| `str`(如既有 `"cancelled"` | 原样 | None | 全 None**不解析字符串猜诊断**) |
| 领域异常,`event_kind='attempt'` | 见下 | `type(exc).__name__` | `type(exc.__cause__).__name__ or None` / `exc.status_code` / `summarize_body` 已产出的 `exc.body_text or None` |
| 领域异常,`event_kind='terminal_failure'` | 见下 | `type(exc).__name__` | **全 None**(C1:不搬最后一次 attempt 的状态与正文冒充整池归因) |
error 文本:`str(exc)` 为空退回 `type(exc).__name__``class_prefixed=True` 时前置 `"{类名}: "`OCR 既有 metric 归组口径);取消路径一律传字符串(attempt 行沿用既有 `"cancelled"`,终态行用同一明确取消文案),故 `error_type` 与其余三列均 NULL`ResultInvalidError` 且为终态时并入有界结构化说明:
```python
def _structured_detail(exc: ResultInvalidError) -> str:
"""结构化耗尽的有界说明:`repair=` 至多 200 字符 + `validation=` 至多 3 条×200 字符;
**不含 raw_text**(模型正文预算已由 attempt 行的 response 列承担),两段以 ` | ` 拼接。"""
```
复用 `structured.py` 的既有规则所有者,不复制数值:把 `_MAX_FEEDBACK_ERRORS`/`_MAX_ERROR_CHARS` 改名为 `MAX_FEEDBACK_ERRORS`/`MAX_ERROR_CHARS``_format_errors` 改名 `format_bounded_errors(errors: Sequence[str]) -> str`(行为逐字不变),`StructuredMW``_structured_detail` 两个消费者共同引用。
装配闸(设计 §7C3):
```python
def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
"""装配期一次 `signature.bind` 形状校验:不执行写入,只证明该形状能被接受。
参数名从 `TelemetryRecorder.record_llm_call` 的协议签名派生(不手抄第四份清单),
绑定用哨兵 `None`,不读真实请求数据。`**kwargs`(VAR_KEYWORD)自动通过;
`TypeError``ValueError` 装配期报错;不可 inspect(C 实现等)同样按配置错误报错。
"""
```
终态唯一出口(公开边界 helper,三个 client 共用):
```python
async def emit_terminal_once(
emitter: TelemetryEmitter | None,
*,
request: ChatRequest,
context: _CallContext,
error: PolyGatewayError | str,
operation: CallOperation,
class_prefixed_error: bool = False,
) -> None:
"""去重(claim_terminal)+ 同步冻结快照 + best effort 写入。
`emitter is None` 或已写过 → 直接返回;写入侧异常按既有降级只落 warning;
**`CancelledError` 原样传播**(取消优先,不 shield、不开后台任务)。
"""
```
`TelemetryMW` 相应收缩:删除其 `except (GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError)``except CancelledError` 两个终态分支(改由 `GatewayClient.chat` 的边界统一写,避免两处同时写),保留 `cache_hit` 记录并传 `operation="chat"`
### 3.3 `_record` 的 10 个新列取值(唯一定型点)
| 列 | attempt | cache_hit | terminal_failure |
| --- | --- | --- | --- |
| `scope` | 构造期注入值 | 同 | 同 |
| `operation` | 调用点给定的四值之一,**绝不读 `exc.operation`** | 同 | 同 |
| `logical_call_id` | `request.call_context.logical_call_id`,上下文缺席 → NULL(不造 ID) | 同 | 同 |
| `event_kind` | `'attempt'` | `'cache_hit'` | `'terminal_failure'` |
| `http_status_code` / `cause_type` / `error_body` | 见 §3.2 表(成功行全 NULL,成功不统一填 200 | 全 NULL | 全 NULL |
| `error_type` | 该行自身错误类名 | NULL | 自身类名(`AllSourcesExhausted` / `CircuitOpenError` / `ResultInvalidError` / …) |
| `attempts` / `total_latency_ms` | NULL | NULL | `stats` 两字段 |
其余 26 列口径逐字不变;终态行仍 `cost=None``usage_source='unavailable'`、token 0,不复制 attempt 的用量与费用。
### 3.4 schema 与端口(`telemetry/schema.py``ports.py`
10 列按**同一顺序**追加进五处(`SQLITE_DDL``PG_DDL``SQLITE_BACKFILL``_PG_BACKFILL_DECLS``COLUMNS`),全部可空、无默认值,物理位置在现有末列 `reasoning_effort` 之后:
| 顺序 | 列名 | SQLite | Postgres |
| --- | --- | --- | --- |
| 1 | `scope` | TEXT | TEXT |
| 2 | `operation` | TEXT | TEXT |
| 3 | `logical_call_id` | TEXT | TEXT |
| 4 | `event_kind` | TEXT | TEXT |
| 5 | `http_status_code` | INTEGER | INTEGER |
| 6 | `error_type` | TEXT | TEXT |
| 7 | `cause_type` | TEXT | TEXT |
| 8 | `error_body` | TEXT | TEXT |
| 9 | `attempts` | INTEGER | INTEGER |
| 10 | `total_latency_ms` | INTEGER | INTEGER |
`COLUMNS` 由 26 → 36,物理列(含 `created_at`)27 → 37。**不建索引、不改旧列、不回填旧行、不 ALTER 默认生产 PG**(manual 档仍裁剪写入)。`ports.py` 按同序追加 10 个 keyword-only 无默认值参数,docstring 更新为"36 字段冻结",并说明 `error_body` 沿用 `summarize_body` 上限、不进 `PGW_TELEMETRY_TEXT_CAP` 覆盖面。
### 3.5 三条链路的上下文所有权
| 链路 | 创建点 | 传递 | 附加统计 | 终态 |
| --- | --- | --- | --- | --- |
| chat | `GatewayClient.chat`overlay/维度/档位三项校验**之后**(校验异常保持原行为,在统计边界外) | 放进 `ChatRequest.call_context`,洋葱各层经 `replace` 保留同一引用;`RetryMW._attempt``transport.complete``register_attempt()`**上下文为 `None` 时跳过**——库内现场构造的 `ChatRequest` 不得因此报错) | 返回前 `dataclasses.replace(response, call_stats=ctx.snapshot())`(含缓存命中路径与内联遥测耗时) | `except PolyGatewayError` / `except CancelledError` 各调 `emit_terminal_once`,随后原样 `raise`;非领域异常不捕、无终态 |
| embed | `EmbeddingClient.embed``texts` 类型校验与**调用方维度校验**`validate_caller_dimensions`)之后;`expected_dim` 校验在 `_attempt` 内,本就在统计边界内 | 显式参数传到 `_embed_batch``_attempt``_emit` 现场构造的 `ChatRequest``call_context=ctx``transport.embed` 前登记 | 合并结果 `replace(..., call_stats=ctx.snapshot())`**空输入返回真实 `attempts=0` 统计且不写任何遥测行** | 同上,`operation="embed"`;终态 messages = `<embed texts=N batches=M>` 占位 + 第一批(至多 `batch_size` 条、每条 200 字符,与逐批行同款构造) |
| OCR | `OcrClient._call``image``bytes`/非空校验**通过之后**(M1 例外) | `_call` 拥有上下文并返回 `tuple[_AttemptOutcome, CallStats]`,两个公开方法据此附加统计;`transport` 调用前登记(layout 的 POST+ZIP GET 计 **1** 次) | `OcrTextResult` / `OcrLayoutResult``call_stats` | `_call``except PolyGatewayError` / `except CancelledError``emit_terminal_once``operation` 由公开方法传入(`recognize_text` / `parse_layout`),`class_prefixed_error=True``no_sources``raise` 在循环之前,`try` 必须**包住该 raise**,否则无源终态行写不出 |
`GatewayClient` 需自存 `self._now``self._emitter`(现未保存);统计生效与否**不由 telemetry 是否启用决定**`emitter is None` 时统计照常,仅无行可写)。
## 4. 任务与提交点
### T0:设计批准状态、计划与基线(本任务)
- [x] 设计文档状态行改为"人类已批准(2026-09-09",新建本计划(≤600 行)。
- [x] 记录基线:`git status --short --branch``git log --oneline -3`、HEAD `a81cc91`;确认源码零差异,`CLAUDE.md` 既有 markdown 差异与 `.pi/` 一律不动、不暂存。
- [ ] 基线验证(由执行者在 T1 之前跑一次,作为"先失败"的对照底):`make check``conda run -n PolyGateway pytest tests/unit -q` → 预期全绿。**基线计数以本会话实跑输出为准**(近期会话记录为 1385 项量级),不拿计划里的数字当期待值;实际失败照录,不改期待绕过。
- [ ] 计划自审 + 独立模型审查(替代 Codex,用户已同意);意见逐条核验后就地修订。
- [ ] wiki 登记:`.claude/tools/research_wiki.py add_entity/add_edge/rebuild_index`design ↔ plan `implements`),登记前先确认工具不覆盖同路径已有文档。
- [ ] 提交点:`docs: record approved call observability design and plan`(生产改动前的回滚点)。
### T1:统计内核与三条链路的逻辑边界(不动遥测列)
**文件**`types.py``__init__.py``client.py``embedding.py``ocr.py``middleware/retry.py``middleware/cache.py`;测试 `tests/unit/test_types.py``test_client.py``test_retry.py``test_cache.py``test_embedding.py``test_ocr_client.py``test_structured.py`
按 §3.1 与 §3.5 实现。先写测试并确认在旧实现上红(`AttributeError: 'LLMResponse' object has no attribute 'call_stats'` 不算行为红——先落一个"同一次调用的重试次数无处可读"的行为断言,再实现)。
| 红绿组 | 必须证明 |
| --- | --- |
| 计数 | 一次成功=1;失败重试=实际尝试数;免预算 429 计入;多源拒绝(端口本地拒绝)计入;结构化重问计入同一上下文且不重置;embedding 三批=3OCR layout 双 HTTP=1;缓存命中=0;空输入=0 |
| 逻辑 ID | 重试/重问/分批共享同一 `logical_call_id`;同一 client 并发两次调用互不串(`asyncio.gather` 两路各自计数与 ID);`parent_call_id` 语义不变 |
| 计时(FakeClock | 缓存 IO、退避 sleep、准入等待、重问、内联遥测收尾全部计入 `total_latency_ms`;毫秒/秒不混用(1.5s → 1500);`emitter=None` 时统计仍正确。**替身构造要求**:假缓存后端的 `get`/`set` 与假 recorder 的 `record_llm_call` 内部**必须主动推进 FakeClock**,否则"缓存 IO/内联遥测计入总耗时"的断言会退化成恒等于 0 的空转绿 |
| 缓存不毒化 | `_serialize` 输出无 `call_stats` 键;手写含 `"call_stats": {...}` 的历史条目命中后 `response.call_stats is None`**dict 不得冒充 `CallStats`**);缓存 key 逐字节不变(黄金值) |
| 附加点 | 失败异常上**不附任何统计字段**(`hasattr(exc, "call_stats") is False`);`CancelledError` 类型与语义不变;非领域异常原样传播 |
| 空输入 | `embed([])` 返回 `attempts=0`、真实 `logical_call_id`,且注入的内存 recorder **零行** |
**验证**`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_client.py tests/unit/test_retry.py tests/unit/test_cache.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_structured.py -q` → 目标断言先红后绿,其余保留行为绿;`make check`(含 import-linter`types.py` 不得引入实现层 import)。
- [ ] 提交点:`feat: track logical call statistics across governed calls`
### T2:遥测 10 列、诊断保真、scope/operation 与装配闸
**文件**`telemetry/schema.py``ports.py``middleware/telemetry.py``middleware/structured.py``middleware/retry.py``transports/openai_compat.py`、三个 client 的 Emitter 构造行;测试 `tests/unit/test_telemetry.py``test_ports.py``test_openai_compat.py``test_monkey_ocr.py``test_structured.py`
按 §3.2–§3.4 实现(本任务只产出 attempt / cache_hit 两类行的新列,终态行留 T3)。先写测试确认旧实现红:现状下 `error` 列是被 `str()` 压平的自由文本、无 `scope`/`operation` 列、旧签名 recorder 只落 warning。
| 红绿组 | 必须证明 |
| --- | --- |
| 列与 SQL | `len(COLUMNS) == 36`、物理列 37、五处列序一致(新建库与 ALTER 追加列序相同);`insert_sql` 两端语句更新;`telemetry_schema_sql` 输出与库内 DDL 同源 |
| 端口 | `inspect.signature` 实测 10 个新参存在、keyword-only、无默认值;docstring 字段数与实测一致 |
| 诊断保真 | 中转把 529 改写成 503 → 记 503**不猜回 529**);直接 529 → 529;空 `str()` 的 Connect/Read/Write/PoolTimeout → error 落类名、`cause_type` 为对应 httpx 类名;`error_body``summarize_body` 摘要且不等于 `raw_text`;成功行五列 NULL(不填 200) |
| operation | `embed` 非 200 → `exc.operation == "embedding"`(历史误标修正);新列 `operation` 恒为四值之一,与 `exc.operation` 无关、不随异常变化;`monkey_ocr``success != true`**200** 的失败行 → `http_status_code == 200`(该列不可作失败判据) |
| scope | attempt 与 cache_hit 行都带 scopemodel/provider/source 未选出时仍留原空值 |
| 字符串入参 | 取消 attempt 的 `"cancelled"` 仍原样落 `error`,其余四列 NULL(**不解析字符串**) |
| 装配闸 | 旧签名 recorder → 构造 `TelemetryEmitter`(即三个 client 装配)**抛错**而非 warning`**kwargs` recorder 通过;不可 inspect 的对象 → 配置错误报错;参数名确由协议签名派生(改协议后闸自动跟随的断言) |
| 有界说明 | `format_bounded_errors` 重命名后 `StructuredMW` 反馈文案逐字不变(黄金串);常量只有一份定义 |
**验证**`conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_openai_compat.py tests/unit/test_monkey_ocr.py tests/unit/test_structured.py -q``make check`
- [ ] 提交点:`feat: record scope, operation and failure diagnostics per row`
### T3:终态行、取消口径与统一出口
**文件**`middleware/telemetry.py`(终态 helper 与 `TelemetryMW` 收缩)、`client.py``embedding.py``ocr.py`;测试 `tests/unit/test_telemetry.py``test_client.py``test_embedding.py``test_ocr_client.py``test_structured.py``test_retry.py`
按 §3.2 的 `emit_terminal_once` 与 §3.5 的三条链路实现。先写测试确认旧实现红:结构化耗尽当前**没有任何失败行**;embedding/OCR 的无源、准入拒绝、重试耗尽与尝试外取消同样无终态行。
| 红绿组 | 必须证明 |
| --- | --- |
| 补漏 | chat 结构化耗尽、embedding/OCR 的 `no_sources`、准入拒绝、`retry_exhausted`、尝试外取消各恰有 **1**`terminal_failure` |
| 不变量 I3 | 领域失败每调用至多 1 条(recorder 写失败仅 warning,SQL 可见 ≤1);重复调用出口不产生第二条(`claim_terminal`);直接 `RequestRejectedError` / `ResultInvalidError` 现在**既有 attempt 错误行也有终态行**(400 密集负载错误行翻倍是已批准的下游可见变化) |
| 非领域异常 | 编程错(如 `KeyError`)→ **0 条**终态行、原样传播、分类不被改写 |
| 取消三路 | chat / embed / OCR 同策略尽力写一条(允许 0);**终态写的 await 上被取消 → `CancelledError` 传播**(不 shield、无后台任务);permit 与探针释放行为不变(`inflight == 0` |
| 归因 SQL | 设计 §5 那条 `WHERE logical_call_id = :lcid` 查询同时给出终态 reason 文案与逐源状态码/正文;终态行 `http_status_code`/`cause_type`/`error_body` 三列 NULL;结构化耗尽的终态 `error` 含有界 validation/repair 说明且**不含 `raw_text`** |
| 行语义 | 三类行均带 scope;`event_kind` 可区分;`SUM(cost)` 不因终态行变化(终态 `cost IS NULL`、usage `unavailable`);`AVG(latency_ms)``event_kind` 分组的断言;终态 `latency_ms == total_latency_ms`(同一快照) |
| 快照时机 | 终态快照不含自身写入耗时(FakeClock:写入内推进时钟,列值不变);成功响应快照含返回前已完成的内联遥测耗时 |
| 摘要 | embedding 终态 messages 为占位 + 第一批截断文本,不含全量原输入;OCR 终态沿用 `<ocr:{kind} image_bytes=…>`,图像 bytes 不入库;OCR 终态 error 保留类名前缀 |
**验证**`conda run -n PolyGateway pytest tests/unit -q`(全量单测,含上述文件);`make check`
- [ ] 提交点:`feat: emit one terminal failure row per logical call`
### T4:旧测试机械核对、存储兼容、变异证据、文档与独立验证
**文件**:下述机械核对清单 + `tests/integration/test_postgres_telemetry.py` + 文档清单 + 验收 finding。
**一次机械核对(禁止分散反复修)**:本版真正的破坏面**不只是 recorder 签名,还有 Emitter 侧**`TelemetryEmitter.__init__` 新增必填 `scope`、三个 `emit_*` 新增必填 `operation``emit_terminal_failure``latency_ms` 改收 `stats`),这些关键词不含 `record_llm_call`/`COLUMNS`。故全量清单用:
```bash
grep -rn "record_llm_call\|COLUMNS\|_EXPECTED_COLUMNS\|TelemetryEmitter(\|emit_attempt(\|emit_cache_hit(\|emit_terminal_failure(" tests/ --include=*.py
```
一轮改完再跑,不逐个文件试错。已核实的免改项:`tests/unit/test_backpressure.py:231` 的假 emitter 是 `emit_attempt(self, *args, **kwargs)`,兼容;`tests/integration/` 无 Emitter 构造点。已知点:
| 位置 | 动作 |
| --- | --- |
| `tests/unit/test_ports.py:99` `_DummyRecorder` | 显式签名补齐 10 参(它是"新签名可实现"的活证据,不改成 `**kwargs` |
| `tests/unit/test_ports.py:264` `TestTelemetryRecorderSignature` | 新参进 `no default` / keyword-only 参数化 |
| `tests/unit/test_telemetry.py:40` `_EXPECTED_COLUMNS`、:220/:321/:597/:639/:647/:686-698 计数与尾部断言 | 26→36、27→37、尾部 10 列、旧表 backfill 目标列数 |
| `tests/unit/test_telemetry.py:108` `_record_minimal` | 字段字典补 10 键(默认 NULL 形态) |
| `tests/integration/test_postgres_telemetry.py:100` 字段字典 | 同上(该函数返回值被逐列断言消费,改动须与 `COLUMNS` 同序) |
| `test_client.py` / `test_embedding.py` / `test_ocr_client.py` / `test_openai_compat.py` / `test_pricing.py` / `test_cache.py` / `test_usage_source_domain.py``_MemoryRecorder` | 均为 `**fields` 形态,**recorder 签名无需改**;只需核对断言里的字段计数与新列期望 |
| `tests/unit/test_pricing.py``tests/unit/test_usage_source_domain.py`**Emitter 调用点** | **须改**`TelemetryEmitter(``scope=``emit_attempt(`/`emit_cache_hit(``operation=``test_usage_source_domain.py:297``emit_terminal_failure(...)` 同时缺 `scope`/`operation`/`stats` 且多传 `latency_ms`,不改必 `TypeError`recorder 形态兼容 ≠ emitter 调用点兼容) |
**存储兼容**(真实 PG,复用 `tests/integration/conftest.py``pg_sandbox` / `pg_catalog_probe`,不新建沙箱设施;**不引用 `assert_no_leftovers`**——它是 `tests/integration/test_pg_sandbox.py:19` 的模块级 fixture,对 `test_postgres_telemetry.py` 不可见,上提它要改 §2 清单外的 `conftest.py`。本版不新增沙箱资源创建路径,残留风险与 1.3.4 逐字相同,由该文件既有用例覆盖,属可复用的既有证据):
- SQLite:新建库 37 列;旧表(1.3.4 形态 27 列)auto 档补齐 10 列且列序与新建库一致;manual 档不发 DDL、按现有列裁剪写入并发一条点名缺列的 warning;旧行新列为 NULL。
- PG(验收取以下**四项**):auto 追加 10 列;**manual 缺列裁剪**写入成功且不抛(`_trim_columns` 路径);旧行新列为 NULL;新旧进程混写同一表(旧列集写入 + 新列集写入并存)。
**变异证据**(仓库外临时副本 + `PYTHONPATH=<副本>/src`,先确认 `polygateway.__file__` 指向副本;逐个变异 → 跑指定节点记 exit 1 与被杀断言 → 恢复校验散列 → exit 0;**绝不在主工作区改生产代码凑红**):
| 变异 | 必须被杀死的断言 |
| --- | --- |
| `register_attempt()` 移到 transport 调用之后的 `except` 分支外/内错位 | 失败重试与 429 计数断言 |
| `ChatRequest` 上下文字段改为 `replace` 时新建实例(模拟上下文复制) | 重问/分批共享同一 `logical_call_id` 的断言 |
| `_rehydrate` 去掉 `call_stats=None` 覆盖 | 历史 dict 冒充 `CallStats` 的断言 |
| Emitter 入口提前 `str(exc)` 压平 | `error_type`/`http_status_code`/`error_body` 保真断言 |
| 终态行复制最后一次 attempt 的 token/cost | 费用聚合与终态 `cost IS NULL` 断言 |
| 去掉 `claim_terminal` 去重 | 每失败调用至多一条终态的断言 |
| 装配闸改为捕获 `TypeError` 后 warning | 旧签名 recorder 装配期报错的断言 |
**文档同步(发布前必须同批;wiki 站点自 2026-08-02 下线,按 docs-convention §2 的下线期条款,承接方为 README / CHANGELOG / .env.example / ARCHITECTURE 四处)**
| 位置 | 改什么 |
| --- | --- |
| `README.md:23` | "必录 26 字段" → 实测值(`len(inspect.signature(TelemetryRecorder.record_llm_call).parameters) - 1`,预期 36),不凭记忆 |
| `README.md:26/399/455``ARCHITECTURE.md:592``.env.example:109` | `PGW_TELEMETRY_TEXT_CAP` 覆盖面仍是四处;明确 `error_body` 沿用 `summarize_body` 上限、`error` 的新增结构化说明另有独立限长,**二者都不在 cap 覆盖内** |
| `README.md` 能力表 + 新增小节 | 四响应的 `call_stats` 读法;设计 §8 的 SQL 迁移**五项**(失败行数改判据、`error IS NOT NULL` 不再是判据、`AVG(latency_ms)` 须按 `event_kind` 分组、费用口径不变、失败行可能带 200) |
| `ARCHITECTURE.md:565` 必录字段清单 + §7.8 补列一节 | 追加 10 列语义、`event_kind` 三态、终态行不变量 I3/I4、`operation``exc.operation` 是两个语义 |
| `CHANGELOG.md` 未发布段 | 公共面四项 + 下游动作清单(点名"计失败调用改 `WHERE event_kind = 'terminal_failure'`"与自定义 recorder 的装配期报错) |
| `research-wiki/schemas/llm-calls.md``metrics/call-telemetry-coverage.md` | 复用既有实体登记新列与三类行口径;真实覆盖基线待首次运行填,不写伪百分比 |
**验收命令与证据**
| 检查 | 命令 / 要求 |
| --- | --- |
| 静态 | `make check``git diff --check``conda run -n PolyGateway python -m compileall -q src/polygateway` |
| 日常全量 | `make test`(真实退出码、coverage ≥80%,连接依赖 skip 单列) |
| 集成 | `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py tests/integration/test_governance_stack.py -q` |
| 契约 | `conda run -n PolyGateway pytest tests/contracts -q`(限流契约随实现一起交付的既有套件) |
| 变异 | 上表七项逐条 exit 1 → 还原 exit 0,日志留 `tests/outputs/135/` |
| 独立验证 | 由**父会话前台派全新上下文 verifier**:只给批准设计、本计划、分支 diff 与命令,不给实现自评;至少覆盖"正确性/回归"与"下游可见变化/范围"两角度,Critical/Important 清零 |
| slow | `pytest -m slow` 属发布清单第 4 步(CLAUDE §4.4.1),在合并 main 之后统一跑;**本版不新增 live 轮次、不补跑未变更的模型能力矩阵**,1.3.4 已有有效证据直接引用 |
长跑用 tmux`PYTHONUNBUFFERED=1`,输出重定向到 `tests/outputs/135/`,命令后**不得接管道**(退出码失真),完成判定用 `wait`/PID 轮询,不用会自匹配的 `pgrep -f`
- [ ] 提交点:`docs: document logical call telemetry and migration impact`(机械核对与存储兼容若需单独回滚点,可先提 `test: align telemetry fixtures with the 36-field recorder`,仍在 5 个提交内)
## 5. 测试矩阵 → 任务映射(设计 §9 逐族落位)
| 设计测试族 | 任务 |
| --- | --- |
| logical 计数(含空输入 0 尝试 0 行) | T1 |
| 计时(缓存/退避/准入/重问/收尾、关 recorder) | T1(快照时机的终态部分在 T3) |
| 失败与取消(三路同策略、终态 await 取消传播、非领域异常 0 行) | T3 |
| 保真诊断(529/503、空超时文案、`exc.operation`、新列四值) | T2 |
| 归因 SQL(终态 reason + 逐源现场、结构化说明无 `raw_text` | T3 |
| 行语义(三类行 scope、至多一条终态、费用不重复、`AVG` 分组) | T2(前两项)+ T3 |
| 装配闸(旧签名报错、`**kwargs` 通过、不可 inspect | T2 |
| 存储兼容(SQLite 新旧表、PG manual/auto、旧行 NULL、混写) | T4 |
| 变异(计数位置、上下文复制、缓存回放、提前压平、终态双计费用) | T4 |
**共享测试设施一律复用,不新建**`tests/contracts/conftest.py:26``tests/unit/test_ocr_client.py:344``FakeClock`(注入 `now`/`sleep`,确定性计时);各测试文件既有的 `_MemoryRecorder``**fields`)与脚本化 MockTransport`tmp_path` + 真实 `SQLiteRecorder(auto_migrate=...)``tests/integration/conftest.py` 的 PG 沙箱三件套。真实 LLM 输出如产生,按 CLAUDE §4.6 落 `tests/outputs/<module>/`
## 6. 阻塞矩阵与发布交接
| 情形 | 本库可完成 | 不可自行宣称/处置 |
| --- | --- | --- |
| 无 PG 可用 | 单测与 SQLite 全部覆盖;PG 用例 skip 并单列 | 不得把 skip 记作通过;发布前须补跑或取人类具名豁免 |
| 下游自定义 recorder 未知 | 装配闸 + 迁移文档 + `**kwargs` 兼容路径 | 不能声称"后端完全不受影响":其 schema、INSERT 字段与契约测试仍须同步 |
| 设计外漏洞 | 独立记录实际文件与反例,交父会话核定 | 不顺手实施 #20 之外的 issue、不新增表/端口/deadline/hedging |
| 发布 | 门全绿后按 CLAUDE §4.4.1 逐步执行(文档先行 → CHANGELOG 定版 → 双处版本号 → 合并 main → `make lint`/`make test`/`pytest -m slow` → tag → 构建 → 上传 → 下载验证 → Release + 挂仓库 + 页面核对) | 本计划**不复制**该清单,也不预先勾选任何发布步骤;只 bump 版本号不叫发布 |
## 7. 自审
- 设计每节可指到任务:§3→T1、§4→T1、§5→T2/T3、§6→T3、§7→T2、§8→T4、§9→§5 映射表、§10 六项批准项全部落在 T1–T3 的公共面改动内。
- 无占位符与待定项:跨任务消费的类型(`CallStats``_CallContext``_ErrorFields`)、四个 emit 入口、两个 helper`_assert_recorder_shape``emit_terminal_once`)、10 列取值表、10 列 DDL 类型均已写出可执行定义;被引用的 `PolyGatewayError``ResultInvalidError``summarize_body``format_bounded_errors``FakeClock``pg_sandbox` 全部指向既有实现或本计划已定义项。
- 一致性核对:`emit_terminal_failure` 去掉 `latency_ms` 后,唯一调用者是 `emit_terminal_once``TelemetryMW` 的终态分支删除后 chat 终态只剩客户端边界一处;`operation` 只由调用点给定,链路上无任何位置读 `exc.operation`
- 未采纳项(异常上挂可变统计、终态搬运最后一次 attempt 的状态与正文、成功侧汇总行)理由在设计正文,本计划不复活。
- 本计划编写过程**未运行 pytest、未做变异、未调用任何模型、未提交**;T0 基线验证与其后各任务的红绿证据由执行者在自己的会话内出示。
+42 -7
View File
@@ -1,12 +1,11 @@
---
type: schema
node_id: schema:llm-calls
title: "表结构: llm_calls(遥测 26 字段)"
title: "表结构: llm_calls(遥测 36 字段)"
date: 2026-07-20
---
# 表结构: llm_calls(遥测 26 字段)
# 表结构: llm_calls(遥测 36 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
@@ -31,7 +30,38 @@ date: 2026-07-20
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 |
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB |
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
| reasoning_effort | TEXT | 本次调用**实际发出**的推理档位(2026-09-04,issue #20);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
| reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
| scope | TEXT | 池名(2026-09-09,issue #19/#23);Emitter 构造期注入,三类行都带,**不拿 `source_name` 顶替** |
| operation | TEXT | `chat` / `embed` / `recognize_text` / `parse_layout`,由调用点给定;与 `PolyGatewayError.operation` 是两个语义,链路上不得读后者填本列 |
| logical_call_id | TEXT | 一次逻辑调用一个 ID(重试/换源/重问/分批共享);上下文缺席落 NULL,**不造 ID** |
| event_kind | TEXT | 三态 `attempt` / `cache_hit` / `terminal_failure`——三类行的唯一机械判据。见下方口径 |
| http_status_code | INTEGER | 失败 attempt 行的实收状态码(中转改写过就记改写后的,**不猜回原值**);成功行与终态行恒 NULL,且 200 也可能是失败行(MonkeyOCR `success != true`) |
| error_type | TEXT | 该行自身错误的类名;取消路径传字符串故为 NULL |
| cause_type | TEXT | `__cause__` 的类名(如 `ReadTimeout`)——httpx 超时类的 `str()` 为空,只靠 error 列分不出是哪种超时;仅失败 attempt 行非空 |
| error_body | TEXT | 网关响应正文摘要(`summarize_body` 上限,**不在 `PGW_TELEMETRY_TEXT_CAP` 覆盖面内**);仅失败 attempt 行非空 |
| attempts | INTEGER | 该逻辑调用真实打出去的尝试次数(免预算 429 也计);**只属终态行** |
| total_latency_ms | INTEGER | 该逻辑调用的总墙钟(含缓存 IO、退避、准入等待、重问);**只属终态行**,与该行 `latency_ms` 同取一份冻结快照 |
## 三类行与失败归因口径(2026-09-09,issue #19/#23)
遥测行不再只有"一次尝试"一种。`event_kind` 三态同时决定其余列的取值面:
| event_kind | 什么时候写 | 列取值 |
| --- | --- | --- |
| `attempt` | 每次真实尝试(含失败与取消) | 失败时诊断四列可非空;`attempts`/`total_latency_ms` NULL |
| `cache_hit` | 缓存命中 | 诊断四列与快照两列全 NULL |
| `terminal_failure` | 每次**领域失败**的整池终态,每逻辑调用至多一条 | `attempts`/`total_latency_ms` 非空;`http_status_code`/`cause_type`/`error_body` 恒 NULL;`cost` NULL、`usage_source='unavailable'`、token 0 |
两条不变量: 每次领域失败至多一条终态行(I3,`claim_terminal()` 去重);非领域异常(编程错)**零条**终态行、原样传播(I4)。
**终态行三列恒 NULL 是红线**: 把最后一次 attempt 的状态码与正文搬上来,等于拿最后一个源冒充整池归因。逐源现场由同一 `logical_call_id` 的 attempt 行给出:
```sql
SELECT event_kind, source_name, http_status_code, error_type, cause_type, error, error_body
FROM llm_calls WHERE logical_call_id = :lcid ORDER BY created_at;
```
下游口径迁移四条: ① 计失败调用改 `WHERE event_kind = 'terminal_failure'`;② `error IS NOT NULL` 不再是失败调用判据(跨两类行);③ `AVG(latency_ms)` 须按 `event_kind` 分组(终态行是整个逻辑调用的总耗时);④ 费用口径不变(终态行 cost 恒 NULL)。
## usage/成本口径(2026-07-30,est_tokens 解耦)
@@ -107,7 +137,7 @@ ORDER BY model, calls DESC;
## 推理档位口径(2026-09-04,issue #20)
`reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
`reasoning_effort` 的来源取决于行类型,不能总括为「实际发出」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
三个 emit 入口的取值同样各自定死,与 `sampling` 同构:
@@ -115,9 +145,10 @@ ORDER BY model, calls DESC;
| --- | --- | --- |
| `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** |
| `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** |
| `emit_cache_hit` / `emit_terminal_failure` | 无 | `request.reasoning_effort` |
| `emit_cache_hit` | 无 | 本次 `request.reasoning_effort`,不取历史 applied、不推源级 |
| `emit_terminal_failure` | 无 | 本次 `request.reasoning_effort`,可能尚未选源 |
成功行必须读实档而非重算: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。
真实成功尝试必须读实际编码档而非重算(分析须同时排除 cache_hit 和 error;未知 AUTO 仅尽力,不证明上游推理,raw-only=NULL: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。
OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。
@@ -142,3 +173,7 @@ ORDER BY model, calls DESC;
## 评估基线
首版无历史基线,标"待首次运行后建立";验收断言: 单测覆盖成功/失败/缓存命中/取消四路径各产生恰一行;并发 50 协程写全落库。
## 1.3.4 测试侧证据(不新增 schema)
`tests/live_evidence.py`e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。
+3 -1
View File
@@ -39,6 +39,7 @@ from polygateway.thinking import (
)
from polygateway.types import (
EFFORT_ORDER,
CallStats,
Effort,
EmbeddingResponse,
LLMResponse,
@@ -50,13 +51,14 @@ from polygateway.types import (
ThinkingObservation,
)
__version__ = "1.3.3"
__version__ = "1.3.5"
__all__ = [
"DEFAULT_PROFILES",
"EFFORT_ORDER",
"Effort",
"AllSourcesExhausted",
"CallStats",
"CircuitOpenError",
"EmbeddingClient",
"EmbeddingResponse",
+54 -11
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import asyncio
import dataclasses
import hashlib
import json
import random
@@ -19,11 +20,12 @@ from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.config import GatewaySettings
from polygateway.errors import PolyGatewayError
from polygateway.middleware.base import compose
from polygateway.middleware.cache import CacheMW
from polygateway.middleware.retry import RetryMW
from polygateway.middleware.structured import StructuredMW
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW, emit_terminal_once
from polygateway.ports import TelemetryStatusProvider
from polygateway.pricing import PricingTable
from polygateway.providers import get_provider
@@ -34,13 +36,19 @@ from polygateway.sources import (
RoundRobinSelector,
SourceCooldownMemo,
)
from polygateway.thinking import effective_effort, get_capability, resolve_thinking
from polygateway.thinking import (
effective_effort,
get_capability,
resolve_thinking,
validate_thinking_raw,
)
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
TelemetryStatus,
_CallContext,
coerce_effort,
validate_caller_dimensions,
validate_request_overlay,
@@ -82,21 +90,26 @@ def _guard_thinking(
就带着指路信息炸掉`get_provider` 现在就是同一形态的双点调用
"""
for source, profile in zip(sources, profiles, strict=True):
resolve_thinking(
profile,
get_capability(source.model, table=capabilities),
# 装配期看不见请求级档位(它逐次调用才产生),故只解源级两层;请求级
# 只能在运行期由 transport 校验(设计 §10 的装配期/运行期分工)
effective_effort(
effort = effective_effort(
request_effort=None,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
),
)
resolve_thinking(
profile,
get_capability(source.model, table=capabilities),
effort,
model=source.model,
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
# 判死,而它在运行期本来是能映射到最近档跑起来的
fallback=source.effort_fallback,
)
validate_thinking_raw(
source.extra_body,
effort=effort,
wire=profile.thinking,
origin=f"{source.name} extra_body",
)
def _fingerprint_mark(source: SourceConfig) -> str:
@@ -227,7 +240,7 @@ class GatewayClient:
rng: Any = random.random,
) -> None:
emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap)
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
if telemetry is not None
else None
)
@@ -279,6 +292,10 @@ class GatewayClient:
self._structured_available = structured_strategy is not None
self._terminal = terminal # 内部引用: 装配自省/测试用
self._handler = compose(middlewares, terminal)
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
self._now = now
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
self._emitter = emitter
self._transport = transport
self._telemetry = telemetry
self._cache = cache
@@ -359,6 +376,9 @@ class GatewayClient:
if reasoning_effort is None
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
)
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
context = _CallContext(now=self._now)
request = ChatRequest(
messages=messages,
session_id=session_id,
@@ -372,8 +392,31 @@ class GatewayClient:
reasoning_effort=effort,
tenant_id=dimension_tenant_id,
meta=dimensions,
call_context=context,
)
return await self._handler(request)
try:
response = await self._handler(request)
except PolyGatewayError as exc:
# 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3),
# 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述
# 的不是同一件事(尝试 vs 逻辑终态),由 `event_kind` 区分
await emit_terminal_once(
self._emitter, request=request, context=context, error=exc, operation="chat"
)
raise
except asyncio.CancelledError:
# 尽力而为且**取消优先**: 不 shield、不开后台任务;写入那一次 await 上
# 再被取消则 `CancelledError` 照常传播(与 TelemetryMW 历史行为同款)
await emit_terminal_once(
self._emitter,
request=request,
context=context,
error="cancelled",
operation="chat",
)
raise
# 快照在返回前冻结: 故它含缓存命中路径与已完成的内联遥测耗时
return dataclasses.replace(response, call_stats=context.snapshot())
async def aclose(self) -> None:
"""幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。
+99 -8
View File
@@ -16,6 +16,7 @@
from __future__ import annotations
import asyncio
import dataclasses
import math
import random
import time
@@ -41,12 +42,13 @@ from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
from polygateway.types import (
ChatRequest,
EmbeddingResponse,
LLMResponse,
TelemetryStatus,
_CallContext,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
@@ -127,7 +129,9 @@ class EmbeddingClient:
self._transport = transport
self._retry = retry
self._emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None
TelemetryEmitter(telemetry, scope=self._scope, pricing=pricing, text_cap=text_cap)
if telemetry
else None
)
self._telemetry = telemetry
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
@@ -184,7 +188,11 @@ class EmbeddingClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
)
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
context = _CallContext(now=self._now)
if not texts:
# 合法零尝试: 返回真实统计(attempts=0),且**不写任何遥测行**
# ——与 cache_hit 不同,不要按"遥测必录"推断它有台账行(设计 §3 M2)
return EmbeddingResponse(
vectors=[],
dim=0,
@@ -195,7 +203,40 @@ class EmbeddingClient:
latency_ms=0,
call_id=str(uuid.uuid4()),
source_name="",
call_stats=context.snapshot(),
)
try:
return await self._embed_all(
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
)
except PolyGatewayError as exc:
await self._emit_terminal(
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context, exc
)
raise
except asyncio.CancelledError:
# 三条链路同一口径尽力写一条(允许 0 条);取消优先,不 shield
await self._emit_terminal(
texts,
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
context,
"cancelled",
)
raise
async def _embed_all(
self,
texts: list[str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> EmbeddingResponse:
"""切批串行执行并合并;无源的 raise 必须在本方法内——否则无源终态行写不出。"""
if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
outcomes = []
@@ -205,11 +246,49 @@ class EmbeddingClient:
texts[start : start + self._batch_size],
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
tenant_id,
meta,
context,
)
)
return self._merge(outcomes)
# 全批共享同一上下文,故分批是实现细节而非 N 次独立逻辑调用
return dataclasses.replace(self._merge(outcomes), call_stats=context.snapshot())
async def _emit_terminal(
self,
texts: list[str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
error: PolyGatewayError | str,
) -> None:
"""终态行的请求摘要(设计 §6 M4): 计数占位 + 第一批截断文本。
描述的是**本次调用的整体输入**但不扩大单行正文预算: 失败批的具体文本由同
`logical_call_id` attempt 行给出,终态行不保存全量原输入
"""
batches = math.ceil(len(texts) / self._batch_size)
messages = [{"role": "user", "content": f"<embed texts={len(texts)} batches={batches}>"}]
# 与逐批行同款构造(至多 `batch_size` 条、每条 200 字符)
messages += [
{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in texts[: self._batch_size]
]
await emit_terminal_once(
self._emitter,
request=ChatRequest(
messages=messages,
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
call_context=context,
),
context=context,
error=error,
operation="embed",
)
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
@@ -220,6 +299,7 @@ class EmbeddingClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> _BatchOutcome:
fails = 0
reasons: dict[str, str] = {}
@@ -232,7 +312,7 @@ class EmbeddingClient:
continue
async with clock.attempting():
outcome = await self._attempt(
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta, context
)
if isinstance(outcome, _BatchOutcome):
return outcome
@@ -258,10 +338,13 @@ class EmbeddingClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> _BatchOutcome | _FailedBatch:
call_id = str(uuid.uuid4())
started = self._now()
actual = 0
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
context.register_attempt()
try:
result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
if self._expected_dim is not None and result.dim != self._expected_dim:
@@ -287,6 +370,7 @@ class EmbeddingClient:
parent_call_id,
tenant_id,
meta,
context,
result,
)
return _BatchOutcome(result, source, call_id, latency_ms)
@@ -301,6 +385,7 @@ class EmbeddingClient:
parent_call_id,
tenant_id,
meta,
context,
error=exc,
)
raise
@@ -316,6 +401,7 @@ class EmbeddingClient:
parent_call_id,
tenant_id,
meta,
context,
error="cancelled",
)
raise
@@ -336,6 +422,7 @@ class EmbeddingClient:
parent_call_id,
tenant_id,
meta,
context,
error=exc,
)
return _FailedBatch(exc, immediate=dead)
@@ -372,8 +459,9 @@ class EmbeddingClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
result: EmbeddingTransportResult | None = None,
error: object | None = None,
error: PolyGatewayError | str | None = None,
) -> None:
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
if self._emitter is None:
@@ -386,6 +474,7 @@ class EmbeddingClient:
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
call_context=context,
)
response = None
if result is not None:
@@ -410,10 +499,12 @@ class EmbeddingClient:
call_id=call_id,
latency_ms=int((self._now() - started) * 1000),
response=response,
error=None if error is None else str(error),
# 异常对象原样下传: 状态码/底层异常类型/网关正文在 Emitter 内定型
error=error,
# embedding payload 硬编码 {model, input},从不带推理参数;源上即便
# 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事
reasoning_applies=False,
operation="embed",
)
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
+7
View File
@@ -208,6 +208,10 @@ class CacheMW:
max_inter_token_ms=None,
call_id=str(uuid.uuid4()),
structured_data=structured_data,
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
# 过滤**会放行它**——不覆盖就会有 dict 冒充 `CallStats` 漏给调用方。
# 本次调用的真实统计由公开边界在返回前追加(设计 §3)
call_stats=None,
)
return LLMResponse(**fields)
except Exception as exc:
@@ -230,6 +234,9 @@ class CacheMW:
def _serialize(self, response: LLMResponse) -> str:
data = dataclasses.asdict(response)
data.pop("structured_data", None) # pydantic 实例不可 JSON 往返(设计 §2.1)
# 统计描述**本次**调用,存进去再放出来等于向下一个调用方谎称
# 它重试了 N 次;`asdict` 会把 `CallStats` 摊成 dict,故必须显式剔除
data.pop("call_stats", None)
return json.dumps(data, ensure_ascii=False)
async def _safe_get(self, key: str) -> str | None:
+16 -4
View File
@@ -42,6 +42,7 @@ from polygateway.types import LLMResponse
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.ports import (
GateDecision,
Permit,
@@ -181,7 +182,7 @@ class RetryMW:
circuit_open: str = "fail_fast",
cooldown_memo: SourceCooldownMemo | None = None,
pacer: AdaptivePacer | None = None,
emitter: object | None = None,
emitter: TelemetryEmitter | None = None,
now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random,
@@ -278,6 +279,11 @@ class RetryMW:
call_id = str(uuid.uuid4())
started = self._now()
actual = 0
# 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样
# "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。
# 上下文为 None = 库内现场构造的请求,跳过而不是报错
if request.call_context is not None:
request.call_context.register_attempt()
try:
result = await self._transport.complete(
messages=request.messages,
@@ -408,9 +414,13 @@ class RetryMW:
started: float,
*,
response: LLMResponse | None = None,
error: object | None = None,
error: PolyGatewayError | str | None = None,
) -> None:
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。"""
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。
异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码底层异常
类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉
"""
if self._emitter is None:
return
try:
@@ -420,9 +430,11 @@ class RetryMW:
call_id=call_id,
latency_ms=int((self._now() - started) * 1000),
response=response,
error=None if error is None else str(error),
error=error,
# chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算
reasoning_applies=True,
# 公开方法四值之一;本中间件只服务 chat 洋葱
operation="chat",
)
except asyncio.CancelledError:
raise
+16 -5
View File
@@ -14,6 +14,8 @@ from typing import TYPE_CHECKING
from polygateway.errors import ResultInvalidError
if TYPE_CHECKING:
from collections.abc import Sequence
from polygateway.ports import CallNext, StructuredOutputStrategy
from polygateway.types import ChatRequest, LLMResponse
@@ -22,12 +24,18 @@ _FEEDBACK_TEMPLATE = (
"Your previous reply was not valid JSON matching the required schema. "
"Errors: {errors}. Reply with ONLY the corrected JSON object."
)
_MAX_FEEDBACK_ERRORS = 3
_MAX_ERROR_CHARS = 200
MAX_FEEDBACK_ERRORS = 3
MAX_ERROR_CHARS = 200
def _format_errors(errors: list[str]) -> str:
clipped = [e[:_MAX_ERROR_CHARS] for e in errors[:_MAX_FEEDBACK_ERRORS]]
def format_bounded_errors(errors: Sequence[str]) -> str:
"""校验错误的有界拼装: 至多 3 条 × 每条 200 字符。
**本模块是这条规则的所有者**: 重问反馈文案与 1.3.5 终态行的结构化说明
两个消费者共用同一份实现与同一组数值数值复制成两份必然漂移,而漂移后
"模型看到的错误""台账里记的错误"就不再是同一件事行为与重命名前逐字相同
"""
clipped = [e[:MAX_ERROR_CHARS] for e in list(errors)[:MAX_FEEDBACK_ERRORS]]
return "; ".join(clipped) if clipped else "output could not be parsed"
@@ -104,7 +112,10 @@ class StructuredMW:
messages = [
*current.messages,
{"role": "assistant", "content": bad_content},
{"role": "user", "content": _FEEDBACK_TEMPLATE.format(errors=_format_errors(errors))},
{
"role": "user",
"content": _FEEDBACK_TEMPLATE.format(errors=format_bounded_errors(errors)),
},
]
reask = dataclasses.replace(current, messages=messages)
if self._escalation is not None:
+284 -48
View File
@@ -2,13 +2,19 @@
Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制
15 参调用的教训)分工: RetryMW Emitter 逐次记录每次尝试;TelemetryMW
(最外层)只记尝试层看不见的事件缓存命中scope 级失败取消;
RequestRejected/ResultInvalid 已被尝试层记录,最外层放行不重复记
(最外层)只记尝试层看不见的缓存命中;**终态失败行**由三个 client 的公开
边界经 `emit_terminal_once` 统一写出(1.3.5)两处同时写就会双计
一行遥测属于三类事件之一(`event_kind`): `attempt`(一次尝试)`cache_hit`
(未产生网关调用)`terminal_failure`(一次**逻辑调用**的失败终态)后两者与
前者**不是重复事实**,故统计失败调用次数只能取 `terminal_failure`,
不得按 `error IS NOT NULL` 跨两类直接计数(设计 §6/§8)
"""
from __future__ import annotations
import asyncio
import inspect
import json
import time
import uuid
@@ -17,12 +23,10 @@ from typing import TYPE_CHECKING
from loguru import logger
from polygateway.errors import (
GatewayUnavailableError,
GovernanceBackendError,
SourceNotConfiguredError,
)
from polygateway.errors import PolyGatewayError, ResultInvalidError
from polygateway.middleware.cache import digest_messages
from polygateway.middleware.structured import MAX_ERROR_CHARS, format_bounded_errors
from polygateway.ports import TelemetryRecorder
from polygateway.thinking import effective_effort
from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling
@@ -30,9 +34,17 @@ if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from typing import Any
from polygateway.ports import CallNext, TelemetryRecorder
from polygateway.ports import CallNext
from polygateway.pricing import PricingTable
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
from polygateway.types import (
CallOperation,
CallStats,
ChatRequest,
EventKind,
LLMResponse,
SourceConfig,
_CallContext,
)
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
@@ -225,26 +237,159 @@ class _AttemptUsage:
)
@dataclass(frozen=True)
class _ErrorFields:
"""一行遥测的错误列;未知一律 `None`。
存在的理由是把"三种入参形态 × 两类行"的定型规则收敛到**一处**:
改前调用方先 `str(exc)` 压平,状态码底层异常类型与网关正文全部丢失
"""
error: str | None = None
error_type: str | None = None
cause_type: str | None = None
http_status_code: int | None = None
error_body: str | None = None
def _structured_detail(exc: ResultInvalidError) -> str:
"""结构化阶梯耗尽的**有界**说明(设计 §5 C2)。
`ResultInvalidError("结构化输出阶梯耗尽")` message 不含校验与修复错误,而该
失败发生在 StructuredMW 之上RetryMW 侧的 attempt 行全是**成功行**,终态行是
唯一记录故把说明并入现有 `error`
**不含 `raw_text`**: 它是模型正文,attempt 行的 `response` 列已按 `text_cap` 记过
一份;再存一份等于绕过既有的正文预算条数与限长复用 `structured.py` 的同一
套常量(重问反馈与本说明同一口径),数值只有一份
"""
parts: list[str] = []
if exc.repair_error:
parts.append(f"repair={exc.repair_error[:MAX_ERROR_CHARS]}")
if exc.validation_errors:
parts.append(f"validation={format_bounded_errors(exc.validation_errors)}")
return " | ".join(parts)
def _error_fields(
error: PolyGatewayError | str | None,
*,
event_kind: EventKind,
class_prefixed: bool,
) -> _ErrorFields:
"""三种入参形态的唯一定型点(设计 §5/§6)。
- `None` None(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了")
- `str`(取消路径的 `"cancelled"`) 原样落 `error`,**不解析字符串猜诊断**
- 领域异常 只读它既有的属性,不遍历任意对象不猜正文
**终态行的三列恒为 NULL(C1 红线)**: `GatewayUnavailableError` 家族从不携带
状态码与响应体,NULL 正是它自身的真实状态把最后一次 attempt 的状态码与正文
搬上来,就是拿最后一个源冒充整池归因逐源现场由同一 `logical_call_id`
attempt 行给出
"""
if error is None:
return _ErrorFields()
if isinstance(error, str):
return _ErrorFields(error=error)
name = type(error).__name__
# 空 `str()` 退回类名(httpx 的 Connect/Read/Write/PoolTimeout 文案就是空的);
# `class_prefixed` 是 OCR 的既有口径(按类名归组的 metric),故逐字保留它的拼法
text = f"{name}: {error}" if class_prefixed else (str(error) or name)
if event_kind == "terminal_failure":
if isinstance(error, ResultInvalidError):
detail = _structured_detail(error)
if detail:
text = f"{text} | {detail}"
return _ErrorFields(error=text, error_type=name)
cause = error.__cause__
return _ErrorFields(
error=text,
error_type=name,
cause_type=type(cause).__name__ if cause is not None else None,
# getattr 而非直读: 本函数在 `_record` 的降级 try **之外**求值,
# 一个非领域异常误传进来不得把一次真实失败换成 AttributeError
http_status_code=getattr(error, "status_code", None),
# 空串归 None: 既有 `body_text` 的缺省就是空串,而本列的语义是"未知"
error_body=getattr(error, "body_text", "") or None,
)
def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
"""装配期一次 `signature.bind` 形状校验: 不执行写入,只证明该形状能被接受。
`_record` `except Exception` 会把旧 recorder `TypeError` 吞成 warning,
后果是自定义 recorder 在下游升级后**100% 丢遥测且调用照常成功**正是
"遥测必录"要防的形态,而文档级迁移清单挡不住它故在装配期当场报错
(不是 warning: 降级方向的铁律管的是**运行期写失败**,不是装配错误)
参数名从 `TelemetryRecorder.record_llm_call` 的协议签名**派生**(不手抄第四份
字段清单),绑定用哨兵 `None`,不读任何真实请求数据;`**kwargs`
(VAR_KEYWORD)自动通过不可 inspect(C 实现等)同样按配置错误报错宁可
装配不起来,不进入"运行期静默丢行"
边界诚实声明: 它只证明该形状能被接受,**不能证明函数体真的落这些列**
Raises:
ValueError: 签名不符不可 inspect,或协议本身不可 inspect
"""
try:
# 模块全局查找而非常量快照: 协议改了,闸就跟着改(测试可据此机械验证)
protocol = inspect.signature(TelemetryRecorder.record_llm_call).parameters
except (TypeError, ValueError) as exc: # pragma: no cover - 协议一向可 inspect
raise ValueError(f"TelemetryRecorder.record_llm_call 签名不可读取: {exc}") from exc
sentinels = {name: None for name in protocol if name != "self"}
label = type(recorder).__name__
method = getattr(recorder, "record_llm_call", None)
if method is None:
# 连方法都没有: 比旧签名更明确的配置错误。不让它以裸 AttributeError
# 逆流而上——那不属错误四分类,且现场离"注错了东西"这个真因很远
raise ValueError(
f"注入的遥测 recorder {label} 没有 record_llm_call 方法,不满足 TelemetryRecorder 端口"
)
try:
signature = inspect.signature(method)
except (TypeError, ValueError) as exc:
raise ValueError(
f"遥测 recorder {label} 的 record_llm_call 不可 inspect(如 C 实现),"
"无法在装配期确认它接受当前字段形状;请换成 Python 实现或包一层"
) from exc
try:
signature.bind(**sentinels)
except TypeError as exc:
raise ValueError(
f"遥测 recorder {label} 的 record_llm_call 签名与 TelemetryRecorder 不符"
f"(当前 {len(sentinels)} 个字段): {exc}"
"这一条故意在装配期报错——放行的后果是每行遥测都被降级成 warning 后丢弃"
) from exc
class TelemetryEmitter:
"""从请求与结果组装 26 字段并写入 recorder;一切写失败降级 warning。"""
"""从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(
self,
recorder: TelemetryRecorder,
*,
scope: str,
pricing: PricingTable | None = None,
text_cap: int | None,
) -> None:
"""`text_cap` 无默认值是有意的: 是关键行为参数,漏传即静默改变落库正文
"""`text_cap` 与 `scope` 无默认值是有意的: 两者都是关键行为参数。
`text_cap` 漏传即静默改变落库正文;`scope` 漏传则三类行都失去池名
终态失败可能根本没选出源, scope 始终已知,不拿 `source_name` 顶替
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传
同理,值域校验也放在这一处: 三个 Client `text_cap` 全部汇流到这里,
同理,值域校验与**装配闸**都放在这一处: 三个 Client 全部汇流到这里,
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
条公共装配路`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)
条公共装配路(`text_cap=0` 会让每条正文只剩一个省略标记;P5 不得静默)
"""
if text_cap is not None and text_cap <= 0:
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
_assert_recorder_shape(recorder)
self._recorder = recorder
self._scope = scope
self._pricing = pricing
self._text_cap = text_cap
@@ -256,8 +401,10 @@ class TelemetryEmitter:
call_id: str,
latency_ms: int,
response: LLMResponse | None,
error: str | None,
error: PolyGatewayError | str | None,
reasoning_applies: bool,
operation: CallOperation,
class_prefixed_error: bool = False,
) -> None:
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
@@ -266,7 +413,11 @@ class TelemetryEmitter:
共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` embedding
会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的
**不设默认值**: `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传
当场 TypeError,好过被静默当成"没表态"
当场 TypeError,好过被静默当成"没表态"`operation` 同理且另有一层:
它只能由调用点给定,**绝不读 `exc.operation`**(后者是 HTTP 子操作)
`error` **领域异常对象**而非预先 `str()` 压平的文本: 状态码/底层异常类型/
网关正文在此提取成四列(设计 §5)取消路径仍传既有字符串 `"cancelled"`
"""
usage = _AttemptUsage.of(response)
await self._record(
@@ -284,7 +435,7 @@ class TelemetryEmitter:
ttft_ms=usage.ttft_ms,
max_inter_token_ms=usage.max_inter_token_ms,
cache_hit=False,
error=error,
errors=_error_fields(error, event_kind="attempt", class_prefixed=class_prefixed_error),
cached_prompt_tokens=usage.cached_prompt_tokens,
model_reported=usage.model_reported,
reasoning_tokens=usage.reasoning_tokens,
@@ -296,10 +447,19 @@ class TelemetryEmitter:
reasoning_effort=_attempt_effort(
request=request, source=source, response=response, applies=reasoning_applies
),
operation=operation,
event_kind="attempt",
attempts=None,
total_latency_ms=None,
)
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
"""缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。"""
async def emit_cache_hit(
self, *, request: ChatRequest, response: LLMResponse, operation: CallOperation
) -> None:
"""缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。
逻辑计数两列恒 NULL: 本行描述的是"一次命中",不是一次逻辑调用的终态
"""
await self._record(
request=request,
call_id=response.call_id,
@@ -315,7 +475,7 @@ class TelemetryEmitter:
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=True,
error=None,
errors=_ErrorFields(),
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
cached_prompt_tokens=response.cached_prompt_tokens,
@@ -334,12 +494,28 @@ class TelemetryEmitter:
# 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射
# 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的)
reasoning_effort=_normalize_effort(request.reasoning_effort),
operation=operation,
event_kind="cache_hit",
attempts=None,
total_latency_ms=None,
)
async def emit_terminal_failure(
self, *, request: ChatRequest, call_id: str, latency_ms: int, error: str
self,
*,
request: ChatRequest,
call_id: str,
error: PolyGatewayError | str,
operation: CallOperation,
stats: CallStats,
class_prefixed_error: bool = False,
) -> None:
"""scope 级失败/取消记录: 无具体源,溯源字段置空标记。"""
"""一次**逻辑调用**的失败终态: 无具体源,溯源字段置空标记。
`latency_ms` `total_latency_ms` 同取**同一份冻结快照**,避免双时钟微差;
故本方法不再收 `latency_ms`token cost 一律不从 attempt 行复制
(费用聚合仍只由 attempt / cache_hit 行决定,口径不变)
"""
await self._record(
request=request,
call_id=call_id,
@@ -351,11 +527,13 @@ class TelemetryEmitter:
prompt_tokens=0,
completion_tokens=0,
usage_source="unavailable",
latency_ms=latency_ms,
latency_ms=stats.total_latency_ms,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
error=error,
errors=_error_fields(
error, event_kind="terminal_failure", class_prefixed=class_prefixed_error
),
cached_prompt_tokens=None,
model_reported=None,
reasoning_tokens=None,
@@ -368,6 +546,10 @@ class TelemetryEmitter:
meta=request.meta,
# 可能根本没选出源,故与 sampling 同样只取请求档
reasoning_effort=_normalize_effort(request.reasoning_effort),
operation=operation,
event_kind="terminal_failure",
attempts=stats.attempts,
total_latency_ms=stats.total_latency_ms,
)
async def _record(
@@ -387,7 +569,9 @@ class TelemetryEmitter:
ttft_ms: float | None,
max_inter_token_ms: float | None,
cache_hit: bool,
error: str | None,
# 1.3.5: 错误四列已由 `_error_fields` 定型(三种入参形态 × 两类行的唯一规则所有者),
# 本方法只搬运——拆成五个平铺参数就是把"一处定型"换回"三处各自拼"
errors: _ErrorFields,
cached_prompt_tokens: int | None,
model_reported: str | None,
sampling: str | None,
@@ -403,6 +587,11 @@ class TelemetryEmitter:
# 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把
# "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制
reasoning_effort: str | None,
# —— 1.3.5: 行形态与逻辑调用快照 ——
operation: CallOperation,
event_kind: EventKind,
attempts: int | None,
total_latency_ms: int | None,
) -> None:
try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -413,7 +602,7 @@ class TelemetryEmitter:
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
cost = None
elif error is None and model and self._pricing is not None:
elif errors.error is None and model and self._pricing is not None:
cost = self._pricing.cost(
model, prompt_tokens, completion_tokens, cached_prompt_tokens
)
@@ -425,6 +614,7 @@ class TelemetryEmitter:
_cap_messages(digest_messages(request.messages), self._text_cap),
ensure_ascii=False,
)
context = request.call_context
await self._recorder.record_llm_call(
call_id=call_id,
parent_call_id=request.parent_call_id,
@@ -442,7 +632,7 @@ class TelemetryEmitter:
ttft_ms=ttft_ms,
max_inter_token_ms=max_inter_token_ms,
cache_hit=cache_hit,
error=error,
error=errors.error,
cost=cost,
cached_prompt_tokens=cached_prompt_tokens,
model_reported=model_reported,
@@ -457,6 +647,19 @@ class TelemetryEmitter:
# Postgres 那一路悄悄少一列数据
thinking_observation=_normalize_observation(thinking_observation),
reasoning_effort=reasoning_effort,
# —— 1.3.5 十列 ——
scope=self._scope,
# 调用点给定的公开方法四值,**绝不读 `exc.operation`**(设计 §5 I1/I2)
operation=operation,
# 上下文缺席(库内现场构造的 ChatRequest)→ NULL,**不造 ID**(I5)
logical_call_id=None if context is None else context.logical_call_id,
event_kind=event_kind,
http_status_code=errors.http_status_code,
error_type=errors.error_type,
cause_type=errors.cause_type,
error_body=errors.error_body,
attempts=attempts,
total_latency_ms=total_latency_ms,
)
except asyncio.CancelledError:
raise
@@ -464,36 +667,69 @@ class TelemetryEmitter:
logger.warning("遥测记录失败(降级不冒泡): {}", exc)
async def emit_terminal_once(
emitter: TelemetryEmitter | None,
*,
request: ChatRequest,
context: _CallContext,
error: PolyGatewayError | str,
operation: CallOperation,
class_prefixed_error: bool = False,
) -> None:
"""三个 client 共用的**终态唯一出口**: 去重 + 同步冻结快照 + best effort 写入。
去重由 `claim_terminal()` 承担(每逻辑调用至多一条终态行);`emitter is None`
或已写过 直接返回
**降级范围包含诊断字段的提取与构建**,不只是写入那一步: `_record` 内的
`except Exception` 只兜住落库, `_error_fields` / `canonical_sampling_json`
在它**之外**求值下游经公共端口(自实现 `StructuredOutputStrategy`
transport)构造出的 `ResultInvalidError(validation_errors=( str,))` 会让提取期
`TypeError` 顶替调用方本该收到的领域异常,错误四分类被击穿且终态行照样丢
故在此整段兜底, RetryMW attempt 出口(`retry.py::_emit`)同款写法
终态行按 best effort: 兜底命中时本次逻辑调用**没有**终态行(`claim_terminal()`
已消耗,不补写不重试重写一遍只会把同一个提取期异常再抛一次)
**`CancelledError` 原样传播**(取消优先, shield不开后台任务): 这一次
`await` 本身就是新的取消点,外部取消落在它上时调用方会看到 `CancelledError`
而非领域错误 TelemetryMW 的历史行为同款,已经人类批准(设计 §6/§10)
快照冻结是**同步**动作,故终态行不含它自身的写入耗时
"""
if emitter is None or not context.claim_terminal():
return
try:
stats = context.snapshot()
await emitter.emit_terminal_failure(
request=request,
call_id=str(uuid.uuid4()),
error=error,
operation=operation,
stats=stats,
class_prefixed_error=class_prefixed_error,
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("终态遥测记录失败(降级不冒泡): {}", exc)
class TelemetryMW:
"""洋葱最外层: 观测尝试层看不见的路径,任何路径都留痕(遥测必录)。"""
"""洋葱最外层: 观测尝试层看不见的**缓存命中**。
1.3.5 起不再在此写终态失败行: 终态由 `GatewayClient.chat` 的公开边界经
`emit_terminal_once` 统一写出两处同时写会让同一次失败出两条终态行,
而下游正是按 `event_kind = 'terminal_failure'` 计失败调用次数的
"""
def __init__(
self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic
) -> None:
self._emitter = emitter
self._now = now
# `now` 自 1.3.5 起本类不再读取(终态行迁到公开边界后无耗时可测),但形参保留:
# 删它会平白打断 `TelemetryMW(emitter, now=...)` 这一既有装配写法
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
started = self._now()
try:
response = await call_next(request)
except (GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError) as exc:
await self._emitter.emit_terminal_failure(
request=request,
call_id=str(uuid.uuid4()),
latency_ms=int((self._now() - started) * 1000),
error=str(exc),
)
raise
except asyncio.CancelledError:
# 尽力而为: 取消也留痕(§5.1 约定④);随后立即重抛
await self._emitter.emit_terminal_failure(
request=request,
call_id=str(uuid.uuid4()),
latency_ms=int((self._now() - started) * 1000),
error="cancelled",
)
raise
if response.cache_hit:
await self._emitter.emit_cache_hit(request=request, response=response)
await self._emitter.emit_cache_hit(request=request, response=response, operation="chat")
return response
+153 -24
View File
@@ -37,15 +37,17 @@ from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
from polygateway.ports import OutcomeAwareSelector
from polygateway.types import (
CallStats,
ChatRequest,
LLMResponse,
OcrLayoutResult,
OcrTextResult,
TelemetryStatus,
Usage,
_CallContext,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
@@ -65,6 +67,7 @@ if TYPE_CHECKING:
)
from polygateway.types import (
BackpressurePolicy,
CallOperation,
OcrLayoutTransportResult,
OcrTextTransportResult,
RetryPolicy,
@@ -126,7 +129,9 @@ class OcrClient:
self._breaker = BreakerGate(breaker, scope=self._scope)
self._transport = transport
self._retry = retry
self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None
self._emitter = (
TelemetryEmitter(telemetry, scope=self._scope, text_cap=text_cap) if telemetry else None
)
self._telemetry = telemetry
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
# 引用才关得到自建的 redis 客户端(设计 §3.4)
@@ -176,8 +181,14 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
)
outcome = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
outcome, call_stats = await self._call(
"text",
"recognize_text",
image,
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
)
result = outcome.result
return OcrTextResult(
@@ -187,6 +198,7 @@ class OcrClient:
latency_ms=outcome.latency_ms,
call_id=outcome.call_id,
raw=result.raw,
call_stats=call_stats,
)
async def parse_layout(
@@ -206,8 +218,14 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
)
outcome = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
outcome, call_stats = await self._call(
"layout",
"parse_layout",
image,
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
)
result = outcome.result
return OcrLayoutResult(
@@ -218,6 +236,7 @@ class OcrClient:
latency_ms=outcome.latency_ms,
call_id=outcome.call_id,
raw=result.raw,
call_stats=call_stats,
)
async def check_health(self) -> dict[str, bool]:
@@ -237,16 +256,60 @@ class OcrClient:
async def _call(
self,
kind: _OcrKind,
operation: CallOperation,
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome:
) -> tuple[_AttemptOutcome, CallStats]:
if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
if not image:
raise ValueError("image 不能为空")
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
context = _CallContext(now=self._now)
try:
return await self._run(
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context
)
except PolyGatewayError as exc:
await self._emit_terminal(
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context, exc
)
raise
except asyncio.CancelledError:
# 三条链路同一口径尽力写一条(允许 0 条);取消优先,不 shield
await self._emit_terminal(
kind,
operation,
image,
session_id,
parent_call_id,
tenant_id,
meta,
context,
"cancelled",
)
raise
async def _run(
self,
kind: _OcrKind,
operation: CallOperation,
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> tuple[_AttemptOutcome, CallStats]:
"""选源与重试循环。
无源的 raise 必须在本方法内(而非循环之前的调用方): 它得被 `_call`
`try` 包住,否则无源终态行根本写不出来(设计 §3.5)
"""
if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
fails = 0
@@ -260,10 +323,19 @@ class OcrClient:
continue
async with clock.attempting():
outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
kind,
operation,
image,
*picked,
reasons,
session_id,
parent_call_id,
tenant_id,
meta,
context,
)
if isinstance(outcome, _AttemptOutcome):
return outcome
return outcome, context.snapshot()
fails += 1
if fails >= self._retry.max_attempts:
raise AllSourcesExhausted(
@@ -278,6 +350,7 @@ class OcrClient:
async def _attempt(
self,
kind: _OcrKind,
operation: CallOperation,
image: bytes,
source: SourceConfig,
permit: Permit,
@@ -287,11 +360,14 @@ class OcrClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4())
started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
context.register_attempt()
try:
result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry))
@@ -300,6 +376,7 @@ class OcrClient:
latency_ms = int((self._now() - started) * 1000)
await self._emit(
kind,
operation,
image,
source,
call_id,
@@ -308,6 +385,7 @@ class OcrClient:
parent_call_id,
tenant_id,
meta,
context,
result,
)
return _AttemptOutcome(result, source, call_id, latency_ms)
@@ -315,6 +393,7 @@ class OcrClient:
await self._gate_on_terminal(exc, entry)
await self._emit(
kind,
operation,
image,
source,
call_id,
@@ -323,6 +402,7 @@ class OcrClient:
parent_call_id,
tenant_id,
meta,
context,
error=exc,
)
raise
@@ -331,6 +411,7 @@ class OcrClient:
await self._record_quietly(self._breaker.release_probe(entry))
await self._emit(
kind,
operation,
image,
source,
call_id,
@@ -339,6 +420,7 @@ class OcrClient:
parent_call_id,
tenant_id,
meta,
context,
error="cancelled",
)
raise
@@ -350,6 +432,7 @@ class OcrClient:
self._feed_outcome(source.name, ok=False)
await self._emit(
kind,
operation,
image,
source,
call_id,
@@ -358,6 +441,7 @@ class OcrClient:
parent_call_id,
tenant_id,
meta,
context,
error=exc,
)
return _FailedAttempt(exc, immediate=dead)
@@ -399,9 +483,60 @@ class OcrClient:
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc)
def _request_for(
self,
kind: _OcrKind,
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> ChatRequest:
"""OCR 行的现场 ChatRequest: 占位摘要,**图像 bytes 永不入库**。
尝试行与终态行共用同一个构造点: 占位字面量复制成两份就会漂移
调用方维度与上下文必须显式填回(OCR 不走 chat 洋葱),否则 OCR 行的
维度恒为空`logical_call_id` 恒为 NULL
"""
return ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
call_context=context,
)
async def _emit_terminal(
self,
kind: _OcrKind,
operation: CallOperation,
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
error: PolyGatewayError | str,
) -> None:
"""终态行: 沿用 `<ocr:{kind} image_bytes=…>` 占位,错误文本保留类名前缀。"""
await emit_terminal_once(
self._emitter,
request=self._request_for(
kind, image, session_id, parent_call_id, tenant_id, meta, context
),
context=context,
error=error,
operation=operation,
# metric ocr-call-success 的注册口径是按类名归组,终态行同款保留
class_prefixed_error=True,
)
async def _emit(
self,
kind: _OcrKind,
operation: CallOperation,
image: bytes,
source: SourceConfig,
call_id: str,
@@ -410,20 +545,15 @@ class OcrClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
error: object | None = None,
error: PolyGatewayError | str | None = None,
) -> None:
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
if self._emitter is None:
return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
request = ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
request = self._request_for(
kind, image, session_id, parent_call_id, tenant_id, meta, context
)
latency_ms = int((self._now() - started) * 1000)
response = None
@@ -443,20 +573,19 @@ class OcrClient:
source_name=source.name,
usage_source="measured",
)
# 错误带异常类名前缀(metric ocr-call-success 注册口径: 按类名归组)
if error is None or isinstance(error, str):
error_text = error
else:
error_text = f"{type(error).__name__}: {error}"
# 错误文本的类名前缀现由出口的显式策略参数承担(设计 §6 I7):
# 三处各拼一遍才是下一次漂移的种子,而取消行传的是字符串,不受前缀影响
await self._emitter.emit_attempt(
request=request,
source=source,
call_id=call_id,
latency_ms=latency_ms,
response=response,
error=error_text,
error=error,
# OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding)
reasoning_applies=False,
operation=operation,
class_prefixed_error=True,
)
@staticmethod
+20 -1
View File
@@ -271,7 +271,7 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable
class TelemetryRecorder(Protocol):
"""遥测后端;26 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20),唯一调用点是 TelemetryEmitter。
"""遥测后端;36 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning
@@ -286,6 +286,15 @@ class TelemetryRecorder(Protocol):
recorder 只负责落库,不做任何语义判断, `sampling` 列由
`canonical_sampling_json()` emitter 侧定型是同一先例
1.3.5 新增十列同理已在 emitter 侧定型: `operation` **公开方法**四值之一
( `PolyGatewayError.operation` 这个 HTTP 子操作是两个语义);`event_kind` 区分
attempt / cache_hit / terminal_failure 三类行;`attempts` `total_latency_ms`
只在终态行非空;`error_body` 沿用 transport `summarize_body` 的上限,
**不进 `PGW_TELEMETRY_TEXT_CAP` 的覆盖面**
**本签名是装配闸的唯一事实源**: `TelemetryEmitter.__init__` 按它派生参数名做
一次 `signature.bind` 形状校验(设计 §7),改本签名即改闸的判据
"""
async def record_llm_call(
@@ -317,4 +326,14 @@ class TelemetryRecorder(Protocol):
meta: str,
thinking_observation: str,
reasoning_effort: str | None,
scope: str,
operation: str,
logical_call_id: str | None,
event_kind: str,
http_status_code: int | None,
error_type: str | None,
cause_type: str | None,
error_body: str | None,
attempts: int | None,
total_latency_ms: int | None,
) -> None: ...
+4 -14
View File
@@ -29,9 +29,8 @@ class ThinkingWire:
``=None`` ``thinking_budget`` 调深度,不是档位)
============== ==========================================================
`on_base={}` `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于
开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加),
后者是"不知道怎么表达"
`on_base={}` `on_base=None` 不可混: 前者是协议无需额外开启字节
是否满足 AUTO 由模型能力清单决定后者是不知道怎么表达
**为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat /
openai-responses / anthropic-messages / google-generate-content 四种端点协议,
@@ -117,21 +116,12 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
# 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。
# enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线
# 194),reasoning_effort 才是真开关——本段形态据此成立。
# `on_base={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段
# 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。
# 之所以接受: 本次一度改成 `on_base={}`("开"不需要任何参数),该形态依赖
# "模型默认就推理"这个前提,而 T10 真实网关实测推翻了它——MiniMax-M3 不发任何
# 推理参数时 5/5 轮不推理(六个强度值 minimal..max 则全部生效且彼此等价)。
# 于是存量配 ENABLE_THINKING=true 的下游会从"真开推理"静默变成"不推理"。
# 取 medium 是为逐字恢复旧版的 thinking_on,与存量行为一致;M3 六档等价,
# 故选哪档对效果无差别。
# 正解是让 `auto` 受能力表约束(模型不支持"由模型自定"时报错并指路显式档位),
# 属公共行为变更,已记入 gitea issue #21 待下一版处理。
# 开启片段不代选强度;AUTO 可满足性由具体模型能力清单决定。
"minimax": ProviderProfile(
name="minimax",
thinking=ThinkingWire(
off={"reasoning_effort": "none"},
on_base={"reasoning_effort": "medium"},
on_base={},
effort_key="reasoning_effort",
),
strip_think_tags=False,
+58 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"
**`COLUMNS` INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)物理表列 = 26 INSERT 字段 +
`created_at` = 27;列数断言一律按物理列数写,两套口径混用是最易错处
`DEFAULT now()` / `datetime('now')`,库从不显式写它)物理表列 = 36 INSERT 字段 +
`created_at` = 37;列数断言一律按物理列数写,两套口径混用是最易错处
本模块只依赖标准库: `telemetry/` `backends/``transports/``structured/` 同层且
互不依赖(import-linter 契约执法)
@@ -52,7 +52,17 @@ CREATE TABLE IF NOT EXISTS llm_calls (
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT,
reasoning_effort TEXT
reasoning_effort TEXT,
scope TEXT,
operation TEXT,
logical_call_id TEXT,
event_kind TEXT,
http_status_code INTEGER,
error_type TEXT,
cause_type TEXT,
error_body TEXT,
attempts INTEGER,
total_latency_ms INTEGER
);
"""
@@ -84,7 +94,17 @@ CREATE TABLE IF NOT EXISTS llm_calls (
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT,
reasoning_effort TEXT
reasoning_effort TEXT,
scope TEXT,
operation TEXT,
logical_call_id TEXT,
event_kind TEXT,
http_status_code INTEGER,
error_type TEXT,
cause_type TEXT,
error_body TEXT,
attempts INTEGER,
total_latency_ms INTEGER
);
"""
@@ -105,6 +125,18 @@ SQLITE_BACKFILL = (
# 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none'
# (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事
("reasoning_effort", "TEXT"),
# 1.3.5 十列: 全部可空且无默认值——旧行的 NULL 表达的是"补列之前根本没记过
# 这件事",与任何哨兵值都不是一回事,故不回填(设计 §5)
("scope", "TEXT"),
("operation", "TEXT"),
("logical_call_id", "TEXT"),
("event_kind", "TEXT"),
("http_status_code", "INTEGER"),
("error_type", "TEXT"),
("cause_type", "TEXT"),
("error_body", "TEXT"),
("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"),
)
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -120,6 +152,17 @@ _PG_BACKFILL_DECLS = (
# 可空,理由同 SQLITE_BACKFILL 同名项
("thinking_observation", "TEXT"),
("reasoning_effort", "TEXT"),
# 1.3.5 十列,列序与 SQLITE_BACKFILL 逐项对齐(两条路径的物理列序不许分叉)
("scope", "TEXT"),
("operation", "TEXT"),
("logical_call_id", "TEXT"),
("event_kind", "TEXT"),
("http_status_code", "INTEGER"),
("error_type", "TEXT"),
("cause_type", "TEXT"),
("error_body", "TEXT"),
("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"),
)
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -158,6 +201,17 @@ COLUMNS = (
"meta",
"thinking_observation",
"reasoning_effort",
# —— 1.3.5 逻辑调用统计与结构化失败诊断(issue #19/#23)——
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
)
_COLUMN_SET = frozenset(COLUMNS)
+1 -1
View File
@@ -143,7 +143,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 26 字段冻结签名(ports.TelemetryRecorder)。
"""写一行遥测;字段集合即 36 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪), `self._insert`
占位符同序两者必须一起改,分开改就是把值写进错位的列
+80 -12
View File
@@ -441,6 +441,62 @@ def effective_effort(
return Effort.AUTO if enable_thinking else Effort.NONE
_RAW_THINKING_ROOTS = frozenset(
{
"reasoning_effort",
"enable_thinking",
"thinking",
"thinking_budget",
"reasoning",
"thinkingConfig",
}
)
def _has_output_effort(raw: Mapping[str, Any]) -> bool:
"""标准嵌套强度只检查明确的路径,不猜私有方言。"""
output = raw.get("output_config")
return isinstance(output, Mapping) and "effort" in output
def validate_thinking_wire(wire: ThinkingWire, *, model: str) -> None:
"""拒绝开启片段代选强度,未知形态仍交由请求方向检查。"""
base = wire.on_base
if base is not None and (
"reasoning_effort" in base
or (wire.effort_key is not None and wire.effort_key in base)
or _has_output_effort(base)
):
raise ThinkingUnsupportedError(
f"模型 {model!r} 的 on_base 不得包含强度档位;请移除强度并显式传 reasoning_effort"
)
def validate_thinking_raw(
raw: Mapping[str, Any],
*,
effort: Effort | None,
wire: ThinkingWire | None,
origin: str,
) -> None:
"""受管推理只有一个来源;同值或被遮蔽的 raw 控制也拒绝。"""
if effort is None:
return
roots = set(_RAW_THINKING_ROOTS)
if wire is not None:
for fragment in (wire.on_base, wire.off):
if fragment is not None:
roots.update(fragment)
if wire.effort_key is not None:
roots.add(wire.effort_key)
if roots.intersection(raw) or _has_output_effort(raw):
# 不打印 raw 或自定义键名,防配置中夹带秘密。
raise ThinkingUnsupportedError(
f"{origin} 与受管 reasoning_effort={effort.value!r} 冲突;"
"请删除 raw 推理控制,或移除源级/请求级推理表态后仅用 raw"
)
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
@@ -471,11 +527,8 @@ def resolve_thinking(
信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是
issue #20 的成因。
**`auto` 不受档位清单约束**: 它表达的是"开启,但不指定强度",在请求体里就是
"不写 `effort_key`",而不是写进 `effort_key` 的某个取值, Phase 5 放行它
反过来判会让存量的 `ENABLE_THINKING=true`(T5 起等价于 `auto`) deepseek-v4
glm-5.3 这类清单里没有 `auto` 的模型上当场报错,而设计 §12 明确承诺存量
配置继续可跑那里唯一允许新报错的是"关闭一个官方不可关的模型"
**已登记的 AUTO 同样受清单约束**: 开启形态不证明模型支持不指定强度
AUTO 不在强弱轴上不允许 nearest 静默代选付费档位未知模型仍尽力并告警
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,
`capability` None(未登记)时无从从别处取得模型名
@@ -493,6 +546,7 @@ def resolve_thinking(
而是**静默判否**: Phase 2 按开启方向取字段Phase 4 整条被绕过,最后在拼错误
文案时才以 `AttributeError` 现形(一个未文档化也不属四分类的异常)
"""
validate_thinking_wire(profile.thinking, model=model)
# Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举
if effort is not None:
effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})")
@@ -517,7 +571,7 @@ def resolve_thinking(
# 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由)
if effort is Effort.NONE and not capability.can_disable:
raise ThinkingUnsupportedError(_cannot_disable(model, capability))
# Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring)
# Phase 5: 已登记选择必须可满足;AUTO 不允许按强度距离映射
applied = _settle_tier(effort, capability, model=model, fallback=fallback)
return ThinkingResolution(_inject(profile, applied, model=model), applied)
@@ -544,12 +598,15 @@ def _settle_tier(
) -> Effort:
"""Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。
`auto` 直接放行: 它不是写进 `effort_key` 的取值,而是"不写 effort_key"
(理由见 `resolve_thinking` docstring)
AUTO 与强度档统一检查成员但不参与最近强度映射
"""
if effort is Effort.AUTO or effort in capability.supported_efforts:
if effort in capability.supported_efforts:
return effort
mapped = _nearest_effort(effort, capability) if fallback == "nearest" else None
mapped = (
_nearest_effort(effort, capability)
if fallback == "nearest" and effort is not Effort.AUTO
else None
)
if mapped is None:
raise ThinkingUnsupportedError(
_tier_unsupported(model, effort, capability, fallback=fallback)
@@ -634,7 +691,18 @@ def _tier_unsupported(
else f"该模型只有开关、没有强度档位,可用: {listed}"
)
# 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话
hint = "" if fallback == "nearest" else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest"
hint = (
""
if fallback == "nearest" or effort is Effort.AUTO
else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest"
)
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}"
@@ -676,7 +744,7 @@ def _warn_unregistered(
) -> None:
logger.warning(
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});"
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
"不保证开启、关闭或强度生效。实测后请用 register_capability 登记",
model,
profile.name,
dict(payload),
+31 -10
View File
@@ -34,6 +34,7 @@ from polygateway.thinking import (
observe_thinking,
reconcile_thinking,
resolve_thinking,
validate_thinking_raw,
)
from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.types import (
@@ -153,18 +154,28 @@ def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
def _status_to_error(
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
source: SourceConfig,
status: int,
body_text: str,
headers: Mapping[str, str],
*,
operation: str,
) -> Exception:
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
摘要只算一次,message `body_text` 共用同一份串: 两份不同长度会让"遥测里
看到的""下游 catch 到的"对不上,排查时反而多一层困惑。
`operation` **HTTP 子操作**词表(`chat` / `embedding` / `ocr_text` / ...),
调用点显式给定它曾被硬编码成 `"chat"`, `embed()` 的非 200 分支也走它
于是现存所有 embedding HTTP 失败的 `exc.operation` 都是错的(1.3.5 设计 §5 I1)
注意它与遥测新列 `operation`(公开方法四值)**两个语义**,不做自动转换
"""
summary = summarize_body(body_text)
ctx: dict[str, Any] = {
"source_name": source.name,
"status_code": status,
"operation": "chat",
"operation": operation,
"body_text": summary,
}
if status == 429:
@@ -371,20 +382,23 @@ class OpenAICompatTransport:
self._warned_models.add(source.model)
# 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。
# 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错"
resolution = resolve_thinking(
profile,
capability,
effective_effort(
effort = effective_effort(
request_effort=reasoning_effort,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
),
)
resolution = resolve_thinking(
profile,
capability,
effort,
model=source.model,
# 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确
# 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出)
fallback=source.effort_fallback,
warn_unregistered=first_time,
)
for raw, origin in ((source.extra_body, "source extra_body"), (overlay, "request overlay")):
validate_thinking_raw(raw, effort=effort, wire=profile.thinking, origin=origin)
payload.update(resolution.payload)
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
# overlay(含结构化注入)在后覆盖之。两行不可调换
@@ -505,7 +519,10 @@ class OpenAICompatTransport:
except httpx.TransportError as exc:
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
if resp.status_code != 200:
raise _status_to_error(source, resp.status_code, resp.text, resp.headers)
# 历史误标修正: 本分支属 `embed()`,与上方 ctx 同为 `"embedding"`
raise _status_to_error(
source, resp.status_code, resp.text, resp.headers, operation="embedding"
)
return _parse_embedding_payload(resp, source, len(texts))
async def _complete_stream(
@@ -520,7 +537,9 @@ class OpenAICompatTransport:
async with client.stream("POST", url, json=payload) as resp:
if resp.status_code != 200:
body = (await resp.aread()).decode("utf-8", errors="replace")
raise _status_to_error(source, resp.status_code, body, resp.headers)
raise _status_to_error(
source, resp.status_code, body, resp.headers, operation="chat"
)
sink: dict[str, Any] = {}
guarded = stream_with_liveness_timeouts(
_iter_sse_deltas(resp.aiter_lines(), sink),
@@ -618,7 +637,9 @@ class OpenAICompatTransport:
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。"""
resp = await client.post(url, json=payload)
if resp.status_code != 200:
raise _status_to_error(source, resp.status_code, resp.text, resp.headers)
raise _status_to_error(
source, resp.status_code, resp.text, resp.headers, operation="chat"
)
try:
body = resp.json()
except json.JSONDecodeError as exc:
+107 -2
View File
@@ -8,11 +8,12 @@ import dataclasses
import json
import math
import re
from collections.abc import Mapping
import uuid
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from types import MappingProxyType
from typing import Any
from typing import Any, Literal
from loguru import logger
@@ -274,6 +275,91 @@ class ThinkingObservation(StrEnum):
UNKNOWN = "unknown"
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。
`PolyGatewayError.operation`(HTTP 子操作, `download_result`)**两个语义**,
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)"""
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
"""一行遥测描述的事件形态;旧行 NULL,不回填。
终态行与 attempt **不是重复事实**(前者描述逻辑终态,后者描述单次尝试),
故禁止按 `error IS NOT NULL` 跨两类直接计失败调用次数(设计 §6/§8)"""
EVENT_KINDS: tuple[EventKind, ...] = ("attempt", "cache_hit", "terminal_failure")
@dataclass(frozen=True)
class CallStats:
"""一次**公开调用**(而非单次尝试)的统计快照(设计 §3)。
四种响应各平铺三字段会立刻漂移,故收敛成单一对象并由包根导出
第三方合成响应的 `None` 表示**未知**,不得伪造 0
"""
logical_call_id: str
"""每次公开调用一个 UUID;重试、结构化重问、embedding 分批共享同一个。
不占用既有 `parent_call_id`(后者是调用方的业务关联,语义不变)"""
attempts: int
"""准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝。
**不是 HTTP 请求条数**: OCR layout POST + ZIP GET 在同一次 transport
调用内, 1 缓存命中与空输入是合法的零尝试"""
total_latency_ms: int
"""从输入校验通过到返回/异常传播前的单调时钟快照。
含缓存 IO退避等待准入等待重问分批与内联记账
"总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)"""
class _CallContext:
"""私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。
**每调用一个实例**的单任务对象: chat 重试结构化重问embedding 分批
都在同一任务内串行推进,故计数无需锁**严禁提升为 client 实例属性**
那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"
VT `evolve_llm = llm` 教训的同一形态)
"""
__slots__ = ("_attempts", "_now", "_started", "_terminal_claimed", "logical_call_id")
def __init__(self, *, now: Callable[[], float]) -> None:
self.logical_call_id = str(uuid.uuid4())
self._now = now
self._started = now()
self._attempts = 0
self._terminal_claimed = False
def register_attempt(self) -> None:
"""transport 调用**前**登记一次尝试(含免预算 429 与端口本地拒绝)。
登记点在调用前而非成功后: 否则失败与取消的尝试会从计数里消失,
而那正是诊断时最需要看见的那几次
"""
self._attempts += 1
def snapshot(self) -> CallStats:
"""同步冻结当前快照;**绝不 await**,可多次调用。"""
return CallStats(
logical_call_id=self.logical_call_id,
attempts=self._attempts,
total_latency_ms=int((self._now() - self._started) * 1000),
)
def claim_terminal(self) -> bool:
"""首次 `True`、其后 `False`: 保证每逻辑调用至多写一条终态行。"""
if self._terminal_claimed:
return False
self._terminal_claimed = True
return True
@dataclass(frozen=True)
class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -336,6 +422,9 @@ class LLMResponse:
`None` 不是"没推理": 库不表态时也不推定模型自己的默认档"没看见"不许说成
"发生了"( `thinking_observation` `UNKNOWN` 一脉)"""
call_stats: CallStats | None = None
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
@dataclass(frozen=True)
class ChatRequest:
@@ -382,6 +471,16 @@ class ChatRequest:
而档位要经能力表校验要进缓存 key要落遥测混进直通层等于放弃这三样,
正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。"""
# —— 库内部逻辑调用上下文(1.3.5;追加在末尾,不扰动既有字段的位置构造)——
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 `logical_call_id` 落 NULL。
`compare=False, repr=False` 不是洁癖: `compare` 会让两个内容相同的请求因
"不是同一次调用"而不相等, `repr` 则把库内部件泄进调用方的日志
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
重试/重问/分批才能共享同一个逻辑 ID 与计数"""
@dataclass(frozen=True)
class Usage:
@@ -722,6 +821,8 @@ class OcrTextResult:
latency_ms: int
call_id: str
raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True)
@@ -739,6 +840,8 @@ class OcrLayoutResult:
latency_ms: int
call_id: str
raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True)
@@ -783,3 +886,5 @@ class EmbeddingResponse:
call_id: str
source_name: str
cost: float | None = None
call_stats: CallStats | None = None
"""本次逻辑调用(含全部分批)的统计快照(1.3.5);`None` = 未知。"""
+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 sys
from pathlib import Path
from uuid import uuid4
import pytest
from dotenv import dotenv_values
from polygateway import GatewayClient
from polygateway import Effort, GatewayClient, GatewaySettings
from tests.e2e.conftest import (
LiveCapture,
captured_chat_round,
chat_expectations,
enforce_verdict,
observed_client,
source_controls,
)
from tests.live_evidence import write_live_round
_REPO = Path(__file__).resolve().parents[2]
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由是这些用例的成败取决于网关此刻快不快,而
# pre-commit 关卡跑全套件——网关一抖就挡住与之无关的提交,久了会把"测试红了
# 先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉。发版清单负责让它们真跑。
_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*"
),
pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
]
_OUT = Path("tests/outputs/134/live")
@pytest.fixture
async def client():
c = GatewayClient.from_env("LLM", env=_ENV)
yield c
await c.aclose()
async def _call_shape(matrix, **kwargs):
"""session/parent 总由逐轮 UUID 传入;保留 cache_salt 调用形态。"""
settings = GatewaySettings.from_env("LLM", env=_ENV)
messages = [{"role": "user", "content": "Reply with exactly: compatibility-ok"}]
capture = LiveCapture(
expectations=chat_expectations(
settings, messages=messages, stream=True, controls=source_controls(settings)
)
)
def validate(response):
assert response.content.strip() and response.call_id
async with observed_client(settings, capture) as client:
_, verdict = await captured_chat_round(
client,
capture,
run_id=uuid4().hex,
matrix_id=matrix,
round_index=1,
output_dir=_OUT,
messages=messages,
models={s.name: s.model for s in settings.sources},
providers={s.name: s.provider for s in settings.sources},
source_efforts={
s.name: s.reasoning_effort
if s.reasoning_effort is not None
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
if s.enable_thinking is not None
else None
for s in settings.sources
},
aliases={},
validate=validate,
**kwargs,
)
enforce_verdict(verdict)
class TestGovDocOnboarding:
"""GovDoc agent/loop.py:377 调用形态: session_id + parent_call_id。"""
"""历史 session_idparent_call_id 调用点契约"""
async def test_call_site_shape_runs_governed(self, client):
response = await client.chat(
[{"role": "user", "content": "Reply with exactly: govdoc-ok"}],
session_id="govdoc-e2e",
parent_call_id="step-1",
)
assert response.content.strip()
assert response.call_id # GovernedLLMClient 契约字段全在
async def test_call_site_shape_runs_governed(self):
await _call_shape("compat-parent")
async def test_structural_protocol_match(self, client):
async def test_structural_protocol_match(self):
"""外部 Protocol 缺包单列未覆盖;合成契约另在 unit 跑。"""
run_id = uuid4().hex
sys.path.insert(0, str(_REPO / "reference/GovDoc-SaaS/packages/docagent-core/src"))
try:
from docagent_core.protocols import LLMProvider
except ImportError:
pytest.skip("GovDoc protocols 依赖不可导入(结构断言已由单测兜底覆盖)")
write_live_round(
_OUT,
run_id=run_id,
matrix_id="external-protocol",
round_index=0,
safe_fields={
"status": "UNCOVERED",
"reason": "外部 Protocol 包缺失;未验证真实下游",
},
)
pytest.skip("外部 Protocol 包缺失,未覆盖")
finally:
sys.path.pop(0)
status = "FAIL"
client = None
try:
client = GatewayClient.from_env("LLM", env=_ENV)
assert isinstance(client, LLMProvider)
status = "PASS"
finally:
if client is not None:
await client.aclose()
write_live_round(
_OUT,
run_id=run_id,
matrix_id="external-protocol",
round_index=0,
safe_fields={"status": status, "reason": "外部 Protocol 结构契约,不是模型能力"},
)
class TestVideoTreeOnboarding:
"""VT loop.py:336 调用形态: session_id + cache_salt(跨 epoch 重采样)"""
"""历史 cache_salt 调用点契约,平铺键装配已移至 unit"""
async def test_call_site_shape_with_cache_salt(self, client):
response = await client.chat(
[{"role": "user", "content": "Reply with exactly: vt-ok"}],
session_id="vt-e2e",
cache_salt="epoch-1",
)
assert response.content.strip()
async def test_flat_legacy_keys_assemble(self):
"""VT 现有键名(LLM_TIMEOUT/LLM_MAX_RETRIES 等)零改名装配成功。"""
source_keys = {k: v for k, v in _ENV.items() if k.split("__")[0] == "LLM" and "__" in k}
flat_env = {
**source_keys,
# 与 .env 的 LLM__MINIMAX__1__TIMEOUT_S 同值。取 120(VT 旧值)会让本用例的
# 超时比生产配置还紧一半,在慢网关上必然间歇红——而本用例断言的是平铺
# 键名能否解析成 SourceConfig.timeout_s,超时取值本身不是被测对象
"LLM_TIMEOUT": "300",
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "2.0",
"LLM_RETRY_MAX_DELAY": "30.0",
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
"LLM_TTFT_TIMEOUT": "30",
"LLM_INTER_TOKEN_TIMEOUT": "15",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
}
client = GatewayClient.from_env("LLM", env=flat_env)
try:
resp = await client.chat([{"role": "user", "content": "Reply: flat-ok"}])
assert resp.content.strip()
finally:
await client.aclose()
async def test_call_site_shape_with_cache_salt(self):
await _call_shape("compat-salt", cache_salt="epoch-1")
+77 -68
View File
@@ -1,89 +1,98 @@
"""真实网关 /embeddings 端点探测(M2 设计 §11.6;人类默认口径: 实现时探测)。
.env LLM 源网关发一次真实 embeddings 请求: 支持则记录向量证据,
不支持(404/翻译为领域错误) skip 并把响应记录进 tests/outputs/
(降级证据) EMBED scope 配置时复用 LLM 源的 base_url/api_key
"""
from __future__ import annotations
"""真实 embedding 探测;404 仅证明请求型号不可用,不外推端点能力。"""
import dataclasses
import os
from datetime import datetime
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from dotenv import dotenv_values
from polygateway.errors import PolyGatewayError
from polygateway import GatewaySettings
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import SourceConfig
from tests.e2e.conftest import LiveCapture, ObservedTransport, enforce_verdict
from tests.live_evidence import (
LiveVerdict,
classify_live_failure,
messages_digest,
request_is_valid,
safe_attempts,
write_live_round,
)
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
"LLM__MINIMAX__1__BASE_URL" not in _ENV,
reason="缺真实网关配置(.env)",
),
pytest.mark.skipif("LLM__MINIMAX__1__BASE_URL" not in _ENV, reason="缺少矩阵必需配置,未覆盖"),
]
_OUT = Path("tests/outputs/embedding")
def _record(name: str, lines: list[str]) -> Path:
_OUT.mkdir(parents=True, exist_ok=True)
path = _OUT / f"{name}_{datetime.now():%Y%m%d_%H%M%S}.md"
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
async def test_probe_real_gateway_embeddings():
source = SourceConfig(
name="probe_1",
provider="minimax",
base_url=_ENV["LLM__MINIMAX__1__BASE_URL"],
api_key=_ENV["LLM__MINIMAX__1__API_KEY"],
model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1"),
timeout_s=30.0,
est_tokens=8,
"""沿已校验源的 timeout/trust_env,所有路径 finally 关闭。"""
settings = GatewaySettings.from_env("LLM", env=_ENV)
configured = next(s for s in settings.sources if s.name == "minimax_1")
source = dataclasses.replace(
configured, model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1")
)
transport = OpenAICompatTransport()
texts = ["polygateway embedding probe"]
url = httpx.URL(source.base_url)
capture = LiveCapture(
expectations={
source.name: {
"model": source.model,
"origin": str(url.copy_with(path="", query=None)).rstrip("/"),
"path": url.path.rstrip("/") + "/embeddings",
"input_shape": 1,
"control": {},
"messages_digest": messages_digest(texts),
}
}
)
real = OpenAICompatTransport(client_factory=capture.client_factory)
transport = ObservedTransport(real, capture)
run_id, parent, call_id = uuid4().hex, uuid4().hex, uuid4().hex
verdict = LiveVerdict("FAIL", "轮次未完成")
completed_rounds = 0
try:
result = await transport.embed(
texts=["polygateway embedding probe"], source=source, call_id="probe"
)
except PolyGatewayError as exc:
path = _record(
"probe_unsupported",
[
"# Embedding 端点探测: 网关不支持",
f"- base_url: {source.base_url}",
f"- model: {source.model}",
f"- 错误分类: {type(exc).__name__}",
f"- status_code: {exc.status_code}",
f"- 详情: {exc}",
"",
"结论: e2e 按设计 §11.6 降级,embedding 行为由 unit 全覆盖。",
],
)
await transport.aclose()
pytest.skip(f"网关不支持 embeddings({type(exc).__name__}),证据: {path}")
else:
await transport.aclose()
with capture.round_context(session_id=run_id, parent_call_id=parent):
try:
result = await transport.embed(texts=texts, source=source, call_id=call_id)
events = [
e
for a in capture.attempts(session_id=run_id, parent_call_id=parent)
for e in a.http
]
assert len(events) == 1 and request_is_valid(events[0])
assert result.dim > 0 and len(result.vectors) == 1
_record(
"probe_supported",
[
"# Embedding 端点探测: 网关支持",
f"- base_url: {source.base_url}",
f"- model: {source.model}",
f"- dim: {result.dim}",
f"- usage: {result.prompt_tokens}({result.usage_source})",
f"- 向量前 5 维: {result.vectors[0][:5]}",
f"- raw: {dataclasses.asdict(result)['raw']}",
],
verdict = LiveVerdict("PASS", "向量形状与实发请求合格")
completed_rounds = 1
except Exception as error:
verdict = classify_live_failure(
error, capture.attempts(session_id=run_id, parent_call_id=parent)
)
completed_rounds = 1
finally:
write_live_round(
Path("tests/outputs/134/live"),
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
from datetime import datetime
from pathlib import Path
from uuid import uuid4
import pytest
from dotenv import dotenv_values
from pydantic import BaseModel
from polygateway import GatewayClient
from polygateway import Effort, GatewaySettings
from tests.e2e.conftest import (
LiveCapture,
captured_chat_round,
chat_expectations,
enforce_verdict,
observed_client,
source_controls,
)
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE,
reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)",
),
pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
]
_OUT_DIR = Path("tests/outputs/e2e")
class MiniAnswer(BaseModel):
"""最小结构化响应契约。"""
answer: int
reason: str
def _report(name: str, sections: list[tuple[str, str]]) -> Path:
_OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = _OUT_DIR / f"{name}_{ts}.md"
body = [f"# e2e 冒烟: {name}", ""]
for title, content in sections:
body += [f"## {title}", "", "```", content, "```", ""]
path.write_text("\n".join(body), encoding="utf-8")
return path
@pytest.fixture
async def client():
c = GatewayClient.from_env("LLM", env=_ENV)
yield c
await c.aclose()
async def _smoke(matrix, prompt, validate, *, stream=True, structured=None):
"""全量注入仅替换取证装配,仍调用生产结构化策略。"""
settings = GatewaySettings.from_env("LLM", env=_ENV)
messages = [{"role": "user", "content": prompt}]
controls = source_controls(settings)
capture = LiveCapture(
expectations=chat_expectations(
settings,
messages=messages,
stream=stream,
controls=controls,
structured_max_retries=(
settings.structured_max_retries if isinstance(structured, type) else None
),
)
)
async with observed_client(settings, capture) as client:
_, verdict = await captured_chat_round(
client,
capture,
run_id=uuid4().hex,
matrix_id=matrix,
round_index=1,
output_dir=Path("tests/outputs/134/live"),
messages=messages,
models={s.name: s.model for s in settings.sources},
providers={s.name: s.provider for s in settings.sources},
source_efforts={
s.name: s.reasoning_effort
if s.reasoning_effort is not None
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
if s.enable_thinking is not None
else None
for s in settings.sources
},
aliases={},
validate=validate,
stream=stream,
structured=structured,
)
enforce_verdict(verdict)
class TestRealGatewaySmoke:
async def test_stream_chat(self, client):
resp = await client.chat(
[{"role": "user", "content": "Reply with exactly: pong"}], session_id="e2e-smoke"
)
path = _report(
"stream_chat",
[
("响应", resp.content),
(
"元数据",
json.dumps(
{
"model": resp.model,
"source": resp.source_name,
"usage_source": resp.usage_source,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"latency_ms": resp.latency_ms,
"ttft_ms": resp.ttft_ms,
},
ensure_ascii=False,
indent=2,
),
),
],
)
assert resp.content.strip()
assert resp.ttft_ms is not None and resp.latency_ms > 0
print(f"输出: {path}")
"""保留流/非流和结构化真实行为断言。"""
async def test_non_stream_fast_path(self, client):
resp = await client.chat(
[{"role": "user", "content": "Reply with exactly: pong"}], stream=False
)
_report("non_stream", [("响应", resp.content)])
assert resp.content.strip() and resp.ttft_ms is None
async def test_stream_chat(self):
def validate(response):
assert response.content.strip()
assert response.ttft_ms is not None and response.latency_ms > 0
async def test_structured_json_tier(self, client):
resp = await client.chat(
[{"role": "user", "content": 'Reply ONLY with JSON: {"ok": true}'}],
await _smoke("smoke-stream", "Reply with exactly: pong", validate)
async def test_non_stream_fast_path(self):
def validate(response):
assert response.content.strip() and response.ttft_ms is None
await _smoke("smoke-json", "Reply with exactly: pong", validate, stream=False)
async def test_structured_json_tier(self):
def validate(response):
assert isinstance(response.structured_data, dict | list)
await _smoke(
"smoke-structured-json",
'Reply ONLY with JSON: {"ok": true}',
validate,
structured="json",
)
_report("structured_json", [("解析产物", repr(resp.structured_data))])
assert isinstance(resp.structured_data, dict | list)
async def test_structured_model_ladder(self, client):
resp = await client.chat(
[
{
"role": "user",
"content": "What is 2+3? Reply ONLY with JSON matching "
'{"answer": <int>, "reason": <short string>}',
}
],
async def test_structured_model_ladder(self):
def validate(response):
assert isinstance(response.structured_data, MiniAnswer)
assert response.structured_data.answer == 5
await _smoke(
"smoke-structured-model",
'What is 2+3? Reply ONLY with JSON matching {"answer": <int>, "reason": <short string>}',
validate,
structured=MiniAnswer,
)
_report(
"structured_model",
[
("原始响应", resp.content),
("校验产物", resp.structured_data.model_dump_json()),
],
)
assert isinstance(resp.structured_data, MiniAnswer)
assert resp.structured_data.answer == 5
File diff suppressed because it is too large Load Diff
+363 -26
View File
@@ -28,7 +28,7 @@ import pytest
from dotenv import dotenv_values
from polygateway.telemetry.postgres import PostgresRecorder
from polygateway.telemetry.schema import COLUMNS, telemetry_schema_sql
from polygateway.telemetry.schema import COLUMNS, insert_sql, telemetry_schema_sql
_EXPECTED_COLUMNS = [
"call_id",
@@ -58,8 +58,31 @@ _EXPECTED_COLUMNS = [
"meta",
"thinking_observation",
"reasoning_effort",
# —— 1.3.5 逻辑调用统计与结构化失败诊断的十列(issue #19/#23)——
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
]
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
# 而非 `[:-10]`: 后者会随下一次补列静默漂移到另一张表上,而漂移的表现是
# "旧表补列"用例悄悄改测了别的形态。
_PRE_135_COLUMNS = _EXPECTED_COLUMNS[:27]
# 1.3.4 版本的 recorder 实际写入的列(物理列去掉库从不显式写的 created_at)
_PRE_135_WRITTEN_COLUMNS = [c for c in _PRE_135_COLUMNS if c != "created_at"]
# 1.3.5 新增的十列,按 `COLUMNS`(即 INSERT 字段序)排列: manual 档告警逐字比对与
# "旧行新列为 NULL"两处共用同一份,免得两边各抄一份后各自漂移。
_CALL_OBSERVABILITY_COLUMNS = [c for c in COLUMNS if c not in _PRE_135_WRITTEN_COLUMNS]
def _dsn() -> str | None:
"""读 `.env` 的 DSN 并剥掉 SQLAlchemy 风格的 `+driver` 后缀;未配置返回 None。"""
@@ -92,13 +115,13 @@ async def template_admin_dsn() -> str:
return value
async def _record_minimal(
recorder: PostgresRecorder, call_id: str | None = None, **overrides
) -> dict[str, object]:
"""记一行最小遥测,并**返回实际提交的字段**供调用方逐列比对回读结果。
def _minimal_fields(call_id: str | None = None, **overrides) -> dict[str, object]:
"""一行最小遥测的**完整字段字典**(不写库),供 recorder 写入与旧版本进程模拟共用。
返回值不是顺手: 逐列断言若在测试里另抄一份期望值,抄错的那一列会以
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证
独立出来不是顺手: "新旧进程混写"那条用例要以 1.3.4 的列集直接发 INSERT,
若它另抄一份取值,抄错的那一列会以"库写错列位"的形态误报
调用方随后逐列比对回读结果,故返回的就是实际提交的那一份
"""
fields: dict[str, object] = {
"call_id": call_id if call_id is not None else "c1",
@@ -130,8 +153,30 @@ async def _record_minimal(
"thinking_observation": "unknown",
# 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开)
"reasoning_effort": None,
# —— 1.3.5 十列: 默认形态即"一次普通尝试行"(与单测 `_record_minimal` 同款)——
"scope": "LLM",
"operation": "chat",
# 库内现场构造的请求没有上下文 → NULL,不造 ID
"logical_call_id": None,
"event_kind": "attempt",
# 诊断四列只在失败的 attempt 行上非空;成功行不统一填 200
"http_status_code": None,
"error_type": None,
"cause_type": None,
"error_body": None,
# 逻辑快照两列只属终态行
"attempts": None,
"total_latency_ms": None,
}
fields.update(overrides)
return fields
async def _record_minimal(
recorder: PostgresRecorder, call_id: str | None = None, **overrides
) -> dict[str, object]:
"""记一行最小遥测,并**返回实际提交的字段**供调用方逐列比对回读结果。"""
fields = _minimal_fields(call_id, **overrides)
await recorder.record_llm_call(**fields)
return fields
@@ -167,6 +212,17 @@ async def _fetch(dsn: str, sql: str, *args):
await conn.close()
async def _execute_args(dsn: str, sql: str, *args) -> None:
"""带参数执行单条语句(扩展协议);用于模拟旧版本进程按旧列集发出的 INSERT。"""
import asyncpg
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(sql, *args)
finally:
await conn.close()
async def _execute_script(dsn: str, sql: str) -> None:
"""整段执行多语句脚本(不带参数,走简单查询协议)——模拟下游把脚本贴进 psql。"""
import asyncpg
@@ -588,14 +644,24 @@ _PRE_TENANT_INSERT = (
)
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的列
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
# 去掉后的顺序与 DDL 逐字一致(这列在 DDL 里本就排在末尾)。
_PRE_TENANT_COLUMNS = [
c
for c in _EXPECTED_COLUMNS
if c not in ("tenant_id", "meta", "thinking_observation", "reasoning_effort")
]
# 去掉后的顺序与 DDL 逐字一致(这列在 DDL 里本就排在末尾)。
_PRE_TENANT_ABSENT = (
"tenant_id",
"meta",
"thinking_observation",
"reasoning_effort",
*_CALL_OBSERVABILITY_COLUMNS,
)
_PRE_TENANT_COLUMNS = [c for c in _EXPECTED_COLUMNS if c not in _PRE_TENANT_ABSENT]
# 这张表缺的 14 个维度,按 `COLUMNS`(即告警的排列序)列出: manual 档告警逐字比对用。
# 逐字而非前缀断言,是为了让"将来漏进告警的新列"当场红(设计 §4.2 的纪律)。
_PRE_TENANT_MISSING_NOTICE = (
f"以下维度不会被记录: {', '.join(c for c in COLUMNS if c in _PRE_TENANT_ABSENT)}"
)
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
_PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"]
@@ -703,7 +769,7 @@ class TestCallerDimensionsAcceptance:
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 22 → 26 个 recorder 字段(加 created_at 共 27 个物理列),且新列追加在末尾
# 22 → 36 个 recorder 字段(加 created_at 共 37 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch(
schema_dsn,
@@ -908,11 +974,8 @@ class TestManualSchemaModeAcceptance:
assert [m for m in captured_warnings if "补列失败" in m] == []
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
# 逐字钉住个维度: 前缀断言会让将来漏进告警的新列照样绿
assert (
"以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。"
in notices[0]
)
# 逐字钉住缺的每一个维度: 前缀断言会让将来漏进告警的新列照样绿
assert _PRE_TENANT_MISSING_NOTICE in notices[0]
finally:
await recorder.aclose()
@@ -938,11 +1001,8 @@ class TestManualSchemaModeAcceptance:
assert recorder.telemetry_status.degraded is False
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次,第二行不再重复
# 逐字钉住个维度: 前缀断言会让将来漏进告警的新列照样绿
assert (
"以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。"
in notices[0]
)
# 逐字钉住缺的每一个维度: 前缀断言会让将来漏进告警的新列照样绿
assert _PRE_TENANT_MISSING_NOTICE in notices[0]
# 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名
assert (
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]
@@ -974,6 +1034,283 @@ class TestManualSchemaModeAcceptance:
await recorder.aclose()
# 1.3.5 之前(1.3.4 发布形态)的表: 26 个 recorder 字段 + created_at = 27 个物理列,
# 没有本版新增的任何一列。裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行。
_PRE_135_DDL = """
CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT,
reasoning_effort TEXT
)
"""
# 一行 1.3.5 之前写下的历史数据(只列 NOT NULL 列,与当年 recorder 的写入等价)。
# 工厂的 `extra` 逐条裸执行、不接受查询参数,故 call_id 内联成字面量。
_PRE_135_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
"VALUES ('pre135', 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
)
# 这张表缺的正是本版十列;manual 档告警要逐字比对的那句。
_PRE_135_MISSING_NOTICE = f"以下维度不会被记录: {', '.join(_CALL_OBSERVABILITY_COLUMNS)}"
@pytest.fixture
async def pre_135_schema(pg_sandbox) -> tuple[str, str]:
"""一次性沙箱里造一张 **1.3.4 形态的 27 列表**,并留一行本版之前的历史数据。
共享表 `llm_calls` 一个字节都不碰: 本机那张表升级一次就再也回不到旧形态,
指望它还是旧形态的测试第二次跑就会空转( `pre_tenant_schema` 同款理由)
"""
sandbox = await pg_sandbox(ddl=_PRE_135_DDL, extra=(_PRE_135_INSERT,))
return sandbox.dsn, sandbox.schema
class TestCallObservabilityColumnsAcceptance:
"""1.3.5(issue #19/#23)的 PG 存储兼容验收: auto 追加 / manual 裁剪 / 旧行 NULL / 混写。
单元层在 SQLite 上断的是同一族语义,这里断的是**真实 PG 上确实如此**
两端的 DDL补列语句与列序是两份文本(`SQLITE_BACKFILL` `_PG_BACKFILL_DECLS`),
只有真表能证明它们没有分叉
"""
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
"""27 列旧表 auto 补齐到 37 列,新行两类取值读得回,**历史行十列一律 NULL**。
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
会被就地改写成"记过且值为 X",归因查询从此分不清真实缺口故这条断言的
方向是 NULL,不是空串也不是 0
"""
schema_dsn, schema = pre_135_schema
recorder = _recorder(schema_dsn, auto_migrate=True)
try:
# attempt 行: 诊断四列非空、逻辑快照两列 NULL
await _record_minimal(
recorder,
call_id="att",
scope="LLM",
operation="chat",
logical_call_id="lcid-1",
event_kind="attempt",
http_status_code=503,
error_type="TransientError",
cause_type="ReadTimeout",
error_body="upstream said 503",
)
# 终态行: 逻辑快照两列非空、诊断三列 NULL(不搬最后一次 attempt 的现场)
await _record_minimal(
recorder,
call_id="term",
scope="LLM",
operation="chat",
logical_call_id="lcid-1",
event_kind="terminal_failure",
error="AllSourcesExhausted: 全部源已耗尽",
error_type="AllSourcesExhausted",
attempts=3,
total_latency_ms=4200,
)
cols = await _fetch(
schema_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 26 → 36 个 recorder 字段(加 created_at 共 37 个物理列),新列追加在末尾:
# 列序与新建库一致才不会让两条升级路径分叉
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
names = ", ".join(_CALL_OBSERVABILITY_COLUMNS)
rows = await _fetch(
schema_dsn,
f"SELECT call_id, {names} FROM llm_calls "
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
["att", "pre135", "term"],
)
by_id = {r["call_id"]: r for r in rows}
assert dict(by_id["att"]) == {
"call_id": "att",
"scope": "LLM",
"operation": "chat",
"logical_call_id": "lcid-1",
"event_kind": "attempt",
"http_status_code": 503,
"error_type": "TransientError",
"cause_type": "ReadTimeout",
"error_body": "upstream said 503",
"attempts": None,
"total_latency_ms": None,
}
assert dict(by_id["term"]) == {
"call_id": "term",
"scope": "LLM",
"operation": "chat",
"logical_call_id": "lcid-1",
"event_kind": "terminal_failure",
"http_status_code": None,
"error_type": "AllSourcesExhausted",
"cause_type": None,
"error_body": None,
"attempts": 3,
"total_latency_ms": 4200,
}
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
assert dict(by_id["pre135"]) == {
"call_id": "pre135",
**dict.fromkeys(_CALL_OBSERVABILITY_COLUMNS),
}
finally:
await recorder.aclose()
async def test_manual_trims_the_insert_on_a_pre_135_table(
self, pre_135_schema, captured_warnings
):
"""27 列旧表 + manual: 一条 DDL 都不发,写入按现有列裁剪后照样落库。
与上一条恰成对照: 同一张表同一份负载,只有 `auto_migrate` 不同,列数就必须是
27 37 之别裁剪是关掉 ALTER 的前提不裁剪的话每行 INSERT 都撞缺列
(SQLSTATE 42703)而被整行丢弃,那是把自动补列换成遥测静默全失
"""
schema_dsn, schema = pre_135_schema
recorder = _recorder(schema_dsn, auto_migrate=False)
try:
recorded = await _record_minimal(
recorder, call_id="man135", scope="LLM", logical_call_id="lcid-x"
)
cols = await _fetch(
schema_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 表结构逐字不动: 既没多出十列,也没被顺手改了列序
assert [r["column_name"] for r in cols] == _PRE_135_COLUMNS
names = ", ".join(_PRE_135_WRITTEN_COLUMNS)
rows = await _fetch(
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", "man135"
)
assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
# 其余 26 列逐列与提交值相等: 少写十列最容易引发的错是剩下的值整体错位
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_135_WRITTEN_COLUMNS}
assert [m for m in captured_warnings if "写入失败" in m] == []
assert [m for m in captured_warnings if "补列失败" in m] == []
assert recorder.telemetry_status.degraded is False
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
# 逐字钉住十个维度: 前缀断言会让将来漏进告警的新列照样绿
assert _PRE_135_MISSING_NOTICE in notices[0]
# 提示里的 SQL 必须可直接粘贴执行(首列与末列各验一条,含类型)
assert "ALTER TABLE llm_calls ADD COLUMN scope TEXT;" in notices[0]
assert "ALTER TABLE llm_calls ADD COLUMN total_latency_ms INTEGER;" in notices[0]
finally:
await recorder.aclose()
async def test_old_and_new_writers_share_one_table(self, fresh_schema, captured_warnings):
"""滚动升级期的混写: 已补列的表上,旧版本进程按 26 列写、新版本按 36 列写。
这是升级窗口里必然出现的形态(先升一个 worker,其余仍是 1.3.4),而它的失败
方式是静默的: 若新列带了 NOT NULL 或旧列集的 INSERT 被拒, worker 的遥测
会整段消失而只留逐行 warning故这里既断三行都在也断没有写入失败 warning
旧进程用 `insert_sql("postgres", 旧列集)` 而不是另抄一条 SQL: 1.3.4
recorder 发出的正是同一函数按当年列集拼出的语句,另抄一份只会各自漂移
"""
fresh_dsn, schema = fresh_schema
recorder = _recorder(fresh_dsn, auto_migrate=True)
try:
# 新版本进程: 建表(37 列)并写一条带完整新列的终态行
await _record_minimal(
recorder,
call_id="new-1",
scope="LLM",
operation="chat",
logical_call_id="lcid-mix",
event_kind="terminal_failure",
error_type="AllSourcesExhausted",
attempts=2,
total_latency_ms=1500,
)
# 旧版本进程: 同一张表,按 1.3.4 的 26 列集写入
legacy_fields = _minimal_fields("old-1", response="from a 1.3.4 worker")
await _execute_args(
fresh_dsn,
insert_sql("postgres", _PRE_135_WRITTEN_COLUMNS),
*(legacy_fields[c] for c in _PRE_135_WRITTEN_COLUMNS),
)
# 新版本进程继续写: 旧进程的写入不得污染后续(列集是每进程各自探测的)
await _record_minimal(
recorder, call_id="new-2", scope="LLM", operation="embed", event_kind="attempt"
)
cols = await _fetch(
fresh_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS # 旧进程不改表结构
rows = await _fetch(
fresh_dsn,
"SELECT call_id, response, scope, operation, event_kind, attempts, "
"total_latency_ms FROM llm_calls ORDER BY call_id",
)
assert [r["call_id"] for r in rows] == ["new-1", "new-2", "old-1"]
by_id = {r["call_id"]: r for r in rows}
assert by_id["old-1"]["response"] == "from a 1.3.4 worker"
# 旧进程写下的行,新列一律 NULL——它没写,也不该被谁替它填
assert (by_id["old-1"]["scope"], by_id["old-1"]["event_kind"]) == (None, None)
assert (by_id["old-1"]["attempts"], by_id["old-1"]["total_latency_ms"]) == (None, None)
assert (by_id["new-1"]["attempts"], by_id["new-1"]["total_latency_ms"]) == (2, 1500)
assert by_id["new-2"]["operation"] == "embed"
assert [m for m in captured_warnings if "写入失败" in m] == []
# 下游可见变化的机械化依据: 新口径"计失败调用"按 event_kind 过滤,
# 混写期旧行(event_kind 为 NULL)既不会被误计成失败,也不会被误计成成功
terminal = await _fetch(
fresh_dsn,
"SELECT count(*) AS n FROM llm_calls WHERE event_kind = 'terminal_failure'",
)
assert terminal[0]["n"] == 1
unclassified = await _fetch(
fresh_dsn, "SELECT count(*) AS n FROM llm_calls WHERE event_kind IS NULL"
)
assert unclassified[0]["n"] == 1
finally:
await recorder.aclose()
_PHYSICAL_COLUMNS_SQL = (
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position"
@@ -1000,7 +1337,7 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 26 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
# 物理列 = 36 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS
+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)
+300 -1
View File
@@ -570,7 +570,7 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
before = build_cache_key("m", messages, "proj", None)
rec = self._Rows()
await TelemetryEmitter(rec, text_cap=8).emit_attempt(
await TelemetryEmitter(rec, text_cap=8, scope="LLM").emit_attempt(
request=ChatRequest(messages=messages),
source=SourceConfig(
name="s1",
@@ -585,6 +585,7 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
response=_resp(),
error=None,
reasoning_applies=True,
operation="chat",
)
# 截断确实发生了(否则本用例恒真)
logged = json.loads(rec.rows[0]["messages"])
@@ -592,3 +593,301 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
assert "(略 112 字)" in logged[1]["content"][0]["text"]
assert build_cache_key("m", messages, "proj", None) == before
class TestExplicitCacheMigration:
"""相同模型身份不代表相同推理策略,隔离必须由调用方显式选择。"""
def _client(self, cache, source, *, capabilities=None, registry=None):
import httpx
from polygateway.transports.openai_compat import OpenAICompatTransport
from tests.unit.test_client import _client, _sse
sent = []
def handler(request):
payload = json.loads(request.content)
sent.append(payload)
return _sse(json.dumps(payload, sort_keys=True))
transport = OpenAICompatTransport(
client_factory=lambda src: httpx.AsyncClient(transport=httpx.MockTransport(handler)),
capabilities=capabilities,
registry=registry,
)
client = _client(
sources=[source],
transport=transport,
cache=cache,
cache_namespace="tenant-a",
cache_ttl_s=60,
)
return client, transport, sent
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
@pytest.mark.parametrize("change", ["capability", "fallback"])
async def test_capability_change_requires_explicit_identity(self, isolation, change):
from polygateway.client import build_model_fingerprint
from polygateway.errors import RequestRejectedError
from polygateway.thinking import ThinkingCapability
from tests.unit.test_client import _source
cache = InMemoryCache()
source = _source(provider="openai", model="migration-model")
if change == "capability":
old_cap = ThinkingCapability((Effort.AUTO, Effort.HIGH), "本地旧声明")
new_cap = ThinkingCapability((Effort.HIGH,), "本地新声明")
tier = Effort.AUTO
new_source = source
else:
old_cap = new_cap = ThinkingCapability((Effort.LOW, Effort.HIGH), "本地映射声明")
source = dataclasses.replace(source, effort_fallback="nearest")
new_source = dataclasses.replace(source, effort_fallback="error")
tier = Effort.MEDIUM
assert build_model_fingerprint([source]) == build_model_fingerprint([new_source])
old, old_transport, old_sent = self._client(
cache, source, capabilities={source.model: old_cap}
)
new, new_transport, new_sent = self._client(
cache, new_source, capabilities={source.model: new_cap}
)
identity = (
{"cache_namespace": "tenant-a:migrated"}
if isolation == "namespace"
else {"cache_salt": "migrated"}
)
try:
original = await old.chat(_MSGS, reasoning_effort=tier)
replay = await new.chat(_MSGS, reasoning_effort=tier)
assert replay.cache_hit and replay.content == original.content
assert len(old_sent) == 1 and not new_sent
with pytest.raises(RequestRejectedError):
await new.chat(_MSGS, reasoning_effort=tier, **identity)
assert not new_sent
assert (await old.chat(_MSGS, reasoning_effort=tier)).cache_hit
finally:
await old_transport.aclose()
await new_transport.aclose()
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
async def test_custom_wire_change_requires_explicit_identity(self, isolation):
from polygateway.client import build_model_fingerprint
from polygateway.providers import ProviderProfile, ThinkingWire
from polygateway.thinking import ThinkingCapability
from tests.unit.test_client import _source
source = _source(provider="custom", model="migration-model")
caps = {source.model: ThinkingCapability((Effort.HIGH,), "本地声明")}
def profile(key):
return {
"custom": ProviderProfile(
name="custom",
thinking=ThinkingWire(off=None, on_base={}, effort_key=key),
strip_think_tags=False,
)
}
cache = InMemoryCache()
old, t1, sent1 = self._client(cache, source, capabilities=caps, registry=profile("depth_a"))
new, t2, sent2 = self._client(cache, source, capabilities=caps, registry=profile("depth_b"))
assert build_model_fingerprint(old._terminal._sources) == build_model_fingerprint(
new._terminal._sources
)
identity = (
{"cache_namespace": "tenant-a:migrated"}
if isolation == "namespace"
else {"cache_salt": "migrated"}
)
try:
original = await old.chat(_MSGS, reasoning_effort=Effort.HIGH)
assert (await new.chat(_MSGS, reasoning_effort=Effort.HIGH)).cache_hit
migrated = await new.chat(_MSGS, reasoning_effort=Effort.HIGH, **identity)
assert not migrated.cache_hit and migrated.content != original.content
assert len(sent1) == len(sent2) == 1
assert sent2[0]["depth_b"] == "high" and "depth_a" not in sent2[0]
assert (await old.chat(_MSGS, reasoning_effort=Effort.HIGH)).content == original.content
finally:
await t1.aclose()
await t2.aclose()
@pytest.mark.parametrize("isolation", ["namespace", "salt"])
async def test_legacy_raw_override_requires_explicit_identity(self, isolation):
from polygateway.client import build_model_fingerprint
from polygateway.errors import RequestRejectedError
from tests.unit.test_client import _source
source = _source(
provider="openai", reasoning_effort="high", extra_body={"reasoning_effort": "low"}
)
cache = InMemoryCache()
key = build_cache_key(build_model_fingerprint([source]), _MSGS, "tenant-a", None)
legacy = dataclasses.asdict(_resp(content="legacy-raw-low", applied_effort=Effort.HIGH))
legacy.pop("structured_data", None)
await cache.set(key, json.dumps(legacy), 60)
client, transport, sent = self._client(cache, source)
identity = (
{"cache_namespace": "tenant-a:migrated"}
if isolation == "namespace"
else {"cache_salt": "migrated"}
)
try:
assert (await client.chat(_MSGS)).content == "legacy-raw-low"
with pytest.raises(RequestRejectedError, match="冲突"):
await client.chat(_MSGS, **identity)
assert sent == []
assert await cache.get(key) is not None
finally:
await transport.aclose()
async def test_per_call_namespace_survives_a_changed_factory_default(self):
from polygateway import GatewayClient, GatewaySettings
from tests.unit.test_client import _ENV, _source
cache = InMemoryCache()
source = _source()
old, transport, sent = self._client(cache, source)
try:
await old.chat(_MSGS, cache_namespace="tenant-a")
# 工厂路径和全量注入配置同模型身份;只改默认不能改变显式租户覆盖。
settings = GatewaySettings.from_env(
env={
**_ENV,
"PGW_CACHE_BACKEND": "memory",
"PGW_CACHE_NAMESPACE": "changed-default",
"PGW_CACHE_TTL_S": "60",
}
)
new = GatewayClient.from_settings(settings, cache=cache)
try:
assert (await new.chat(_MSGS, cache_namespace="tenant-a")).cache_hit
assert len(sent) == 1
finally:
await new.aclose()
finally:
await transport.aclose()
async def test_shared_source_pool_migration_preserves_tenant_boundaries(self):
import httpx
from polygateway.errors import RequestRejectedError
from polygateway.thinking import ThinkingCapability
from polygateway.transports.openai_compat import OpenAICompatTransport
from tests.unit.test_client import _client, _source, _sse
sources = [
_source(name=name, provider="openai", model="shared-model") for name in ("a", "b")
]
cache = InMemoryCache()
sent = []
def handler(request):
sent.append(request)
return _sse()
transports = [
OpenAICompatTransport(
client_factory=lambda src: httpx.AsyncClient(
transport=httpx.MockTransport(handler)
),
capabilities={"shared-model": ThinkingCapability(choices, "本地声明")},
)
for choices in ((Effort.AUTO, Effort.HIGH), (Effort.HIGH,))
]
clients = [
_client(
sources=sources, transport=t, cache=cache, cache_namespace="default", cache_ttl_s=60
)
for t in transports
]
try:
for tenant in ("tenant-a", "tenant-b"):
await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
assert (
await clients[1].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
).cache_hit
with pytest.raises(RequestRejectedError):
await clients[1].chat(
_MSGS, reasoning_effort="auto", cache_namespace=tenant + ":new"
)
assert len(sent) == 2
for tenant in ("tenant-a", "tenant-b"):
assert (
await clients[0].chat(_MSGS, reasoning_effort="auto", cache_namespace=tenant)
).cache_hit
finally:
for transport in transports:
await transport.aclose()
class TestCallStatsNotPoisoned:
"""缓存不得回放历史统计(1.3.5 设计 §3)。
统计描述**本次**调用;把上次那条存进去再放出来,等于对调用方谎称这次
重试了 N 耗了 M 毫秒
"""
async def test_serialized_payload_carries_no_call_stats_key(self):
from polygateway.types import CallStats
backend = InMemoryCache()
mw = _mw(backend)
stats = CallStats(logical_call_id="lc-1", attempts=3, total_latency_ms=900)
terminal = _Terminal(_resp(call_stats=stats))
await mw(ChatRequest(messages=_MSGS), terminal)
key = build_cache_key("m", _MSGS, "proj", None)
stored = json.loads(await backend.get(key))
assert "call_stats" not in stored # asdict 会把它摊成 dict,必须显式剔除
async def test_historic_dict_never_impersonates_call_stats(self):
"""旧条目里的 `call_stats` dict 会被 `_RESPONSE_FIELDS` 放行,必须显式覆盖。
不覆盖就会有一个 dict 冒充 `CallStats` 从公共 API 漏给调用方,
`resp.call_stats.attempts` 当场 `AttributeError`
"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
poisoned = {
"content": "legacy",
"thinking": "",
"model": "m",
"provider": "p",
"prompt_tokens": 1,
"completion_tokens": 2,
"latency_ms": 30,
"ttft_ms": 5.0,
"max_inter_token_ms": 2.0,
"cache_hit": False,
"call_id": "orig",
"source_name": "s1",
"usage_source": "measured",
"call_stats": {
"logical_call_id": "stale-lc",
"attempts": 7,
"total_latency_ms": 9999,
},
}
await backend.set(key, json.dumps(poisoned), ttl_s=100)
terminal = _Terminal(_resp())
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.content == "legacy" and terminal.calls == 0 # 真的走了缓存
assert hit.call_stats is None # dict 不得冒充 CallStats
async def test_cache_key_is_unchanged_by_the_new_field(self):
"""新增内部字段不得扰动 key 公式,否则存量缓存全量冷启动(黄金值)。"""
from polygateway.types import _CallContext
class _Clock:
def __call__(self):
return 1000.0
ctx = _CallContext(now=_Clock())
bare = build_cache_key("m", _MSGS, "proj", None)
assert bare == build_cache_key("m", _MSGS, "proj", None)
# 带上下文的请求与不带的请求必须落在同一个 key 上
with_ctx = ChatRequest(messages=_MSGS, call_context=ctx)
without = ChatRequest(messages=_MSGS)
assert with_ctx.cache_namespace == without.cache_namespace
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
+478 -41
View File
@@ -13,6 +13,7 @@ from polygateway import (
GatewayClient,
GatewaySettings,
RequestRejectedError,
ResultInvalidError,
gather_bounded,
)
from polygateway.backends.memory.breaker import InMemoryGate
@@ -25,6 +26,7 @@ from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
Effort,
GlobalLimits,
RetryPolicy,
@@ -247,52 +249,35 @@ class TestReasoningEffortPriority:
assert "reasoning_effort" not in captured[0]
@pytest.mark.parametrize(
("provider", "model", "fragment"),
"provider,model,tier",
[
("qwen", "qwen-max", {"enable_thinking": True}),
("deepseek", "deepseek-v4-pro", {"thinking": {"type": "enabled"}}),
("zhipu", "glm-5.3", {"thinking": {"type": "enabled"}}),
("moonshot", "kimi-k3", {"thinking": {"type": "enabled"}}),
("deepseek", "deepseek-v4-pro", "high"),
("zhipu", "glm-5.3", "low"),
("moonshot", "kimi-k3", "low"),
("minimax", "MiniMax-M3", "medium"),
],
)
async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment):
"""存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。
async def test_legacy_auto_requires_explicit_migration(self, provider, model, tier):
"""旧糖配置明确拒绝,显式选择才能恢复可执行请求。"""
from dataclasses import replace
**只覆盖 `on_base` 自己就说全了""的四段**openai/anthropic/google 的开档
旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位那是设计
§4.2 声明过的**有意变更**(medium GLM/kimi/deepseek 的档位表里根本不存在,
是库替下游做的档位判断),不是本门要守的不变量;这三家的模型经 OpenRouter
登记均为默认推理,不注入也仍是""minimax 不在此列: 它的模型不满足该前提,
已按 issue #21 改回 medium,由下一条用例单独守。
qwen/deepseek 两条字面量逐字取自升级前的 `ProviderProfile.thinking_on`;
zhipu/moonshot 升级前没有对应段,断言的是它们 2026-09-04 登记的形态
"""
captured = []
source = _source(provider=provider, model=model, enable_thinking=True)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
body = captured[0]
assert {k: body[k] for k in fragment} == fragment
# `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档
assert "reasoning_effort" not in body
async def test_legacy_minimax_on_tier_actually_turns_reasoning_on(self):
"""回归门(issue #21): minimax 段的存量 `ENABLE_THINKING=true` 必须真开推理。
本次换代一度把这段的开启形态改成 `on_base={}`(什么参数都不注入),依据是
"这些模型默认就推理,不注入也仍是''"T10 真实网关实测推翻了该前提:
MiniMax-M3 不带任何推理参数时 5/5 **不推理**(六个强度值则全部生效)
于是存量下游从"真开推理"静默变成"不推理", `resolve_thinking` Phase 5
无条件放行 `auto`能力表也堵不住这条路
断言落在**发出去的字节**上而非中间态: 静默不推理这件事只有在请求体里才看得见
"""
captured = []
source = _source(provider="minimax", model="MiniMax-M3", enable_thinking=True)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
assert captured[0]["reasoning_effort"] == "medium"
client = self._capturing_client(captured, sources=[source])
try:
with pytest.raises(RequestRejectedError):
await client.chat([])
assert captured == []
finally:
await client._transport.aclose()
client = self._capturing_client(
captured, sources=[replace(source, enable_thinking=None, reasoning_effort=tier)]
)
try:
await client.chat([])
assert captured[0]["reasoning_effort"] == tier
finally:
await client._transport.aclose()
class TestEffortFallbackWiring:
@@ -863,11 +848,19 @@ _CACHE_ENV = dict(
class _Closable:
"""记 close 次数的假组件;所有权纪律的唯一观测点。"""
"""记 close 次数的假组件;所有权纪律的唯一观测点。
`record_llm_call(**fields)` 是因为它也被当作注入的 telemetry recorder :
1.3.5 的装配闸在构造期就会拒掉不满足 `TelemetryRecorder` 的对象
(否则下游升级后 100% 丢遥测而调用照常成功)`**fields` 形态天然兼容
"""
def __init__(self):
self.closed = 0
async def record_llm_call(self, **fields):
pass
async def aclose(self):
self.closed += 1
@@ -878,6 +871,9 @@ class _SyncClosable:
def __init__(self):
self.closed = 0
async def record_llm_call(self, **fields):
pass
def close(self):
self.closed += 1
@@ -1280,3 +1276,444 @@ class TestTelemetryStatusExposure:
assert _ocr_client().telemetry_status is None
assert _ocr_client(telemetry=_Closable()).telemetry_status is None
self._assert_snapshot(_ocr_client(telemetry=self._recorder(tmp_path)).telemetry_status)
class TestManagedReasoningAdmission:
"""入口前置与实际准入边界保持明确。"""
@pytest.mark.parametrize("factory", ["env", "settings"])
def test_factory_rejects_conflict_before_building_backends(self, monkeypatch, factory):
env = {
**_ENV,
"LLM__QWEN__1__MODEL": "qwen3.7-plus",
"LLM__QWEN__1__ENABLE_THINKING": "true",
"LLM__QWEN__1__EXTRA_BODY": '{"enable_thinking":true}',
}
built = []
def forbidden(*args, **kwargs):
built.append(True)
raise AssertionError("后端不得构造")
monkeypatch.setattr("polygateway.client._build_limiter", forbidden)
with pytest.raises(ValueError, match="冲突"):
if factory == "env":
GatewayClient.from_env(env=env)
else:
GatewayClient.from_settings(GatewaySettings.from_env(env=env))
assert not built
async def test_request_conflict_does_not_enter_onion(self):
client = _client()
async def forbidden(request):
raise AssertionError("不得进入洋葱")
client._handler = forbidden
try:
with pytest.raises(ValueError, match="冲突"):
await client.chat([], reasoning_effort="high", overlay={"reasoning_effort": "high"})
finally:
await client._transport.aclose()
async def test_source_conflict_releases_admitted_permit(self):
source = _source(
reasoning_effort="high", provider="openai", extra_body={"reasoning_effort": "high"}
)
sent = []
client = _client(sources=[source], handler=lambda request: sent.append(request) or _sse())
try:
with pytest.raises(RequestRejectedError, match="冲突"):
await client.chat([])
assert sent == []
assert (await client._limiter_backend.source_stats(source.name)).inflight == 0
finally:
await client._transport.aclose()
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()
class _StatsClock:
"""确定性单调钟;测试主动推进以断言"哪些区段计入了总耗时""""
def __init__(self, start=1000.0):
self.t = start
def __call__(self):
return self.t
def advance(self, seconds):
self.t += seconds
class _TickingCache:
"""假缓存后端: 每次 IO 推进注入钟。
不推进时钟的替身会让"缓存 IO 计入总耗时"的断言退化成恒等于 0 的空转绿
(计划 §T1 替身构造要求)
"""
def __init__(self, clock, tick=0.25):
self._clock = clock
self._tick = tick
self._data = {}
async def get(self, key):
self._clock.advance(self._tick)
return self._data.get(key)
async def set(self, key, value, ttl_s):
self._clock.advance(self._tick)
self._data[key] = value
class _TickingRecorder:
"""假 recorder: 写入时推进注入钟,用于断言内联遥测收尾计入总耗时。"""
def __init__(self, clock, tick=0.5):
self._clock = clock
self._tick = tick
self.rows = []
async def record_llm_call(self, **fields):
self._clock.advance(self._tick)
self.rows.append(fields)
class TestLogicalCallStats:
"""一次公开 chat 调用的统计(1.3.5 设计 §3)。"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_success_reports_one_attempt(self):
async with _client() as client:
resp = await client.chat(self._MSG)
assert resp.call_stats is not None
assert resp.call_stats.attempts == 1
assert resp.call_stats.logical_call_id
async def test_concurrent_calls_do_not_share_counters_or_ids(self):
"""同一 client 并发两路必须各自计数与各自 ID(库铁律「纯 asyncio 中立」)。
上下文若被提升成 client 实例属性,这条就会红那正是 VT
`evolve_llm = llm` 教训的同一形态
"""
async with _client() as client:
a, b = await asyncio.gather(client.chat(self._MSG), client.chat(self._MSG))
assert a.call_stats.logical_call_id != b.call_stats.logical_call_id
assert a.call_stats.attempts == b.call_stats.attempts == 1
async def test_cache_hit_is_zero_attempts_with_a_fresh_logical_id(self):
"""命中不产生网关调用 → 0 尝试;且是**新**逻辑调用,不回放历史统计。"""
clock = _StatsClock()
cache = _TickingCache(clock)
async with _client(
cache=cache, cache_namespace="proj", cache_ttl_s=600, now=clock
) as client:
first = await client.chat(self._MSG)
second = await client.chat(self._MSG)
assert first.cache_hit is False and first.call_stats.attempts == 1
assert second.cache_hit is True
assert second.call_stats.attempts == 0
assert second.call_stats.logical_call_id != first.call_stats.logical_call_id
async def test_cache_io_counts_into_total_latency(self):
"""缓存读写是本次调用真实花掉的时间,必须进总耗时(设计 §3)。"""
clock = _StatsClock()
cache = _TickingCache(clock, tick=0.25)
async with _client(
cache=cache, cache_namespace="proj", cache_ttl_s=600, now=clock
) as client:
hit = (await client.chat(self._MSG), await client.chat(self._MSG))[1]
# 命中路径只有一次 get(0.25s),无网关调用
assert hit.call_stats.attempts == 0
assert hit.call_stats.total_latency_ms == 250
async def test_inline_telemetry_teardown_counts_into_total_latency(self):
"""成功响应的快照含返回前已完成的内联遥测耗时(设计 §6)。"""
clock = _StatsClock()
recorder = _TickingRecorder(clock, tick=0.5)
async with _client(telemetry=recorder, now=clock) as client:
resp = await client.chat(self._MSG)
assert recorder.rows # 确实写了行,否则本断言空转
assert resp.call_stats.total_latency_ms == 500
async def test_milliseconds_not_seconds(self):
"""毫秒/秒不混用: 1.5s 必须是 1500 而不是 1 或 1.5。"""
clock = _StatsClock()
recorder = _TickingRecorder(clock, tick=1.5)
async with _client(telemetry=recorder, now=clock) as client:
resp = await client.chat(self._MSG)
assert resp.call_stats.total_latency_ms == 1500
async def test_stats_work_without_any_telemetry(self):
"""统计生效与否**不由 telemetry 是否启用决定**(设计 §3.5)。"""
async with _client(telemetry=None) as client:
resp = await client.chat(self._MSG)
assert resp.call_stats is not None and resp.call_stats.attempts == 1
async def test_failure_exception_carries_no_stats_attribute(self):
"""本版**不向异常对象附加统计**(设计 §3.1): 第三方可能复用同一异常实例。"""
def reject(request):
return httpx.Response(400, json={"error": {"message": "bad"}})
async with _client(handler=reject) as client:
with pytest.raises(RequestRejectedError) as exc:
await client.chat(self._MSG)
assert hasattr(exc.value, "call_stats") is False
async def test_input_validation_stays_outside_the_stats_boundary(self):
"""校验异常保持原行为,发生在统计边界之外(设计 §3)。"""
async with _client() as client:
with pytest.raises(ValueError, match="meta"):
await client.chat(self._MSG, meta={"BAD-KEY": 1})
class TestChatTerminalFailureRows:
"""chat 链路的**真实**终态行(1.3.5 设计 §6;补漏而非改口径)。
这些路径改前一条失败行都没有(结构化耗尽)或只有尝试行,
"这次调用到底失败了几次"因此 SQL 答不出来
"""
_MSG = [{"role": "user", "content": "hi"}]
def _rows(self, recorder, kind):
return [r for r in recorder.rows if r["event_kind"] == kind]
async def test_structured_exhaustion_writes_the_only_failure_row(self):
"""结构化耗尽发生在 transport 成功之后: attempt 行全是成功行,终态是唯一记录。"""
from pydantic import BaseModel
class Answer(BaseModel):
answer: int
recorder = _MemoryRecorder()
async with _client(
handler=lambda request: _sse("not json at all"),
telemetry=recorder,
structured_max_retries=1,
) as client:
with pytest.raises(ResultInvalidError):
await client.chat(self._MSG, structured=Answer)
attempts = self._rows(recorder, "attempt")
terminals = self._rows(recorder, "terminal_failure")
assert [a["error"] for a in attempts] == [None, None] # 两次尝试都成功
assert len(terminals) == 1
row = terminals[0]
assert row["error_type"] == "ResultInvalidError"
# C2: 有界结构化说明并入 error,且不含 raw_text(正文预算已由 attempt 行承担)
assert "validation=" in row["error"] or "repair=" in row["error"]
assert len(row["error"]) < 1200
async def test_retry_exhaustion_writes_exactly_one_terminal_row(self):
"""重试耗尽: 逐次 attempt 行之外只能有**一条**终态行。
终态行的 `attempts` 是整次逻辑调用的真实尝试数这正是改前 SQL
答不出的"这次调用到底重试了几次"
"""
recorder = _MemoryRecorder()
async with _client(
handler=lambda request: httpx.Response(503),
telemetry=recorder,
retry=RetryPolicy(3, 0.001, 0.01),
) as client:
with pytest.raises(AllSourcesExhausted):
await client.chat(self._MSG)
attempts = self._rows(recorder, "attempt")
terminals = self._rows(recorder, "terminal_failure")
assert len(attempts) == 3
assert len(terminals) == 1
row = terminals[0]
assert row["error_type"] == "AllSourcesExhausted"
assert row["attempts"] == 3 # 整次逻辑调用的尝试数
assert row["scope"] == "llm" and row["operation"] == "chat"
# 终态行的 latency_ms 与 total_latency_ms 同取一份冻结快照
assert row["latency_ms"] == row["total_latency_ms"]
async def test_request_rejected_now_has_both_an_attempt_and_a_terminal_row(self):
"""已批准的下游可见变化: 400 密集负载的错误行翻倍,失败计数只能取终态。"""
recorder = _MemoryRecorder()
async with _client(
handler=lambda request: httpx.Response(400, json={"error": {"message": "bad"}}),
telemetry=recorder,
) as client:
with pytest.raises(RequestRejectedError):
await client.chat(self._MSG)
attempts = self._rows(recorder, "attempt")
terminals = self._rows(recorder, "terminal_failure")
assert len(attempts) == 1 and attempts[0]["http_status_code"] == 400
assert len(terminals) == 1
# C1: 终态不搬运最后一次 attempt 的状态码与正文
assert terminals[0]["http_status_code"] is None
assert terminals[0]["error_body"] is None
# 两类行共享同一 logical_call_id,归因查询才连得起来
assert attempts[0]["logical_call_id"] == terminals[0]["logical_call_id"]
async def test_non_domain_exception_writes_no_terminal_row(self):
"""编程错原样传播,本版**不承诺**任何统计或终态行,也不偷偷改分类。"""
recorder = _MemoryRecorder()
async def boom(request):
raise KeyError("programming error")
async with _client(telemetry=recorder) as client:
client._handler = boom
with pytest.raises(KeyError):
await client.chat(self._MSG)
assert self._rows(recorder, "terminal_failure") == []
async def test_cancellation_writes_at_most_one_terminal_row(self):
"""取消尽力写一条(允许 0),且 `CancelledError` 类型与语义不变。"""
recorder = _MemoryRecorder()
async def hang(request):
raise asyncio.CancelledError
async with _client(telemetry=recorder) as client:
client._handler = hang
with pytest.raises(asyncio.CancelledError):
await client.chat(self._MSG)
terminals = self._rows(recorder, "terminal_failure")
assert len(terminals) == 1
assert terminals[0]["error"] == "cancelled"
# 字符串不解析猜诊断
assert terminals[0]["error_type"] is None
async def test_terminal_row_is_written_once_per_logical_call(self):
"""`claim_terminal` 去重: 同一次调用即便出口被多次触达也只有一条。"""
from polygateway.middleware.telemetry import emit_terminal_once
from polygateway.types import _CallContext
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
context = _CallContext(now=client._now)
request = ChatRequest(messages=self._MSG, call_context=context)
for _ in range(3):
await emit_terminal_once(
client._emitter,
request=request,
context=context,
error=AllSourcesExhausted(
scope="llm", reason="retry_exhausted", retry_after_s=1.0
),
operation="chat",
)
assert len(self._rows(recorder, "terminal_failure")) == 1
@pytest.fixture
def captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。
名字避开裸 `warnings`: 那会遮蔽标准库模块名,本文件将来任何一次
`import warnings` 都会与它静默互相顶掉,而报错点离真因很远
"""
from loguru import logger
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
yield messages
logger.remove(sink_id)
class TestTerminalEmitDegradation:
"""终态出口的降级方向: 诊断字段的**提取**同样在降级范围内(铁律"遥测写失败降级不冒泡")。
改前 `_record` except 只包住写入本身,而错误诊断列在它之外求值
经公共扩展点(自实现 `StructuredOutputStrategy`/transport)构造出的
`ResultInvalidError(validation_errors=( str,))` 会让提取期抛 `TypeError`
顶替调用方本该收到的领域异常,错误四分类当场被击穿
"""
_MSG = [{"role": "user", "content": "hi"}]
def _rows(self, recorder, kind):
return [r for r in recorder.rows if r["event_kind"] == kind]
async def test_broken_validation_errors_keep_the_domain_error(self, captured_warnings):
"""自实现策略给出非 str 的 `validation_errors`: 领域异常必须原样上抛。"""
class _BadStrategy:
"""公共端口 `StructuredOutputStrategy` 的下游实现(库外没有类型执法)。"""
def request_overlay(self, schema):
return {}
def parse(self, text):
raise ResultInvalidError("模型返回不可解析", validation_errors=(object(),))
recorder = _MemoryRecorder()
async with _client(telemetry=recorder, structured_strategy=_BadStrategy()) as client:
with pytest.raises(ResultInvalidError): # 不是 TypeError
await client.chat(self._MSG, structured="json")
# 降级有声: 静默吞掉等于遥测缺口无人知道
assert [m for m in captured_warnings if "终态遥测记录失败" in m]
# 尝试行不受影响;终态行按 best effort 允许 0 条,但绝不能重复
assert len(self._rows(recorder, "attempt")) == 1
assert len(self._rows(recorder, "terminal_failure")) <= 1
async def test_cancellation_is_never_swallowed_by_the_degradation(self):
"""降级不得吞取消: 写入那一次 await 上被取消,`CancelledError` 照常传播。"""
from polygateway.middleware.telemetry import emit_terminal_once
from polygateway.types import _CallContext
class _CancellingEmitter:
async def emit_terminal_failure(self, **kwargs):
raise asyncio.CancelledError
context = _CallContext(now=asyncio.get_running_loop().time)
request = ChatRequest(messages=self._MSG, call_context=context)
with pytest.raises(asyncio.CancelledError):
await emit_terminal_once(
_CancellingEmitter(),
request=request,
context=context,
error=AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0),
operation="chat",
)
+44
View File
@@ -997,3 +997,47 @@ class TestCrossFieldInvariants:
)
client = GatewayClient.from_settings(settings)
assert client is not None
async def test_flat_legacy_keys_assemble_without_source_timeout_or_network():
"""完整合成 env 验证平铺回落,不借真实配置或 Redis/PG。"""
env = dict(_BASE_ENV)
del env["LLM__QWEN__1__TIMEOUT_S"]
env.update({"LLM_TIMEOUT": "317", "LLM_TTFT_TIMEOUT": "41", "LLM_INTER_TOKEN_TIMEOUT": "19"})
settings = GatewaySettings.from_env("LLM", env=env)
assert settings.sources[0].timeout_s == 317
assert settings.sources[0].ttft_timeout_s == 41
assert settings.sources[0].inter_token_timeout_s == 19
assert settings.retry.max_attempts == 3
client = GatewayClient.from_settings(settings)
try:
assert client._transport._clients == {}
finally:
await client.aclose()
@pytest.mark.parametrize("model", ["MiniMax-M2.7", "MiniMax-M3"])
def test_live_assembly_rejection_is_local_only(model):
"""M2.7 NONE 与 M3 AUTO 的旧 live 装配断言离线执行。"""
env = _env(**{"LLM__QWEN__1__MODEL": model})
settings = GatewaySettings.from_env("LLM", env=env)
source = dataclasses.replace(
settings.sources[0], provider="minimax", enable_thinking=model == "MiniMax-M3"
)
with pytest.raises(ValueError, match=model):
GatewayClient.from_settings(dataclasses.replace(settings, sources=(source,)))
def test_live_unknown_wire_assembly_is_local_only():
"""L9 明确全 None profile,不从当前默认注册表猜未知形态。"""
mystery = ProviderProfile(
name="mystery",
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
strip_think_tags=False,
)
settings = GatewaySettings.from_env("LLM", env=_BASE_ENV)
source = dataclasses.replace(settings.sources[0], provider="mystery", enable_thinking=False)
with pytest.raises(ValueError, match="register_provider"):
GatewayClient.from_settings(
dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery)
)
+112
View File
@@ -533,3 +533,115 @@ class TestEmbeddingSettings:
s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
client = EmbeddingClient.from_settings(s)
assert isinstance(client, EmbeddingClient)
class TestReasonlessTelemetryContract:
"""从真实客户端到落库,误配推理配置也不能产生推理档。"""
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
async def test_failed_then_successful_attempts_have_null_effort(
self, config, backend, tmp_path
):
import sqlite3
from polygateway.telemetry.sqlite import SQLiteRecorder
path = tmp_path / "embed.sqlite"
recorder = (
_MemoryRecorder() if backend == "memory" else SQLiteRecorder(path, auto_migrate=True)
)
client, _ = _embed_client(
[_src(**config)], [TransientError("retry"), "ok"], telemetry=recorder
)
try:
await client.embed(["text"], session_id="run", parent_call_id="embed")
if backend == "memory":
rows = [(r["error"], r["reasoning_effort"]) for r in recorder.rows]
else:
with sqlite3.connect(path) as db:
rows = db.execute("SELECT error, reasoning_effort FROM llm_calls").fetchall()
assert len(rows) == 2
assert sum(bool(error) for error, _ in rows) == 1
assert [tier for _, tier in rows] == [None, None]
finally:
await client.aclose()
if backend == "sqlite":
recorder.close()
@pytest.mark.parametrize("exhausted", [False, True])
async def test_failed_attempts_still_have_null_effort(self, exhausted):
from polygateway.errors import AllSourcesExhausted
script = (
[TransientError("retry")] * 3 if exhausted else [RequestRejectedError("bad request")]
)
recorder = _MemoryRecorder()
client, _ = _embed_client([_src(enable_thinking=True)], script, telemetry=recorder)
with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError):
await client.embed(["text"])
# 1.3.5: 逐次 attempt 行之外,本次逻辑调用另有**一条**终态行
attempts = [r for r in recorder.rows if r["event_kind"] == "attempt"]
terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"]
assert len(attempts) == len(script)
assert len(terminals) == 1
# 推理档在三类行上都必须是 NULL: embed payload 从不带推理参数
assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows)
async def test_embedding_wire_ignores_reasoning_configuration(self):
seen = []
def handler(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json=_ok_body([[1.0]], usage={"prompt_tokens": 1}))
transport = _transport_with(handler)
try:
await transport.embed(
texts=["text"],
source=_src(enable_thinking=True, reasoning_effort="high"),
call_id="wire",
)
assert seen == [{"model": "embed-1", "input": ["text"]}]
finally:
await transport.aclose()
class TestEmbedLogicalCallStats:
"""分批共享同一逻辑调用(1.3.5 设计 §3/§3.5)。"""
async def test_three_batches_count_three_attempts(self):
"""分批是库的实现细节,但每批都真打了一次网关,故计 3 次尝试。"""
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=2)
resp = await client.embed(["a", "bb", "ccc", "dddd", "eeeee"])
assert resp.call_stats is not None
assert resp.call_stats.attempts == 3
async def test_separate_calls_get_distinct_logical_ids(self):
"""一次公开调用一个 ID: 两次 `embed` 不得共用同一个。
共用就意味着上下文被提升成了 client 实例属性(库铁律禁止的形态)
"""
client, _ = _embed_client([_src()], ["ok"] * 5, batch_size=2)
first = await client.embed(["a", "bb", "ccc", "dddd", "eeeee"]) # 3 批
second = await client.embed(["x", "y"]) # 1 批
assert first.call_stats.attempts == 3 and second.call_stats.attempts == 1
assert first.call_stats.logical_call_id != second.call_stats.logical_call_id
async def test_retry_within_a_batch_is_counted(self):
client, _ = _embed_client([_src(), _src(name="e2")], [TransientError("t1"), "ok"])
resp = await client.embed(["a"])
assert resp.call_stats.attempts == 2
async def test_empty_input_is_zero_attempts_and_writes_no_telemetry_row(self):
"""合法零尝试: 返回真实统计,且**不写任何遥测行**(设计 §3 M2)。
cache_hit 不同不要按"遥测必录"推断空输入也有台账行
"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], [], telemetry=rec)
resp = await client.embed([])
assert resp.call_stats is not None
assert resp.call_stats.attempts == 0
assert resp.call_stats.logical_call_id # 真实 ID,不是空串
assert rec.rows == [] # 零遥测行
File diff suppressed because it is too large Load Diff
+28
View File
@@ -405,3 +405,31 @@ class TestLifecycle:
await t.check_health(source=_source())
await t.aclose()
await t.aclose()
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
async def test_ocr_wire_does_not_send_reasoning_configuration(method):
"""真实 multipart 与 ZIP 下载两段均不发送源级推理配置。"""
sent = []
def handler(request):
sent.append(request)
if request.method == "GET":
return httpx.Response(200, content=_zip_bytes())
return httpx.Response(
200, json=_text_body() if method == "recognize_text" else _parse_body()
)
transport = _transport_for(handler)
try:
await getattr(transport, method)(
image=b"image",
source=_source(enable_thinking=True, reasoning_effort="high"),
call_id="wire",
)
assert len(sent) == (1 if method == "recognize_text" else 2)
for request in sent:
for key in (b"reasoning_effort", b"enable_thinking", b"thinking_budget"):
assert key not in request.content
finally:
await transport.aclose()
+88
View File
@@ -564,3 +564,91 @@ class TestAssembly:
client = OcrClient.from_env("OCR", env=dict(self._ENV))
await client.aclose()
await client.aclose()
class TestReasonlessTelemetryContract:
"""text/layout 两个入口分别验证错误行不受源级推理配置污染。"""
@pytest.mark.parametrize(
"method,action", [("recognize_text", "text"), ("parse_layout", "layout")]
)
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
async def test_failed_then_successful_attempts_have_null_effort(
self, method, action, config, backend, tmp_path
):
import sqlite3
from polygateway.telemetry.sqlite import SQLiteRecorder
path = tmp_path / "ocr.sqlite"
recorder = (
_MemoryRecorder() if backend == "memory" else SQLiteRecorder(path, auto_migrate=True)
)
client, _, _ = _client(
[_src(**config)], [TransientError("retry"), action], telemetry=recorder
)
try:
await getattr(client, method)(b"image", session_id="run", parent_call_id=method)
if backend == "memory":
rows = [(r["error"], r["reasoning_effort"]) for r in recorder.rows]
else:
with sqlite3.connect(path) as db:
rows = db.execute("SELECT error, reasoning_effort FROM llm_calls").fetchall()
assert len(rows) == 2
assert sum(bool(error) for error, _ in rows) == 1
assert [tier for _, tier in rows] == [None, None]
finally:
await client.aclose()
if backend == "sqlite":
recorder.close()
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
@pytest.mark.parametrize("exhausted", [False, True])
async def test_failed_attempts_still_have_null_effort(self, method, exhausted):
script = (
[TransientError("retry")] * 3 if exhausted else [RequestRejectedError("bad request")]
)
recorder = _MemoryRecorder()
client, _, _ = _client([_src(enable_thinking=True)], script, telemetry=recorder)
with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError):
await getattr(client, method)(b"image")
# 1.3.5: 逐次 attempt 行之外,本次逻辑调用另有**一条**终态行
attempts = [r for r in recorder.rows if r["event_kind"] == "attempt"]
terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"]
assert len(attempts) == len(script)
assert len(terminals) == 1
# 终态行的 operation 是**公开方法**名,与尝试行一致
assert terminals[0]["operation"] == method
assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows)
class TestOcrLogicalCallStats:
"""OCR 两个公开方法各自拥有一次逻辑调用(1.3.5 设计 §3/§3.5)。"""
async def test_text_success_counts_one_attempt(self):
client, _, _ = _client([_src()], ["text"])
r = await client.recognize_text(b"jpg")
assert r.call_stats is not None and r.call_stats.attempts == 1
async def test_layout_two_http_calls_count_as_one_attempt(self):
"""POST + ZIP GET 在同一次 transport 调用内,计 **1** 次尝试而非 2。
`attempts` 的语义是"调用 transport 端口的次数",不是 HTTP 请求条数
"""
client, _, _ = _client([_src()], ["layout"])
r = await client.parse_layout(b"jpg")
assert r.call_stats is not None and r.call_stats.attempts == 1
async def test_retry_counts_every_attempt(self):
client, _, _ = _client([_src(), _src(name="m2")], [TransientError("t1"), "text"])
r = await client.recognize_text(b"jpg")
assert r.call_stats.attempts == 2
async def test_input_validation_stays_outside_the_stats_boundary(self):
"""`image` 类型/空校验先于上下文创建(M1 例外),保持原异常行为。"""
client, _, _ = _client([_src()], [])
with pytest.raises(TypeError):
await client.recognize_text("not-bytes")
with pytest.raises(ValueError):
await client.recognize_text(b"")
+144 -56
View File
@@ -116,7 +116,7 @@ async def _recorded_cost(result, source):
source_name=source.name,
usage_source=result.usage_source,
)
await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None).emit_attempt(
await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None, scope="LLM").emit_attempt(
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
source=source,
call_id="cid-1",
@@ -124,6 +124,7 @@ async def _recorded_cost(result, source):
response=response,
error=None,
reasoning_applies=True,
operation="chat",
)
return recorder.rows[0]["cost"]
@@ -623,8 +624,8 @@ class TestThinkingReconciliation:
try:
await _complete(transport, self._minimax(False))
await _complete(transport, self._minimax(False))
await _complete(transport, self._minimax(True))
await _complete(transport, self._minimax(True))
await _complete(transport, self._minimax(True), reasoning_effort=Effort.MEDIUM)
await _complete(transport, self._minimax(True), reasoning_effort=Effort.MEDIUM)
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
@@ -705,75 +706,44 @@ class TestNonStreamFastPath:
class TestRequestShaping:
@pytest.mark.parametrize(
("enable_thinking", "expected"),
[(True, {"enable_thinking": True}), (False, {"enable_thinking": False}), (None, {})],
)
async def test_thinking_tri_state_injection(self, enable_thinking, expected):
@pytest.mark.parametrize("tier", [Effort.MEDIUM, Effort.NONE])
async def test_minimax_explicit_tier_is_sent(self, tier):
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
await _complete(_transport_for(handler), _source(enable_thinking=enable_thinking))
assert {k: seen[k] for k in expected} == expected
if enable_thinking is None:
transport = _transport_for(handler)
try:
result = await _complete(
transport, _source(provider="minimax", model="MiniMax-M3"), reasoning_effort=tier
)
assert seen["reasoning_effort"] == tier.value
assert result.applied_effort is tier
assert "enable_thinking" not in seen
assert seen["stream_options"] == {"include_usage": True}
finally:
await transport.aclose()
@pytest.mark.parametrize(
("enable_thinking", "expected"),
# 本条断言反复过一次,记下原委以免第三次改回去:
# T2(2026-09-04)按"MiniMax 开启档本就无需参数"的**推定**把 medium 改成不注入;
# T10(2026-09-05)真实网关实测推翻该推定——M3 不发任何推理参数时 5/5 轮不推理,
# 故 medium 回归(issue #21 的权宜之计,正解是让 auto 受能力表约束)
[(True, "medium"), (False, "none")],
)
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
"""issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。"""
async def test_raw_only_keeps_source_then_request_priority(self):
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source(
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
transport = _transport_for(handler)
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["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):
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
@@ -1056,3 +1026,121 @@ class TestLifecycle:
await _complete(transport, _source())
await transport.aclose()
await transport.aclose()
class TestManagedReasoningOwnership:
"""通过真实 transport 验证拒绝发生在 HTTP 之前。"""
@pytest.mark.parametrize(
"raw",
[
{"reasoning_effort": "high"},
{"enable_thinking": True},
{"thinking": {}},
{"thinking_budget": 100},
{"reasoning": {}},
{"thinkingConfig": {}},
{"output_config": {"effort": "low"}},
],
)
@pytest.mark.parametrize("layer", ["source", "request", "shadowed"])
async def test_raw_control_is_rejected_before_http(self, raw, layer):
sent = []
def handler(request):
sent.append(request)
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
source = _source(
provider="openai",
reasoning_effort=Effort.HIGH,
extra_body=raw if layer != "request" else {},
)
overlay = raw if layer != "source" else {}
transport = _transport_for(handler)
try:
with pytest.raises(RequestRejectedError):
await _complete(transport, source, overlay=overlay)
assert sent == []
finally:
await transport.aclose()
class TestDefaultClientFactory:
"""直接检查生产 factory,不用取证测试的另一套 factory 代替。"""
@pytest.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()
+52 -1
View File
@@ -124,6 +124,18 @@ class _DummyRecorder:
reasoning_tokens,
tenant_id,
meta,
thinking_observation,
reasoning_effort,
scope,
operation,
logical_call_id,
event_kind,
http_status_code,
error_type,
cause_type,
error_body,
attempts,
total_latency_ms,
) -> None: ...
@@ -275,8 +287,47 @@ class TestTelemetryRecorderSignature:
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {"tenant_id", "meta"} <= set(params)
def test_call_observability_fields_are_declared(self):
"""1.3.5 十列进协议(issue #19/#23);字段总数以实测为准不凭记忆。
本签名同时是装配闸的事实源(`_assert_recorder_shape` 按它派生参数名),
故它与实现一旦漂移,下游自定义 recorder 会在装配期就被拒
"""
import inspect
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
} <= set(params)
assert len(params) - 1 == 36 # 减掉 self
@pytest.mark.parametrize(
"name", ["tenant_id", "meta", "thinking_observation", "reasoning_effort"]
"name",
[
"tenant_id",
"meta",
"thinking_observation",
"reasoning_effort",
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
],
)
def test_caller_dimensions_have_no_default(self, name):
import inspect
+10 -6
View File
@@ -169,7 +169,7 @@ def _source(model="qwen-max"):
class TestEmitterCost:
async def test_success_row_costed(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -178,18 +178,19 @@ class TestEmitterCost:
response=_resp(),
error=None,
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["cost"] == pytest.approx(7.2)
async def test_cache_hit_row_costs_zero(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True))
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True), operation="chat")
assert rec.rows[0]["cost"] == 0.0
async def test_failure_row_cost_none(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -198,12 +199,13 @@ class TestEmitterCost:
response=None,
error="TransientError: boom",
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["cost"] is None
async def test_unknown_model_none_without_blocking(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(model="mystery"),
@@ -212,13 +214,14 @@ class TestEmitterCost:
response=_resp(model="mystery"),
error=None,
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["cost"] is None
async def test_no_pricing_keeps_none(self):
"""未注入价格表 = M1 现状: cost 恒 None(回归)。"""
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, text_cap=None)
emitter = TelemetryEmitter(rec, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -227,5 +230,6 @@ class TestEmitterCost:
response=_resp(),
error=None,
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["cost"] is None
+3 -7
View File
@@ -57,14 +57,10 @@ class TestDefaultProfiles:
assert w.off == {"reasoning_effort": "none"}, name
assert w.effort_key == "reasoning_effort", name
def test_minimax_on_tier_carries_a_tier_value(self):
"""issue #21 的权宜之计: minimax 的""必须真写一个档位值,不能是空片段。
断言反复过一次: T2 "这些模型默认就推理"的推定把它改成 `{}`,T10 真实
网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium
"""
def test_minimax_on_does_not_select_a_tier(self):
"""形态不代替模型能力,也不替调用者选择付费档位。"""
w = get_provider("minimax").thinking
assert w.on_base == {"reasoning_effort": "medium"}
assert w.on_base == {}
assert w.off == {"reasoning_effort": "none"}
assert w.effort_key == "reasoning_effort"
+58
View File
@@ -5,6 +5,7 @@
"""
import asyncio
import dataclasses
import pytest
@@ -776,3 +777,60 @@ class TestRateLimitPushback:
await mw(_REQ)
assert ei.value.reason == "retry_exhausted"
assert len(transport.calls) == 3
class TestLogicalAttemptCounting:
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
登记点若挪到成功之后,失败与取消的尝试就会从计数里消失而那正是
诊断时最需要看见的几次
"""
def _ctx(self, clock):
from polygateway.types import _CallContext
return _CallContext(now=clock)
async def test_single_success_counts_one(self):
mw, _, _, _, _, clock = _harness([_src("a")], [_ok()])
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 1
async def test_failed_retries_are_counted(self):
"""两次可重试失败 + 一次成功 = 3 次尝试,不是 1 次。"""
mw, _, _, transport, _, clock = _harness(
[_src("a")], [TransientError("t1"), TransientError("t2"), _ok()]
)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_budget_free_429_still_counts_as_an_attempt(self):
"""429 免的是重试预算,不是"没发生过"——它确实打到了网关。"""
mw, _, _, transport, _, clock = _harness(
[_src("a")],
[
TransientError("t1", status_code=429, retry_after_s=1.0),
TransientError("t2", status_code=429, retry_after_s=1.0),
_ok(),
],
)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_retry_exhausted_counts_every_attempt(self):
mw, _, _, transport, _, clock = _harness(
[_src("a")], [TransientError(str(i)) for i in range(5)], max_attempts=3
)
ctx = self._ctx(clock)
with pytest.raises(AllSourcesExhausted):
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_absent_context_does_not_break_the_call(self):
"""库内现场构造的 `ChatRequest` 没有上下文,不得因此报错(设计 §3.5)。"""
mw, _, _, _, _, _ = _harness([_src("a")], [_ok()])
resp = await mw(_REQ)
assert resp.content == "ok" and _REQ.call_context is None
File diff suppressed because it is too large Load Diff
+116 -19
View File
@@ -285,16 +285,13 @@ class TestResolveThinking:
get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest"
)
def test_phase4_only_blocks_the_off_direction(self):
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。
期望片段 2026-09-05 `{}` 改成 minimax `on_base` 实际值: issue #21 把
该段的""改回带 medium(T2 "开档不注入"是推定,T10 实测推翻)本用例守的
Phase 4 只拦关闭方向,注入什么由 wire 决定,故随 wire
"""
cap = get_capability("MiniMax-M2.7")
got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7")
assert got.payload == {"reasoning_effort": "medium"}
@pytest.mark.parametrize("model", ["MiniMax-M2.5", "MiniMax-M2.7"])
def test_phase4_only_blocks_the_off_direction(self, model):
"""已登记 AUTO 只发开启片段,不由库代选 medium。"""
got = resolve_thinking(
get_provider("minimax"), get_capability(model), Effort.AUTO, model=model
)
assert got.payload == {}
assert got.applied_effort is Effort.AUTO
def test_phase4_passes_when_none_is_registered(self):
@@ -351,17 +348,42 @@ class TestResolveThinking:
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "max"}
assert got.applied_effort is Effort.MAX
def test_auto_never_trips_phase5(self):
"""`auto` = 不指定档位,可满足性只取决于 wire 有没有 on_base。
@pytest.mark.parametrize(
"provider,model", [("deepseek", "deepseek-v4-pro"), ("minimax", "MiniMax-M3")]
)
@pytest.mark.parametrize("fallback", ["error", "nearest"])
def test_unregistered_auto_choice_is_rejected(self, provider, model, fallback):
"""有开启形态也不代表已登记模型支持 AUTO,nearest 不可代选。"""
with pytest.raises(ThinkingUnsupportedError) as exc:
resolve_thinking(
get_provider(provider),
get_capability(model),
Effort.AUTO,
model=model,
fallback=fallback,
)
assert model in str(exc.value)
assert "auto" in str(exc.value)
assert "EFFORT_FALLBACK=nearest" not in str(exc.value)
它不是写进 `effort_key` 的取值,故不受档位清单约束反过来判会让存量的
`ENABLE_THINKING=true`(T5 起等价于 auto) deepseek/glm-5.3 这类清单里
没有 auto 的模型上当场报错设计 §12 明确承诺存量配置继续可跑
"""
cap = get_capability("deepseek-v4-pro") # (none, high, max),清单里没有 auto
got = resolve_thinking(get_provider("deepseek"), cap, Effort.AUTO, model="deepseek-v4-pro")
assert got.payload == {"thinking": {"type": "enabled"}}
def test_minimax_explicit_medium_restores_old_wire(self):
got = resolve_thinking(
get_provider("minimax"), get_capability("MiniMax-M3"), Effort.MEDIUM, model="MiniMax-M3"
)
assert got.payload == {"reasoning_effort": "medium"}
assert got.applied_effort is Effort.MEDIUM
@pytest.mark.parametrize("provider", ["openai", "qwen"])
def test_unknown_auto_warns_without_promising_effect(self, provider):
messages, sink = _warnings()
try:
got = resolve_thinking(
get_provider(provider), None, Effort.AUTO, model="unregistered-model"
)
finally:
logger.remove(sink)
assert got.applied_effort is Effort.AUTO
assert any("不保证" in str(message) for message in messages)
# —— nearest 映射(fallback 的逃生口)——
@@ -825,3 +847,78 @@ class TestEffectiveEffort:
assert (
effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None
)
class TestThinkingWireOwnership:
"""开启片段只能表达开启,不能携带隐式强度。"""
@pytest.mark.parametrize(
"base",
[
{"reasoning_effort": "high"},
{"reasoning_effort": None},
{"depth": "auto"},
{"output_config": {"effort": "low"}},
],
)
@pytest.mark.parametrize("effort", [None, Effort.AUTO, Effort.HIGH])
def test_on_base_cannot_hide_a_tier(self, base, effort):
profile = ProviderProfile(
name="custom",
thinking=ThinkingWire(off={"depth": "none"}, on_base=base, effort_key="depth"),
strip_think_tags=False,
)
with pytest.raises(ThinkingUnsupportedError, match="on_base"):
resolve_thinking(profile, None, effort, model="custom-model")
class TestThinkingRawOwnership:
"""纯规则覆盖标准、自定义根与无意图逃生口。"""
@pytest.mark.parametrize(
"raw",
[
{"reasoning_effort": "high"},
{"enable_thinking": True},
{"thinking": {}},
{"thinking_budget": 100},
{"reasoning": {}},
{"thinkingConfig": {}},
{"output_config": {"effort": None}},
{"depth.key": None},
{"off_control": {}},
],
)
@pytest.mark.parametrize("effort", [Effort.NONE, Effort.AUTO, Effort.HIGH])
def test_control_roots_rejected_without_mutating_input(self, raw, effort):
from copy import deepcopy
from polygateway.thinking import validate_thinking_raw
wire = ThinkingWire(off={"off_control": False}, on_base={}, effort_key="depth.key")
before = deepcopy(raw)
with pytest.raises(ThinkingUnsupportedError):
validate_thinking_raw(raw, effort=effort, wire=wire, origin="test")
assert raw == before
validate_thinking_raw(raw, effort=None, wire=wire, origin="test")
assert raw == before
def test_output_format_is_not_effort_unless_wire_owns_root(self):
from polygateway.thinking import validate_thinking_raw
raw = {"output_config": {"format": "json"}, "temperature": 0, "seed": 7}
validate_thinking_raw(raw, effort=Effort.AUTO, wire=None, origin="test")
wire = ThinkingWire(off=None, on_base={"output_config": {"enabled": True}}, effort_key=None)
with pytest.raises(ThinkingUnsupportedError):
validate_thinking_raw(raw, effort=Effort.AUTO, wire=wire, origin="test")
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)
+141
View File
@@ -610,3 +610,144 @@ class TestSourceConfigEffortNormalization:
"""非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。"""
with pytest.raises(ValueError, match="推理档位"):
_make_source(reasoning_effort=3)
class TestCallStatsAndContext:
"""逻辑调用统计内核(1.3.5 设计 §3/§4)。"""
def test_call_stats_is_frozen_snapshot(self):
from polygateway.types import CallStats
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
with pytest.raises(dataclasses.FrozenInstanceError):
stats.attempts = 3
def test_context_counts_attempts_and_freezes_elapsed(self):
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
from polygateway.types import _CallContext
clock = _FakeMonotonic()
ctx = _CallContext(now=clock)
clock.advance(1.5)
ctx.register_attempt()
ctx.register_attempt()
stats = ctx.snapshot()
assert stats.attempts == 2
assert stats.total_latency_ms == 1500 # 秒→毫秒,不混用单位
def test_snapshot_is_repeatable_and_tracks_later_time(self):
from polygateway.types import _CallContext
clock = _FakeMonotonic()
ctx = _CallContext(now=clock)
first = ctx.snapshot()
clock.advance(2.0)
second = ctx.snapshot()
assert first.total_latency_ms == 0 and second.total_latency_ms == 2000
assert first.logical_call_id == second.logical_call_id
def test_each_context_gets_its_own_logical_id(self):
from polygateway.types import _CallContext
clock = _FakeMonotonic()
assert _CallContext(now=clock).logical_call_id != _CallContext(now=clock).logical_call_id
def test_claim_terminal_is_true_once(self):
"""终态去重位: 保证每逻辑调用至多写一条终态行(设计 §6 不变量 I3)。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
assert ctx.claim_terminal() is True
assert ctx.claim_terminal() is False
def test_chat_request_context_does_not_affect_equality_or_repr(self):
"""上下文是库内部件: 进 `compare`/`repr` 会污染既有请求语义与日志。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
bare = ChatRequest(messages=[{"role": "user", "content": "hi"}])
with_ctx = dataclasses.replace(bare, call_context=ctx)
assert with_ctx.call_context is ctx
assert with_ctx == bare
assert "call_context" not in repr(with_ctx)
def test_replace_preserves_the_same_context_reference(self):
"""洋葱各层经 `replace` 派生请求,上下文必须是同一实例而非拷贝。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], call_context=ctx)
derived = dataclasses.replace(req, stream=False)
assert derived.call_context is ctx
def test_four_responses_default_call_stats_to_none(self):
"""第三方合成响应的 `None` 表示未知,不得伪造 0(设计 §3)。"""
from polygateway.types import (
EmbeddingResponse,
OcrLayoutResult,
OcrTextResult,
)
llm = LLMResponse(
content="c",
thinking="",
model="m",
provider="p",
prompt_tokens=1,
completion_tokens=1,
latency_ms=1,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="c1",
)
emb = EmbeddingResponse(
vectors=[],
dim=0,
model="m",
provider="p",
prompt_tokens=0,
usage_source="measured",
latency_ms=0,
call_id="c2",
source_name="s",
)
text = OcrTextResult(
text="", source_name="s", usage=Usage(0, 0), latency_ms=0, call_id="c3", raw={}
)
layout = OcrLayoutResult(
elements=[],
page_sizes=[],
source_name="s",
usage=Usage(0, 0),
latency_ms=0,
call_id="c4",
raw={},
)
assert (llm.call_stats, emb.call_stats, text.call_stats, layout.call_stats) == (
None,
None,
None,
None,
)
def test_call_stats_is_exported_from_package_root(self):
"""四份平铺字段会漂移,故统计以单一对象出现在公共 API(设计 §3)。"""
import polygateway
from polygateway.types import CallStats
assert polygateway.CallStats is CallStats
assert "CallStats" in polygateway.__all__
class _FakeMonotonic:
"""确定性单调钟;不复用 contracts 的 FakeClock 以免 unit 反向依赖契约包。"""
def __init__(self, start: float = 1000.0) -> None:
self.t = start
def __call__(self) -> float:
return self.t
def advance(self, seconds: float) -> None:
self.t += seconds
+18 -6
View File
@@ -35,6 +35,7 @@ from polygateway.types import (
USAGE_SOURCES,
BackpressurePolicy,
BreakerConfig,
CallStats,
ChatRequest,
EmbeddingTransportResult,
GlobalLimits,
@@ -44,6 +45,9 @@ from polygateway.types import (
SourceConfig,
)
# 终态行的快照入参(1.3.5): `emit_terminal_failure` 不再收 `latency_ms`。
_MIGRATED_STATS = CallStats(logical_call_id="lcid-mig", attempts=1, total_latency_ms=1)
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
_DOMAIN = sorted(USAGE_SOURCES)
@@ -255,7 +259,7 @@ def _resp(usage_source):
@pytest.mark.parametrize("emitted", _DOMAIN)
async def test_emit_attempt_success_stays_in_domain(emitted):
recorder = _MemoryRecorder()
await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_attempt(
request=_REQ,
source=_src(),
call_id="cid",
@@ -263,6 +267,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
response=_resp(emitted),
error=None,
reasoning_applies=True,
operation="chat",
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
@@ -270,7 +275,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
async def test_emit_attempt_failed_attempt_stays_in_domain():
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
recorder = _MemoryRecorder()
await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_attempt(
request=_REQ,
source=_src(),
call_id="cid",
@@ -278,6 +283,7 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
response=None,
error="boom",
reasoning_applies=True,
operation="chat",
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
@@ -285,8 +291,10 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
@pytest.mark.parametrize("emitted", _DOMAIN)
async def test_emit_cache_hit_stays_in_domain(emitted):
recorder = _MemoryRecorder()
await TelemetryEmitter(recorder, text_cap=None).emit_cache_hit(
request=_REQ, response=_resp(emitted)
await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_cache_hit(
request=_REQ,
response=_resp(emitted),
operation="chat",
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
@@ -294,7 +302,11 @@ async def test_emit_cache_hit_stays_in_domain(emitted):
async def test_emit_terminal_failure_stays_in_domain():
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
recorder = _MemoryRecorder()
await TelemetryEmitter(recorder, text_cap=None).emit_terminal_failure(
request=_REQ, call_id="cid", latency_ms=10, error="cancelled"
await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_terminal_failure(
request=_REQ,
call_id="cid",
error="cancelled",
operation="chat",
stats=_MIGRATED_STATS,
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES