Compare commits

...

13 Commits

Author SHA1 Message Date
iomgaa 25a90ebd05 chore: merge 1.3.10 unified completion verdict (#30/#31) 2026-10-15 00:38:40 -04:00
iomgaa 1201a04449 chore: date 1.3.10 changelog for release 2026-10-15 00:38:19 -04:00
iomgaa abcdf6930e fix: address completion verdict review findings
- fidelity cacheability also requires a non-empty projected body, so
  think-tag-only answers are no longer cached
- a no-argument tool sent with arguments null only is a deterministic
  dialect: ResultInvalid tool_arguments_null instead of retried
- text mode treats choice index null as 0 and no longer lets False pose
  as 0
- null-only tool_calls elements no longer produce an empty tool_calls
  list; non-stream fidelity checks role before the verdict; warning
  paths are length-bounded
- docs: ARCHITECTURE 7.8 field counts, README cache/watchdog wording,
  CHANGELOG notes, review record in the design, hook note in the plan

Red: 8 new tests failed before this change (e.g.
test_openai_cacheable_requires_projected_body,
test_null_arguments_tool_is_result_invalid_stream,
test_text_mode_choice_index_null_is_zero[None-True]).
2026-10-15 00:28:20 -04:00
iomgaa 31942f55f1 docs: document completion verdict and bump version to 1.3.10
CHANGELOG 1.3.10 (unreleased) with behaviour-change and breaking
sections, README capability rows and a 1.3.10 section, ARCHITECTURE
section 15 plus 6.2/7 updates, schema doc for the stream_end column,
migration impact notes, ROADMAP entry, .env.example MISSING_DONE
semantics, and a revision pointer on the 1.3.9 design. Follow-ups
filed as #32 and #33.
2026-10-15 00:03:15 -04:00
iomgaa 779a9b29a3 feat: record stream end mode in telemetry
llm_calls gains a nullable stream_end TEXT column (auto backfill,
manual-mode trimming as in 1.3.8). TelemetryRecorder.record_llm_call
takes a new required keyword stream_end; the emitter reads it from the
response on attempt rows and writes NULL for failed, cache-hit and
terminal rows.

Red: 18 tests in tests/unit/test_telemetry.py and test_ports.py failed
against the pre-change source (missing column, 38-field signature,
stream_end absent from emitted rows).
2026-10-14 23:54:11 -04:00
iomgaa 48ba36dd94 fix: gate cache writes and reads on a shared cacheability verdict
Cacheable is now a strict subset of deliverable: salvaged streams,
content-less results and length/filter/unknown finishes are delivered
but never written, and the read path applies the same verdict to the
stored values before hit overrides (invalid stream_end or non-string
finish read as a miss; 1.3.9 fidelity entries fall back to the
completion's finish). Hits reset stream_end. The legacy
_has_completion_output helper is folded into _is_cacheable.

Red: 13 tests in tests/unit/test_cache.py failed before this change
(uncacheable results were written and replayed; new fields not reset).
2026-10-14 23:39:50 -04:00
iomgaa 39a319e34e fix: judge stream completion by finish reason and trailing usage
Both transport paths now share one verdict: a stream is complete on
[DONE] or on finish_reason followed by measured usage and a clean EOF
(both modes; missing_done only governs real truncation). Outcomes follow
the design's first-match table: upstream_error finishes stay transient,
fidelity mode delivers reasoning-only/length results, text mode raises
ResultInvalid for content-less length/filter endings, and non-stream
fidelity judges before building the snapshot so truncated tools reach
tool_truncated. Projections honour the reasoning alias, dialect warnings
are bounded, and responses carry finish_reason and stream_end.

Red: 39 of 46 tests in tests/unit/test_completion_transport.py failed
before this change (e.g. eof_after_finish raised 保真响应缺 [DONE];
reasoning-only length raised empty_completion; reasoning-only stream hit
the TTFT watchdog, re-confirmed by disabling alias liveness).
2026-10-14 23:31:54 -04:00
iomgaa 16743ca11d fix: merge unknown and null stream fields losslessly in completion accumulator
Null is treated as absent at every layer; unknown delta, tool and
function fields merge by OpenAI streaming conventions (strings concat,
label keys idempotent, indexed arrays by index) and conflicts drop the
key and record its path instead of failing the call. The accumulator
now tracks finish and usage-after-finish in both modes, exposes the
reasoning alias, and non-stream parsing splits shape checks from
snapshot construction.

Red: 20 of 47 tests in tests/unit/test_openai_completion.py failed
before this change (e.g. test_null_known_and_unknown_fields_are_absent
raised TransientError 未知 delta 字段; finish_seen/raw_message missing).
2026-10-14 23:21:05 -04:00
iomgaa e5ad67408a feat: add kernel completion verdict functions and response fields
Pure functions in types.py classify finish reasons, judge stream
completeness and outcome, and decide cacheability; TransportResult and
LLMResponse gain finish_reason and stream_end; snapshots accept
tool_calls null.

Red: tests/unit/test_completion_verdict.py failed at collection
(ImportError: _classify_finish et al. undefined) before this change.
2026-10-14 23:12:00 -04:00
iomgaa ab0e924849 docs: add implementation plan for unified completion verdict 2026-10-14 23:04:05 -04:00
iomgaa 607523ef5c docs: fold round-2 review fixes into completion verdict design 2026-10-14 22:46:56 -04:00
iomgaa 95104af2a5 docs: add unified completion verdict design for issues 30 and 31 2026-10-14 22:39:47 -04:00
iomgaa 5d2ad40336 chore: gate complexity only on functions touched by the change
Whole-tree radon blocking let 16 legacy C+ functions block every commit,
including docs-only ones. Touched functions still block; legacy debt is
reported as a warning.
2026-10-14 22:32:52 -04:00
33 changed files with 2841 additions and 271 deletions
+28 -3
View File
@@ -41,10 +41,35 @@ for CODE_DIR in "${CODE_DIRS[@]}"; do
fi
fi
# 复杂度只对"本次改动触及的函数"硬拦: 全量拦截会让存量债务挡住一切提交
# (含纯文档提交),久之只剩绕过一条路;存量 ≥C 函数降为告警,随触及随还。
# 用 HEAD 对比工作区+暂存区: 钩子在 `git add && git commit` 同一命令里先于 add 运行
if command -v radon &> /dev/null; then
RADON_OUTPUT=$(radon cc "$CODE_DIR" -n C -s 2>&1 || true)
if echo "$RADON_OUTPUT" | grep -qE '^\s+[FMC]\s'; then
ERRORS+="[radon] 存在圈复杂度 ≥ C 的函数:\n$RADON_OUTPUT\n"
mapfile -t CHANGED_PY < <(git diff HEAD --name-only --diff-filter=AM -- "$CODE_DIR" | grep '\.py$' || true)
if [[ ${#CHANGED_PY[@]} -gt 0 ]]; then
TOUCHED=$(git diff HEAD -U0 -- "${CHANGED_PY[@]}" | python3 -c '
import json, re, subprocess, sys
hunks, path = {}, None
for line in sys.stdin:
if line.startswith("+++ "):
path = line[6:].strip() if line.startswith("+++ b/") else None
m = re.match(r"@@ -\S+ \+(\d+)(?:,(\d+))? @@", line)
if m and path:
start, count = int(m.group(1)), int(m.group(2) or 1)
hunks.setdefault(path, []).append((start, start + max(count, 1) - 1))
data = json.loads(subprocess.run(["radon", "cc", "-j", "-n", "C", *hunks], capture_output=True, text=True).stdout or "{}")
for path, blocks in data.items():
for b in blocks if isinstance(blocks, list) else []:
if any(s <= b["endline"] and e >= b["lineno"] for s, e in hunks.get(path, [])):
print("%s:%s %s - %s (%s)" % (path, b["lineno"], b["name"], b["rank"], b["complexity"]))
' 2>&1 || true)
if [[ -n "$TOUCHED" ]]; then
ERRORS+="[radon] 本次改动触及的函数圈复杂度 ≥ C:\n$TOUCHED\n"
fi
fi
LEGACY_COUNT=$(radon cc "$CODE_DIR" -n C 2>/dev/null | grep -cE '^\s+[FMC]\s' || true)
if [[ "$LEGACY_COUNT" -gt 0 ]]; then
WARNINGS+="[radon] $CODE_DIR/ 存量 ${LEGACY_COUNT} 个函数圈复杂度 ≥ C(未被本次改动触及,不阻塞)。\n"
fi
fi
+1 -1
View File
@@ -31,7 +31,7 @@ LLM__QWEN__1__TIMEOUT_S=120
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
# LLM__QWEN__1__MISSING_DONE=retry # SSE 真截断(无 [DONE] 且无 finish+其后实测 usage): retry(默认) | salvage;1.3.10 起 finish+实测 usage+干净关闭的无 DONE 流恒视为完整
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传
+37
View File
@@ -1,5 +1,42 @@
# Changelog
## 1.3.10(2026-10-15)
#30/#31 同根修复: 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单判定成功,上游的正常结局被当瞬时错误重试到耗尽(magpie/glm 不发 `[DONE]` 时单次调用挂 1216s;只有思考的 length 截断每次写满 max_tokens 后重抽)。本版改以上游 `finish_reason` 与传输完整性为准,流式/非流式/缓存读写共用一份判定。设计 `research-wiki/designs/2026-10-14-issue30-31-completion-verdict-design.md`。
### 修复
- **缺 `[DONE]` 但完整的流**(#31,**两种模式**): finish_reason 已到、其同帧或之后收到实测 usage、连接干净关闭 → 完整交回,用量记 measured,`stream_end="eof_after_finish"`。文本模式默认 `MISSING_DONE=retry` 原本同样会重试到耗尽;该键现在只管真截断(无 finish 或 finish 后无实测 usage)。
- **只有思考的 length 截断**(#30): 保真模式原样交回(content 空、思考完整、finish_reason=length),不再重抽。
- **`null` 与未知字段**(#30): 任意层 `null` 视为缺省(`refusal: null`、`tool_calls: null` 不再判失败,**非流式同样修复**);保真模式未知字段按 OpenAI 流式惯例无损合并(字符串拼接、对象递归、带 `index` 数组按 index 合并、`index/type/id/format` 同值幂等),Gemini `extra_content` 一类工具层扩展可原样回传。
- **`reasoning` 别名**: vLLM/OpenRouter 的 `reasoning` 字段计入活性(此前只有思考的流会被 TTFT 看门狗误杀),文本模式无非空 `reasoning_content` 时投影为 `thinking`。
- **截断结果不再写缓存**: 文本 salvage 结果此前会被缓存并在 TTL 内反复回放。
### 行为变更
- **文本模式 length/过滤且无正文 → `ResultInvalidError`**(原 `TransientError` 重试到耗尽): 不重试、不换源、熔断记成功不计窗口;**不经 `AllSourcesExhausted` 包装**,直接到调用方。原因码见异常消息(`length_without_content` / `filtered_without_content`)。文本模式"只有思考 + stop"维持瞬时(证据收集中)。
- **finish_reason 为 `network_error`/`error`**(GLM 等上游自报失败)即使有正文也判瞬时。
- **保真模式**: 只有思考/拒答/空白 + 明确 finish(非上游错误)→ 原样交回;length 截断的半截工具(缺 id/name)→ `ResultInvalidError` `tool_truncated`;无参函数全程只发 `arguments: null`(其余字段齐全)→ `ResultInvalidError` `tool_arguments_null`(确定性方言,交付值不改写)。
- 文本模式 `choice.index: null` 视为 0(此前整帧被丢弃);`tool_calls` 中全为 `null` 的元素按 `tool_calls: null` 处理,不产出会被上游拒收的空数组。
- **缓存**: 只有自然结束(stop/tool_calls/无 finish 的旧口径)且有正文的结果写入;length/过滤/未登记 finish 值、salvaged、空正文均交付但不缓存,读到此类条目按 miss。未登记 finish 值按源告警一次。
- **活性口径增补**: 未知字段的非空字符串片段(如 `reasoning`)算进展,`index/type/id/format` 等标签键不算;`stall_ms` 计量随之覆盖。
### 新增
- `LLMResponse` / `TransportResult` 末尾追加 `finish_reason: str | None`、`stream_end: StreamEnd | None`(`done` / `eof_after_finish` / `salvaged`;非流式、缓存命中为 None);包根导出 `StreamEnd`。
- 遥测 `llm_calls` 末尾追加可空列 `stream_end`(INSERT 字段 38 → 39,物理列 39 → 40;旧行 NULL 不回填;auto 档幂等补列,manual 档裁剪写入并点名缺列,补列 SQL 由 `telemetry_schema_sql()` 自取)。
### 破坏性(请先读)
- **`TelemetryRecorder.record_llm_call` 新增必填 keyword `stream_end`**: 自建 recorder 未跟进会在装配期被形状闸拒绝(`**fields` 形态不受影响)。
### 提醒
- 保真模式交付 `finish_reason=length` 的工具调用时 arguments 可能是半截 JSON: **执行工具前先查 `finish_reason`**。
- 未知字段合并冲突时会**删除该字段**并按(源, 路径)告警一次: 结果可能缺字段,以告警为准。典型形态: 带 `index` 的数组(如 `reasoning_details`)后续帧元素缺 `index` 即判形态改变,**整个字段被删**。
- 诚实边界: 中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分;防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
- 旧版本写入的 salvage/length 文本缓存无法辨认: 受影响集合须在升级后首次读写前更换 namespace/salt(同 1.3.9 提醒)。
## 1.3.9(2026-10-01)
- 新增 `chat(response_mode="openai")`,返回 `LLMResponse.completion`:assistant 消息、工具调用、finish_reason、上游 response_id、usage 快照;支持流式聚合及非流式。
+47 -8
View File
@@ -20,11 +20,12 @@
| 长尾对冲 | **默认关闭**的可选加速(`{SCOPE}__HEDGE__AFTER_S`,仅 chat):一次尝试挂起超阈值时向**异源**并发再发一次,先回者赢、输家取消。流式以「首 token 未至」为触发判据(不误杀慢生成),非流式只有纯总时长阈值;对冲走完整限流/熔断准入,拿不到配额静默放弃。成本须知:开启即用配额换延迟(触发窗口内 in-flight 翻倍),输家取消按 est 保留预扣且**可能已被上游计费**(取消止不住上游);输家不喂熔断,遥测 attempt 行标 `error="hedge_cancelled"` 可按 `logical_call_id` join 对账;两路皆败只计一次重试预算;与 `call_deadline_s` 正交组合(装配守卫强制对冲阈值 < 期限)
| 中段沉默对冲(可选,超业界现状能力) | **默认关闭**(`{SCOPE}__HEDGE__STALL_AFTER_S`,仅流式 chat):主路**首 token 之后**相邻增量 chunk 沉默超阈值时向异源并发再发一次,与首 token 对冲 `HEDGE__AFTER_S` **独立生效**、可独设可组合;仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念)。**诚实标注:中段沉默触发并发副本在主流网关/库中无公开先例**(业界收敛为看门狗掐流重试)。成本须知:下游实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲,开启即用配额换延迟,输家可能已被上游计费;看门狗掐死**喂**熔断而对冲输家取消**不喂**(不对称声明),详见[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
| OpenAI 消息保真(1.3.9) | `chat(response_mode="openai")` 返回完整 assistant 消息/工具调用、finish_reason、上游 ID 与 usage;单 choice,复用治理,不执行工具;默认仍为文本模式 |
| 完成判定(1.3.10) | 以上游 `finish_reason` 为准判定"生成结束、结果可交付":缺 `[DONE]` 但 finish 与其后的实测 usage 齐全即完整(两模式);length/过滤等确定性结局不再被当瞬时错误重试;未知/`null` 字段不再判失败;`LLMResponse.finish_reason` / `stream_end` 给出结束原因与方式,详见[1.3.10 统一完成判定](#1310-统一完成判定) |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token(含 `reasoning` 别名)刷活性不计结果;截断流(无 `[DONE]` 且无 finish+其后实测 usage)默认判瞬时(`MISSING_DONE=salvage` 的文本打捞结果交付但不入缓存) |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 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` 与遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 38 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 39 字段;三类行(`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`,及生成时间 `generation_ms`——**1.3.8 起为净口径**:赢家那次 transport 调用墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部,排除准入排队/退避/触发前等待;1.3.7 的墙钟值由 `generation_ms + stall_ms` 还原——与沉默归因 `stall_ms` / `stall_count`(成对报告,None = 无沉默观测)、对冲计数 `hedges` / `hedge_won`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断)与[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
| 遥测的资源与降级 | 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、不建索引** |
@@ -125,7 +126,7 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重
| 定义 | **净生成时间** = 赢家那次 transport 调用的墙钟 − Σ(所有 > `STALL_THRESHOLD_MS`(缺省 1000ms)的相邻增量 chunk 沉默);仍**排除**准入排队/退避/对冲触发前等待(同 1.3.7) |
| 边界 | 仍含 **TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择) |
| 还原公式 | **墙钟 = `generation_ms + stall_ms`**(`stall_ms is None` 时墙钟 = `generation_ms`) |
| `stall_ms` / `stall_count` | 沉默总额(毫秒)与超阈值相邻间隔个数,**成对报告**(只报净数无法行动);chunk = 任何增量产出(`content` 或 `reasoning_content` 均计),空 chunk / usage 帧 / SSE keepalive 不计时 |
| `stall_ms` / `stall_count` | 沉默总额(毫秒)与超阈值相邻间隔个数,**成对报告**(只报净数无法行动);chunk = 任何增量产出(`content`、`reasoning_content`、工具实际增量,1.3.10 起另含未知字段的非空字符串片段如 `reasoning`),空 chunk / usage 帧 / SSE keepalive / 仅标签键(`index`/`type`/`id`/`format`)不计时 |
| `stall_ms is None` 分支条件 | 非流式 / 增量 chunk <2 → 两字段 None 且 `generation_ms` **不退减**;None = 「无沉默观测」而非 0,下游预算公式以此为分支条件 |
| 与基准口径的区隔 | NVIDIA TTFT/ITL/E2EL 与 vLLM 的基准口径**均不扣除**沉默——扣沉默是本库自造口径,与基准数字比较时不得混用 |
@@ -146,7 +147,7 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重
```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]>=1.3.9,<2"
"polygateway[redis,postgres,structured]>=1.3.10,<2"
```
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
@@ -632,16 +633,17 @@ messages.append(completion.message)
保真是 JSON 字段和值保真,不是 HTTP 字节透明代理。仅支持单 choice(`n=1`),
可含多个并行 function tool_calls;arguments 逐字拼接,不解析、修复或执行。
SSE 支持 role/content/reasoning_content/tool_calls,未知增量字段会明确失败;
SSE 的 role/content/reasoning_content/tool_calls 按原语义聚合;1.3.10 起 `null` 视为缺省,
未知增量字段按流式惯例无损合并(冲突删键并告警,见下节);
非流式保留整个 message 扩展字段,不返回顶层 logprobs/created 等其他字段。
上游实际模型仍读 `model_reported`,配置别名是 `model`。
| 边界 | 行为 |
| --- | --- |
| 空文本且有完整工具 | 正常成功;不完整工具或无任何有效产物仍失败 |
| 空文本且有完整工具 | 正常成功;不完整工具失败(length 截断的半截工具归 ResultInvalid),其余见 1.3.10 结局表 |
| error 帧 | 所有模式立即判失败并走已有重试;后续 DONE 不挽救 |
| 缺 DONE | 保真模式不受 salvage 放宽,防截断工具被执行 |
| finish_reason=length | 原样返回,不修补参数;调用方自行判断是否执行 |
| 缺 DONE | 1.3.10 起 finish + 其后实测 usage + 干净 EOF 即完整;真截断仍不受 salvage 放宽,防截断工具被执行 |
| finish_reason=length | 原样返回(含只有思考的回答),不修补参数;**执行工具前先查 finish_reason**,arguments 可能是半截 JSON |
| structured | 不可与保真并用;自行传 overlay.response_format 不启用库的修复/重问阶梯 |
| raw 推理 | 保真不是校验逃生口;raw-only 要求源 ENABLE_THINKING/REASONING_EFFORT 均未设置,请求档为 None,再通过 overlay.reasoning_effort 传原值 |
@@ -676,6 +678,43 @@ except TimeoutError:
应用退出时先停止接单、取消/等待在途调用,在同一个 loop 中 await client.aclose(),
再停 loop 并 join 线程;注入组件仍由所有者关闭。不要从 loop 本身阻塞调用 future.result。
## 1.3.10 统一完成判定
#30/#31 同根: 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单作为成功判据,上游的正常结局
(不发 `[DONE]`、只有思考的 length 截断、`refusal: null`)被当瞬时错误重试到耗尽。1.3.10
改以上游 `finish_reason` 与传输完整性为准,流式/非流式/缓存四处共用一份判定。
| 结束方式(`stream_end`) | 条件 |
| --- | --- |
| `done` | 收到 `[DONE]` |
| `eof_after_finish` | 未发 `[DONE]`,但 finish 已到、其同帧或之后收到的 usage 为实测,连接干净关闭(用量记 measured) |
| `salvaged` | 文本模式 `MISSING_DONE=salvage` 打捞的截断结果(用量降 estimated,**不写缓存**) |
| `None` | 非流式 / 缓存命中 / 失败行 |
| 结局(首个匹配行生效) | `openai` 模式 | `text` 模式 |
| --- | --- | --- |
| finish 为 `network_error`/`error`(上游自报失败) | 瞬时错误 | 瞬时错误 |
| 工具快照不成立 | length → `ResultInvalidError`;其余瞬时 | — |
| 无任何产物 + length/过滤 | 原样交回 | `ResultInvalidError`(不重试、不计熔断) |
| 无任何产物 + 其余(零增量抖动) | 瞬时错误 `empty_completion` | 同左 |
| 有正文 | 交付 | 交付 |
| 只有思考/拒答/空白 + length/过滤 | 原样交回 | `ResultInvalidError` |
| 只有思考/拒答/空白 + stop 等 | 原样交回 | 瞬时错误(证据收集中) |
| 只有思考/拒答/空白 + 无 finish | 瞬时错误 | 同左 |
`ResultInvalidError` 不经 `AllSourcesExhausted` 包装,直接到调用方;原因码在异常消息里
(`length_without_content` / `filtered_without_content` / `tool_truncated` 等,遥测 `error`
列可检索)。只有自然结束(stop/tool_calls/function_call,或未上报 finish 的旧口径)且投影正文非空
(或工具完整)的结果才写缓存;未登记的 finish 值按正常交付、不缓存并告警一次。无参函数只发
`arguments: null` 的工具判 `ResultInvalidError`(`tool_arguments_null`,确定性方言,不改写不重试)。文本模式 `thinking` 在无非空 `reasoning_content` 时取
`reasoning`(vLLM/OpenRouter 新名)。保真模式未知字段:字符串拼接、对象递归、带 `index` 的
数组按 index 合并、`index/type/id/format` 同值幂等;冲突时**删除该字段**并按(源, 路径)
告警一次——结果可能缺字段,以告警为准。诚实边界:中转在上游出错后主动正常关闭连接与真
完整在传输层不可区分,防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
遥测 `llm_calls` 追加可空列 `stream_end`;`TelemetryRecorder.record_llm_call` 新增必填
keyword `stream_end`(**自建 recorder 须跟进,否则装配期被形状闸拒绝**)。
## 许可证
PolyGateway 使用 [Apache License 2.0](LICENSE)。内部移植代码的原创 owner 已明确授权;
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "polygateway"
version = "1.3.9"
version = "1.3.10"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
+21 -3
View File
@@ -413,7 +413,7 @@ flowchart TB
| 值 | 含义 | 生产者 | cost |
| --- | --- | --- | --- |
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1;1.3.10 起即 `stream_end="salvaged"`) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
@@ -470,7 +470,9 @@ flowchart TB
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
| HTTP 400 | `RequestRejectedError` |
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) |
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应)。1.3.10 起仅限**零产物**或无法区分抖动与结局的情形;上游明确的 length/过滤结局见 §15 |
| finish_reason 为 `network_error`/`error`(上游自报失败,1.3.10) | `TransientError` |
| 文本模式 length/过滤且无正文;保真模式 length 截断的半截工具(1.3.10) | `ResultInvalidError`(确定性结局,不重试、不计熔断) |
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
@@ -566,7 +568,7 @@ flowchart TB
### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范;当前 38 个 INSERT 字段,物理表列 39 = 38 + 数据库自填的 `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**、**stall_ms**、**stall_count**。
**必录字段**(继承三项目 15 字段规范;当前 39 个 INSERT 字段,物理表列 40 = 39 + 数据库自填的 `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**、**stall_ms**、**stall_count**、**stream_end**(1.3.10,§15)。
**`stall_ms`/`stall_count` 两列(2026-09-11,1.3.8,issue #27,端口 36 → 38)**: 沉默归因进库,口径见 §7.2 净口径段。取 INTEGER 而非 REAL——落库值是 retry 层已取整的 int,与换算唯一处纪律(N8)一致。追加在 `total_latency_ms` 之后,可空无默认、旧行 NULL 不回填;`emit_cache_hit`/`emit_terminal_failure` 与失败/取消 attempt 行恒 NULL(缓存命中与终态行无 transport 观测;被取消的对冲输家无 TransportResult,其 `hedge_cancelled` 行 stall 恒 NULL,存在性由该行表达)。`generation_ms` **仍不进遥测列**(YAGNI)——墙钟由 `generation_ms + stall_ms` 还原。
@@ -806,3 +808,19 @@ src/polygateway/
| 迁移 | 旧 text 污染无法自动判断,受影响集合必须迁移 namespace/salt;新模式天然隔离;无新遥测列/表,统一 helper 与取消结算不变 |
本增量不会执行工具;length 结束原因与参数原样交回,由下游判断可执行性。嵌套 JSON 是独立可变快照,不承诺深冻结。许可已由原创 owner 明确批准 Apache-2.0。
## 15. 统一完成判定(2026-10-14,#30/#31 已授权增量)
本节修订 §6.2 空补全行、§7 `usage_source` 映射与 §14 协议/失败/缓存三行,详细边界见 [设计](designs/2026-10-14-issue30-31-completion-verdict-design.md)。
| 接缝 | 增量契约 |
|---|---|
| 根因 | 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单判定成功,上游正常结局落 Transient 被重试放大;本版以上游 `finish_reason` 与传输完整性为准 |
| 内核 | `types.py` 纯函数 `_stream_is_complete` / `_classify_finish` / `_judge_outcome` / `_is_cacheable`,流式、非流式、缓存读写四处共用;只返回判定值,错误由调用点翻译 |
| 流完整性 | `[DONE]` → `done`;无 DONE 但 finish 已到且其同帧或之后的 usage 为实测、干净 EOF → `eof_after_finish`;其余为截断:保真不打捞,文本按 `missing_done` |
| 结局 | 首匹配表:上游错误 finish → Transient;坏工具(length → ResultInvalid);零产物 + length/过滤 → 保真交付、文本 ResultInvalid;零产物其余 → Transient(保 2026-07-20 裁决);有正文交付;有产物无正文按 finish 分模式 |
| 字段 | 五层 null 视缺省;保真未知字段无损合并(字符串拼接、index 数组按 index、标签键幂等),冲突删键 + 有界告警;未知字符串片段计活性 |
| 缓存 | 可缓存 ⊂ 可交付:salvaged、无正文、length/过滤/未知 finish 不写,读到即 miss;判定先于命中覆盖 |
| 公共面 | `LLMResponse`/`TransportResult` 追加 `finish_reason`、`stream_end`;遥测加 `stream_end` 列,`record_llm_call` 加必填参数 |
`usage_source` 与 `stream_end` 分工:前者答"账单可信吗",后者答"内容完整吗"。`eof_after_finish ⇒ measured`;`salvaged ⇒ estimated|unavailable`;`estimated ⇒ salvaged`,逆命题不成立。诚实边界:中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分。
+2
View File
@@ -72,6 +72,8 @@
**已交付增补(2026-09-14)**: 流式中段沉默计量与对冲(issue #27/#28,1.3.8)——`generation_ms` 口径重定义为**净生成时间**(墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部;1.3.7 墙钟口径由 `generation_ms + stall_ms` 还原,披露见 CHANGELOG 未发布段),`CallStats` 增 `stall_ms`/`stall_count` 归因字段,遥测表加同两列(INSERT 字段 36→38);`{SCOPE}__HEDGE__STALL_AFTER_S` 中段沉默对冲(独立于 `HEDGE__AFTER_S` 生效、默认关闭;无业界公开先例,超业界现状能力),`{SCOPE}__STREAM__STALL_THRESHOLD_MS` 计量阈值(默认 1000,三层可调)。设计 designs/2026-09-11-streaming-stall-design.md(人类已批准 + 独立审查补遗 B1-B4/N1-N9),实施 plans/2026-09-11-streaming-stall.md,验证 findings/2026-09-11-streaming-stall-validation.md。后续储备:自适应分位阈值(防反馈环,现不做)、流式透传下的 commit 点重审、embedding/OCR 对冲(现不做)。
**已交付增补(2026-10-15,1.3.10 已发布)**: 统一完成判定(issue #30/#31,1.3.10)——以上游 `finish_reason` 与传输完整性判定流完成与结局,流式/非流式/缓存读写共用内核纯函数;缺 `[DONE]` 但 finish+实测 usage 完整即成功(两模式),文本 length/过滤无正文改 `ResultInvalidError`,保真模式 null/未知字段无损合并;`LLMResponse` 增 `finish_reason`/`stream_end`,遥测加 `stream_end` 列。设计 designs/2026-10-14-issue30-31-completion-verdict-design.md(人类授权 Claude 与独立 subagent 两轮审查后直接执行),实施 plans/2026-10-14-issue30-31-completion-verdict.md。后续储备:文本 n>1 混流修复、文本"只有思考+stop"证据收集。
## 7. 开放决策依赖
| 决策 | 阻塞点 | 需拍板时间 |
@@ -7,6 +7,8 @@ date: 2026-10-01
# #29 OpenAI 消息保真设计
> **修订指针(2026-10-14)**: §4.1"未知 delta 拒绝"、§4.2"reasoning-only 不算成功"与"缺 DONE 一律失败"、§5 活性口径、§6 缓存判据已由 [统一完成判定设计](2026-10-14-issue30-31-completion-verdict-design.md)(1.3.10)修订,以后者为准。
> 状态:方向已获人类批准;计划独立审查 P1/P2 已修订并实施,首轮独立代码审查发现的缓存空产物及测试双向依赖已修复,等待父会话最终复核;按人类最终指定准备 1.3.9,取代未发布的 1.4.0;尚未发布。
> 基线:`0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8;日期:2026-10-01。
> 需求:[issue #29](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/29)。
@@ -0,0 +1,251 @@
---
type: design
node_id: design:2026-10-14-issue30-31-completion-verdict-design
title: 统一完成判定设计(#30/#31)
date: 2026-10-14
---
# #30/#31 统一完成判定设计
> 状态: 方向已获人类批准(2026-10-14,四项取舍与五个拍板点按推荐);人类授权"Claude 与独立 subagent 确认无误后直接执行",不再走人类设计审阅。两轮独立审查已闭合(R1 3 阻塞 + 9 应修;R2 通过,2 应修已并入),见 §11。
> 基线: `22609c9`(1.3.9);目标版本 **1.3.10**;分支 `fix/issue30-31-completion-verdict`。
> 需求: [#30](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/30)、[#31](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/31)。
> 修订对象: [1.3.9 保真设计](2026-10-01-issue29-openai-fidelity-design.md) §4.1/§4.2/§5/§6、ARCHITECTURE §6.2/§7/§14。
## 1. 问题与根因
两条 issue 是**同一根因**: 库判定"生成结束了、结果可交付"时,依赖库自己的推断(`[DONE]` 存在、正文非空、字段在白名单内、快照字段类型),而不是上游的权威声明(`finish_reason`)与传输完整性(干净 EOF + 结尾 usage)。推断失败一律落 `TransientError` → 重试/换源/计熔断,把确定性结局放大成 `max_attempts` 次付费重抽并推高健康源的熔断计数。
| 表现 | 位置(1.3.9) | 后果 |
| --- | --- | --- |
| #31 缺 `[DONE]` 但 finish+usage 完整 | `openai_compat.py:668-671`(保真无条件拒);`_check_done:714`(文本 `missing_done=retry` 默认同样拒) | magpie/glm 两模式均重试到耗尽;实测 1216s 挂起、dissect3 38 次终败 |
| #30-1 reasoning-only + `length` | `types.py:478-490`、`_reject_empty_completion` | 每次写满 max_tokens 后丢弃重抽 |
| #30-2 未知键 / `tool_calls:null` | `_openai_completion.py:97-98,112-114,139-140,146-147`;**非流式也中**: `OpenAIChatCompletion` 拒 `tool_calls: None`(`types.py:451-453`,已实测) | 渠道一换 100% 失败,且以"瞬时"形态出现 |
| 隐患: 只有 `reasoning` 字段的流 | 活性只认 content/reasoning_content | 两模式被看门狗当无进展 → TTFT 超时 |
| 隐患: 文本 salvage 的截断结果被缓存 | `cache.py:197-204` 写路径无判定 | 半截结果 TTL 内反复回放 |
补充事实: GLM 的 finish_reason 含 `network_error`/`sensitive`;2026-07-20 空补全裁决针对的是 stop/无 finish 的"零增量"抖动(`findings/2026-07-21-m25-acceptance.md:48`);vLLM 等实现每帧带累计 usage,finish 之前的 usage 不证明走完。
## 2. 方案比较
| 方案 | 内容 | 结论 |
| --- | --- | --- |
| A 局部放行 | 按 issue 字面在三处各加例外 | 否决: 流式/非流式/缓存判据继续分叉;文本 #31 残留;`network_error` 被当成功 |
| B 统一完成判定(选定) | 内核纯函数分两步(流完整性 → 结局分类),流式/非流式/缓存读写四处共用;"可交付"与"可缓存"拆两判据;未知字段无损合并 | 一次消除根因;代价是公共字段追加与遥测端口加参 |
| C 透明透传 | 保真模式不做结局判定,原样交回 | 否决: 零增量抖动回归(违背 2026-07-20 裁决),截断流也被交出 |
## 3. 公共契约变更(只增不改)
| 位置 | 变更 |
| --- | --- |
| `types.py` | 新增 `StreamEnd = Literal["done", "eof_after_finish", "salvaged"]`,包根导出 |
| `TransportResult` / `LLMResponse` 末尾 | 追加 `finish_reason: str \| None = None`、`stream_end: StreamEnd \| None = None` |
| `OpenAIChatCompletion` 校验 | `tool_calls: null` 合法(视同无工具,原样保留键) |
| `TelemetryRecorder.record_llm_call` | 追加**无默认值** keyword-only `stream_end: str \| None`(**破坏性**: 自建 recorder 装配期被形状闸拒绝,CHANGELOG 单列) |
| `llm_calls` 表 | 末尾追加可空列 `stream_end TEXT`(落点见 §7) |
| `Transport.complete` 签名 | **不变** |
`finish_reason` 取值: 流式取 choice 0(保真已强制单 choice;文本取 `index` 缺省或为 0 的 choice)首个非 null 值;非流式取 `choices[0].finish_reason`。保真模式非字符串维持 1.3.9 拒绝(Transient);文本模式非字符串记 None。`finish_reason` 不进遥测列(未获批)。
| 场景 | `stream_end` |
| --- | --- |
| 流式收到 `[DONE]` | `"done"` |
| 干净 EOF + finish + 结尾 measured usage | `"eof_after_finish"` |
| 文本 `missing_done=salvage` 打捞 | `"salvaged"` |
| 非流式 / 缓存命中 / 失败行 | `None`(与 `ttft_ms`/`stall_ms` 同"无从观测"口径) |
与 `usage_source` 分工: 后者答"账单可信吗",前者答"内容完整吗"。`eof_after_finish ⇒ measured`(由 §4.2 条件保证);`salvaged ⇒ estimated|unavailable`;`estimated ⇒ salvaged`,逆命题不成立。
## 4. 统一完成判定
### 4.1 位置与形态
全部放 `types.py` 内部纯函数(下划线前缀,不导出): CacheMW 要复用,import-linter 禁止 middleware 依赖 transports,而内核层 `ports : types : errors : streaming` 允许。函数只返回判定值,**不抛领域错误**(错误需 `source_name` 等上下文,由调用点翻译),不捕获任何异常,取消天然穿透。
| 函数 | 职责 |
| --- | --- |
| `_tool_snapshot_ok(message)` | 工具快照是否成立(id/type/function.name/arguments 规则 + 唯一 id);`OpenAIChatCompletion.__post_init__` 复用 |
| `_classify_finish(value)` | 映射到 finish 类别(下表) |
| `_stream_is_complete(...)` | 第一步,返回 `StreamEnd \| None`(None = 截断) |
| `_judge_outcome(...)` | 第二步,返回 `deliver` 或 `(reject_transient \| reject_result, 原因码)` |
| `_is_cacheable(...)` | §6 判据 |
| finish 类别 | 值(大小写敏感) |
| --- | --- |
| natural | `stop`、`tool_calls`、`function_call` |
| truncated | `length` |
| filtered | `content_filter`、`sensitive` |
| upstream_error | `network_error`、`error` |
| unknown | 其余非空字符串: 按 natural 交付、**不可缓存**、按源+值告警一次(文案写明"该值不缓存",便于下游定位命中率下降);`end_turn`/`stop_sequence` 等无证据不预先归类 |
| absent | None |
### 4.2 第一步: 流完整性(仅流式)
| 条件 | 结果 |
| --- | --- |
| 读取异常 / 活性超时 / error 帧 / 畸形帧 | 照旧 Transient(不动) |
| 收到 `[DONE]` | `done` |
| 干净 EOF、无 DONE、finish 已见、finish 帧**同帧或之后**收到的 usage 被 `_resolve_usage` 判为 measured | `eof_after_finish`,usage 记 measured(不降级) |
| 干净 EOF、其余情形 | 截断: openai → Transient `missing_done`(仍不打捞);text → 原 `_check_done` 语义(零产物 early_eof、`retry` 抛、`salvage` 记 `salvaged`) |
聚合器每尝试记录 `finish_seen` 与 `usage_after_finish`: 收到非 null usage 时,若 `finish_seen` 已真或同帧带 finish,则置后者为真;是否 measured 以**最后一个**非 null usage(与计费所用 `sink["usage"]` 同一对象)在流结束时判定;usage-only 帧(`choices: []`)在 finish 之后到达是常规形态,正确置位。状态随重试/对冲每尝试新建。
诚实边界: 中转站在上游出错后主动正常结束响应,与真完整在传输层不可区分;防线是"finish 必须存在、不属 upstream_error、且其后有 measured usage",文档如实写明。
### 4.3 第二步: 结局分类
产物定义——**保真模式**: 任何产物 = 消息中任一非空字符串片段(content、reasoning_content、reasoning、refusal、合并保留的未知字符串,空白也算)或至少一个非 null 工具条目;**文本模式**: 任何产物 = 投影 content 或 thinking(含 §4.4 别名)非空串(含空白),未知字段与工具增量只计活性不计产物,`_check_done` 零产物判据同口径。两模式**正文产物** = 投影 content strip 后非空,或(仅保真)工具快照成立且非空。
表格**自上而下取首个匹配行**:
| # | 条件 | openai 模式 | text 模式 |
| --- | --- | --- | --- |
| 1 | finish 为 upstream_error | Transient `upstream_finish_error` | 同左 |
| 2 | (仅保真)存在工具条目但 `_tool_snapshot_ok` 不成立 | truncated → ResultInvalid `tool_truncated`;其余 → Transient `tool_incomplete` | 不适用 |
| 3 | 无任何产物 + truncated/filtered | 交付(#30;隐藏推理模型写满预算的形态) | ResultInvalid `length_without_content` / `filtered_without_content` |
| 4 | 无任何产物 + natural/unknown/absent | Transient `empty_completion`(保 2026-07-20 裁决) | 同左 |
| 5 | 有正文产物 | 交付 | 交付 |
| 6 | 有产物无正文 + truncated | 交付(#30) | ResultInvalid `length_without_content` |
| 7 | 有产物无正文 + filtered | 交付 | ResultInvalid `filtered_without_content` |
| 8 | 有产物无正文 + natural/unknown | 交付 | Transient `empty_completion`(待证据;消息带 finish 与 thinking 长度) |
| 9 | 有产物无正文 + absent(流式仅 DONE 收尾,或非流式 body finish 为 null) | Transient `empty_completion` | 同左 |
ResultInvalid 理由: `retry.py:409-414` 对其"不重试、不换源、熔断记成功不计窗口";length/过滤是预算或内容策略的确定性结局,不是源坏了。对冲下原路 ResultInvalid 等对冲路裁定,两败原样上抛(`retry.py:548-560`);StructuredMW 只捕 parse 抛出的 ResultInvalid,不会误重问(`structured.py:79`)。已知代价: 该分支 TPM 全额退款、用量不落成本(`actual=0`),与 Transient 丢成本同级,登记为已知边界。文本 length 改抛 ResultInvalid 后不再经 `AllSourcesExhausted` 包装,§10 要求核对下游 catch 点。
原因码以固定格式 `"{源} {code}: …"` 写进异常 message,从而进遥测 `error` 列(LIKE 可检索);不新增列、不改 `_failure_reason`(Transient 一族熔断原因记 `network_error` 属已知口径)。
保真模式行 2 之外的工具快照成立 + `length`: 交付,arguments 可能是半截 JSON——docstring 与 CHANGELOG 提醒调用方先查 `finish_reason` 再执行工具。非流式跳过第一步,直接以 body 的 finish_reason 走第二步。
### 4.4 文本模式 reasoning 别名
投影 `thinking`: 本次尝试出现过**非空** `reasoning_content` 则只用它,否则用 `reasoning`。保真 `completion.message` 保留上游原键不改名。两模式活性、产物摘要、`thinking_observation` 均基于该投影。
## 5. 字段合并与活性
### 5.1 null 与已知字段
null 规则**优先于**已知字段类型校验: chunk、choice、delta、工具条目、function 五层任意键值为 null 视为缺省,数组中的 null 元素(如 `tool_calls: [null]`)跳过;键从未出现时保留 `key: null`(含 `tool_calls`、`refusal`、`role`、`function.arguments`)。保真成功仍须最终 role=assistant(全程只有 `role: null` 即缺 role,照旧失败)。其余已知字段(content/reasoning_content/role/tool_calls 及工具 id/type/function.name/arguments)非 null 时语义逐字沿用 1.3.9。
### 5.2 未知字段(delta、`tool_calls[]` 条目、`function` 三层统一)
| 值 | 规则 |
| --- | --- |
| 标签键 `index`/`type`/`id`/`format`(任意未知层级) | 同值幂等,不拼接;不同值 = 冲突 |
| 其他字符串 | 按到达顺序拼接(OpenAI SDK 流式累加惯例) |
| 数字/布尔 | 同值幂等;不同值 = 冲突(不学 SDK 相加) |
| 对象 | 递归套用本表 |
| 数组且元素均为带整数 `index` 的对象 | 按 index 合并(`reasoning_details` 一类);合并策略由该键首次出现形态决定,之后形态改变 = 冲突 |
| 其他数组 | 追加 |
| 冲突 | 删除该键路径,本尝试内不再接收;按 (源, 归一路径) 告警一次 |
冲突删键是有损的: docstring 与 CHANGELOG 写明"可能缺字段,以告警为准"。上游方言确定,重拉同样结果,不视为缓存毒化。
### 5.3 活性
已知字段活性逐字沿用 1.3.9(非空 content/reasoning_content、工具 name/arguments 非空增量、首次建立工具身份)。未知字段仅当本帧在非标签键上并入非空字符串片段(含追加型数组中的字符串元素)时算进展(`reasoning` 由此计入);同帧只记一次。残余风险如实登记: 上游每帧重复下发的非标签字符串(如 `status`)会被拼接并持续刷新看门狗。文本模式同口径。README 中"增量 chunk = content 或 reasoning_content"的 stall/TTFT 口径表述同步更新为本节口径。
### 5.4 告警节流
节流集合放 transport 实例(与 `_warned_mismatches` 分立,纯 asyncio 中立),键为 (源, 归一路径) 与 (源, 未知 finish 值):路径中数组下标归一为 `[]`,finish 值截断至 64 字符;集合上限 1024,饱和后记一次"告警已饱和"不再增长。日志只写源名与路径,不回显值(可能含工具参数)。聚合器以只读集合交出冲突路径,transport 负责告警。
## 6. 可交付 vs 可缓存
可缓存 ⊂ 可交付。两模式同一判据 `_is_cacheable`,读写共用:
| 条件(任一成立即不可缓存) | 理由 |
| --- | --- |
| `stream_end == "salvaged"` | 截断结果,修现存毒化 |
| 无正文产物 | 不把空回答固化进 TTL |
| finish 类别为 truncated/filtered/unknown | 截断有随机性,同 key 换时可成功;未知值不可证明是自然结束 |
| openai 模式 completion 原文 content 为空且无工具(沿 1.3.9) | 不信任旧投影 |
| 路径 | 规则 |
| --- | --- |
| 写 | 不可缓存 → 跳过,debug 日志 |
| 读 | `_is_cacheable` 在命中覆盖(`cache.py:224` `fields.update`)**之前**、对存储原值判定;不可缓存 → miss + warning |
| 读: 非法 `stream_end` | 不在 Literal 域内 → miss + warning(关乎内容完整性,故与 `_coerce_observation` 的宽容方向相反) |
| 读: 顶层无 finish_reason | 回退 `completion.finish_reason`(1.3.9 保真 length 条目因此可识别);文本旧条目两字段缺省 → None → 仍可命中 |
| 命中返回 | `stream_end=None`;`finish_reason` 取判定所用值(顶层缺省时为回退值),非字符串视 miss |
| 序列化 | `_RESPONSE_FIELDS` 由 `dataclasses.fields` 自动派生,无需改动 |
旧 salvage/length 文本污染无法辨认: CHANGELOG 继续要求受影响集合迁移 namespace/salt。
## 7. 遥测落点
| 位置 | 变更 |
| --- | --- |
| `middleware/telemetry.py` `_AttemptUsage.of(response)` | 读 `response.stream_end`(本次 LLMResponse 有字段,不像 1.3.8 走 `emit_attempt` kw,避免约 50 处调用点) |
| `_record` | 新增参数,单一 helper 下沉 recorder |
| `emit_cache_hit` / `emit_terminal_failure` / 失败与取消 attempt 行 | 显式 None |
| `telemetry/schema.py` | SQLite/PG 两份 CREATE 末尾、`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS`(列序对齐)、`COLUMNS`;`telemetry_schema_sql()` 随之输出 |
| sqlite/postgres recorder | 按 `COLUMNS` 写入;auto 档幂等补列、manual 档裁剪写入并点名缺列(沿 1.3.8) |
| tests | 约 20 处测试替身 `record_llm_call` 补参数 |
| 文档 | README "必录字段数"按 `inspect.signature` 实测更新;`schemas/llm-calls.md` 加列并修正陈旧计数 |
## 8. 旧行为审计
| 既有行为 | 处置 |
| --- | --- |
| 保真缺 DONE 一律失败 | **替换**: §4.2 判完整时成功;其余仍失败、仍不打捞 |
| 文本 `missing_done=retry` 遇缺 DONE 即抛 | **替换**: 同上;`missing_done` 只管真截断 |
| 文本 salvage + estimated | 保留,另标 `salvaged`,不再写缓存 |
| 零增量 + DONE → Transient | **保留**(`test_stream_zero_content_with_done_is_transient` 红线回归) |
| 保真 reasoning-only/空产物失败 | **替换**: §4.3 行 3/6/7/8 |
| 未知 delta/工具/function 字段拒绝;null 已知字段拒绝 | **替换**: §5 |
| 非流式 `tool_calls: null` 拒绝 | **替换**: 合法 |
| 多 choice/index≠0 拒绝、最终 role 必须 assistant、error 帧立即失败 | 保留 |
| 工具快照强校验 | 保留;仅 length 截断时改归 ResultInvalid |
| finish_reason 冲突拒绝(Transient) | 保留(登记: 确定性方言,将来可改 ResultInvalid) |
| 已知字段活性口径 | 保留;未知字段按 §5.3 增补 |
| 缓存写路径无判定 | **替换**: 按 `_is_cacheable` |
| 文本 n>1 混流 | 不在范围,另开 issue |
## 9. 非功能维度
| 维度 | 回答 |
| --- | --- |
| 并发与取消 | 新状态在每尝试聚合器/sink 内;判定为纯函数,不新增 try/except,`CancelledError` 原路穿透;finally 结算与 permit 释放不变 |
| 降级方向 | 缓存跳写/视 miss 只 debug/warning;遥测新列缺失按 manual 档裁剪 + 告警,不冒泡;限流/熔断不涉及 |
| 幂等与重复 | 减少重复付费重抽;标签键与身份字段幂等;告警节流有界 |
| 持久化与原子性 | 仅新增一可空列,补列沿既有探测后 ALTER 幂等流程;缓存仍成功后单次原子 set |
| 错误分类 | 仅落 Transient 与 ResultInvalid 两类,不新增类;原因码见 §4.3 |
| 可观测 | `stream_end` 字段 + 遥测列;冲突路径与未知 finish 有界告警;遵 structured-logging 更新 `schemas/llm-calls.md` |
## 10. 测试、文档与交付
全部用假 SSE / MockTransport 复现,每条先红后绿;真实中转冒烟标 slow 按发布清单 diff 交集跑。
| 组 | 用例 |
| --- | --- |
| #31 验收 | 1 stop+usage+EOF 成功、`eof_after_finish`、measured、attempts=1;2 tool_calls 同上工具完整;3 无 finish 即关 → 失败;4 finish 无 usage → 失败;5 中途 `RemoteProtocolError` → 失败;6 带 DONE → `done` 不变 |
| #31 补充 | usage 早于 finish(累计 usage)+EOF → 不完整;finish 后 usage 为 `{}` → 不完整;`network_error`+usage+EOF → Transient;文本模式 1/3/4(`retry`、`salvage` 两档) |
| #30 验收 | reasoning-only+length → 交付、content 空、思考完整、attempts=1;流式 `refusal:null`+`tool_calls:null` 成功;非流式 message 带 `tool_calls:null`+`refusal:null` 成功 |
| 结局表 | §4.3 每行至少一例,两模式、流式与非流式;文本 length → ResultInvalid 且熔断未计失败;零增量+DONE(带 stop/不带 finish)仍 Transient;content 非空 + 坏工具 + stop → Transient |
| 合并/活性 | 只有 `reasoning` 刷新活性不超时;二次构造 OpenRouter `reasoning_details` 往返(`format`/`id` 不拼接);工具层嵌套对象原样保留可回传;数值冲突删键、告警一次、调用成功;节流饱和;`role:null`/`arguments:null` 视缺省 |
| 缓存 | 四类不可缓存均不写、注入后读视 miss;非法 `stream_end` 视 miss;1.3.9 旧文本条目仍命中;1.3.9 保真 length 条目经回退识别为不可缓存;命中 `stream_end=None` |
| 遥测 | sqlite/pg(integration)新列写入、旧库补列、manual 档裁剪;形状闸拒绝缺参 recorder |
| 改写 | `test_openai_never_salvages`;`test_invalid_delta_is_domain_error` 中 `{"audio": {}}`、`{"index":0,"extra":1}`、null 已知字段参数;提交说明列出 |
同步: 源码 docstring、README(能力表、`1.3.*` 安装约束核对、活性口径、字段数)、CHANGELOG 1.3.10(行为变更段、破坏性段: recorder 端口、namespace/salt 提醒、半截工具参数提醒、冲突删键提醒)、ARCHITECTURE §6.2/§7/§14、1.3.9 设计加修订指针、`schemas/llm-calls.md`、`research-wiki/migrations/` 三份核对下游对 ResultInvalid 的 catch 点、`docs-convention.md` §2 清单。另开 issue: 文本 n>1 混流;文本"有产物无正文 + stop"证据收集。发布按 CLAUDE.md §4.4.1 九步,#30/#31 在 registry 可安装且验收证据齐备后关闭。
证据: `retry.py:404-450,548-560`;`cache.py:197-236`;`structured.py:79`;[openai-python `_streaming.py`](https://github.com/openai/openai-python/blob/main/src/openai/_streaming.py) 与 `lib/streaming/_deltas.py`;[vLLM reasoning outputs](https://docs.vllm.ai/en/latest/features/reasoning_outputs/);GLM finish 枚举来自第三方镜像,实现前以真实中转探针再核;Gemini `extra_content` 路径未核实原文,测试按通用嵌套对象构造。
## 11. 审查修订记录
| 轮次 | 意见 | 处置 |
| --- | --- | --- |
| R1 | B1 `tool_calls:null` 被快照拒(非流式现存) | 采纳: §3/§5.1/§10 |
| R1 | B2 结局表行重叠 | 采纳: 首匹配行序 + `_tool_snapshot_ok` 前置 |
| R1 | B3 文本"任何产物"不可计算 | 采纳: §4.3 分模式定义 |
| R1 | S1 eof_after_finish 但 usage 非 measured | 采纳: 条件加 measured |
| R1 | S2/S3 活性冲突与标签拼接 | 采纳: §5.2/§5.3 |
| R1 | S4 告警集合无界 | 采纳: §5.4 |
| R1 | S5 无产物 + length 与 #30 原文冲突 | 采纳(人类授权代决): 保真交付、文本 ResultInvalid、stop/absent 仍 Transient |
| R1 | S6-S9 缓存顺序、遥测落点、原因码、null 优先 | 采纳: §6/§7/§4.3/§5.1 |
| R1 | 建议 1-7 | 采纳 1/2/3/5/6/7;建议 4 不标不可缓存(上游确定性,非毒化) |
| R2 | 结论通过;N1 measured 以最后 usage 判定、N2 非空 reasoning_content | 采纳: §4.2/§4.4 |
| R2 | 建议 1-4: 命中回退值、未知 finish 告警文案、数组字符串活性与残余风险、null 五层与数组 null 元素 | 全部采纳: §6/§4.1/§5.3/§5.1 |
| 代码审查 | I1 保真可缓存须同时要求投影正文非空(think 标签剥净后为空不缓存) | 采纳: `_is_cacheable` |
| 代码审查 | I2 无参函数 `arguments` 全程 null → 快照不成立被判 Transient 重试 | 人类授权代决: 归 ResultInvalid `tool_arguments_null`(确定性方言);不改写交付值(改写破坏保真,重试救不回) |
| 代码审查 | I3 文本 `index:null` 整帧丢弃、`index:False` 冒充 0 | 采纳: null 视 0,bool 拒 |
| 代码审查 | Minor: `[null]` 产出空 `tool_calls`、非流式缺 role 先于判定、告警路径长度有界、README 表述 | 采纳;index 数组缺 index 整键删除登记于 CHANGELOG 提醒 |
+4
View File
@@ -202,3 +202,7 @@ stack = ExtractionProviderStack(
| **AIMD 自适应并发**(库常量: 每源初始 8、429 削减 ×0.5、成功 +1/limit、下限 1、ceiling=max(64, 源级 max_concurrency)) | 冷启动每源并发从"无上限"变为 8 起步爬升——高吞吐下游首分钟吞吐低于旧行为;无 env 开关(韧性常量,同 jitter 系数先例),需要禁用者构造函数注入自定义 pacer |
| 连败通道受健康证据抑制(窗口样本 ≥ min_calls 且失败率 < fail_rate 时 5 连败不开路) | CHS "连败必开"在高流量健康源上不再成立(防随机噪声误熔唯一好源);冷启动/低流量语义不变 |
| 结构化重问缺省 1→2 | 解析失败时最多多一跳成本;`PGW_STRUCTURED_MAX_RETRIES=1` 可显式还原 |
## 1.3.10 影响核对(2026-10-14,#30/#31)
CHS 已把 `ResultInvalidError` 作终态(坏结果≠坏服务,熔断记成功),1.3.10 文本 length/过滤且无正文落该分类与既有语义同向,worker `_TERMINAL` 分支无需改;缺 `[DONE]` 而完整的流从此直接成功。
+4
View File
@@ -170,3 +170,7 @@ loop = AgentLoop(client, max_steps=...,
| R7 | 内存限流在多进程(arq)下的口径须在文档/配置中显式警示(B7 过渡期语义),避免误配全局限额 | M1 |
以上 G1/G2 若按本文方向解决,GovDoc 全量迁移中业务代码(agent/workflow/govdoc api)对治理层的消费**零改动**,改动收敛于: 装配净新增、types.py/protocols.py/test_imports.py 三处改写、`.env` 改键。
## 1.3.10 影响核对(2026-10-14,#30/#31)
装配处 `retryable_exceptions=(TransientError, AllSourcesExhausted)`(§4)**不含** `ResultInvalidError`:文本 length/过滤且无正文改抛它后,AgentLoop 步级重试**不再**重抽该步——这正是本次修复的意图(确定性结局重抽只花钱)。业务侧若想对 length 截断做"加大 max_tokens 再试",须显式 except `ResultInvalidError` 并按异常消息原因码 `length_without_content` 分支;其余缺 `[DONE]` 而完整的流(#31)从此直接成功。
@@ -200,3 +200,7 @@ ocr = FrameOcrAdapter(OcrTextPort_from_env()) # 业务适配器包装库 OCR 端
---
*不确定项声明*: 库侧 `from_env(role=...)`、OCR 装配工厂的具体 API 名称以 M1/M3 设计文档定稿为准,本文 §5.2/§6 中相应伪代码届时同步修订;`telemetry.db` 是否存在项目外的离线分析脚本消费方未能实测(项目内 grep 无读取方)。
## 1.3.10 影响核对(2026-10-14,#30/#31)
AgentLoop 注入的 `(TransientError, AllSourcesExhausted)`(§4 点 3)**不含** `ResultInvalidError`:文本 length/过滤且无正文改抛它后,步级重试不再重抽该步(意图如此)。需要"加大 max_tokens 再试"的业务须显式 except 并按原因码 `length_without_content` 分支。§保留项 3"缺 `[DONE]` 判瞬时、不写缓存"在 1.3.10 收窄为"无 finish 或 finish 后无实测 usage 才算截断";finish+实测 usage+干净 EOF 视完整。
@@ -0,0 +1,230 @@
---
type: plan
node_id: plan:2026-10-14-issue30-31-completion-verdict
title: 统一完成判定实现计划(#30/#31)
date: 2026-10-14
---
# #30/#31 统一完成判定实现计划
**目标**: 按[设计](../designs/2026-10-14-issue30-31-completion-verdict-design.md)(下称"设计")实现统一完成判定,修复 #30/#31 并发布 1.3.10。
**方案概述**: 在 `types.py` 内核放一组纯判定函数(流完整性 → 结局分类 → 可缓存),transport 两条路径、CacheMW 读写共用;聚合器改为 null 优先 + 未知字段无损合并;`LLMResponse`/`TransportResult` 追加 `finish_reason`/`stream_end`,遥测加 `stream_end` 列。
**技术**: Python 3.12、httpx、pydantic、pytest(asyncio)、SQLite/PG 遥测、Redis 缓存。环境 `conda run -n PolyGateway`。
本计划不涉及参考实现迁移,保真校验不适用。
## 0. 执行纪律
- 分支 `fix/issue30-31-completion-verdict`。每个任务一次提交,经 `commit` skill;pre-commit 钩子会跑全套件 + ruff + "改动触及函数复杂度 < C",故**每个提交点必须全绿**,改到的既有高复杂度函数(`add_chunk` F42、`_complete_stream`/`_complete_once` C、`_rehydrate` C、`OpenAIChatCompletion.__post_init__` D)须在同一提交内拆到 B 及以下;钩子(2026-10-14 经人类同意改为只拦"本次改动触及"的块,存量 ≥C 降为告警,提交 5d2ad40)按 radon 块判定,**类块**(`ChatCompletionAccumulator` C16、`OpenAIChatCompletion` D24)同样须降到 B 及以下。不改 `OpenAICompatTransport.complete()`(C14),新增逻辑一律放在拆出的 helper 中。
- 每个任务的统一验证(与钩子一致): `conda run -n PolyGateway pytest tests/ -q`(含 integration,本地有 Redis/PG 时真跑)+ `conda run -n PolyGateway ruff check src` + `conda run -n PolyGateway radon cc -n C <本任务改动文件>`(输出必须为空,或仅含本任务未触及的存量块)。各任务列出的子集命令只作开发期快速回路。
- 测试结果门: 每个任务的新增/改写测试先在未改实现的代码上运行并记录**失败**输出(粘贴进提交说明正文的"Red:"一行,写明失败用例名与断言),再实现至通过。
- 后台长跑命令末尾禁止接管道(CLAUDE.md §3)。
## 1. 文件结构
| 文件 | 职责变化 |
| --- | --- |
| `src/polygateway/types.py` | 新增 `StreamEnd`、`FinishClass`、`_OutputSummary`、`_Verdict` 与七个纯函数(§2 列出;另 `_effective_finish`);`TransportResult`/`LLMResponse` 末尾追加两字段;`OpenAIChatCompletion` 接受 `tool_calls: None` 并复用 `_tool_snapshot_ok` |
| `src/polygateway/__init__.py` | 导出 `StreamEnd`;`__version__` → 1.3.10(T6) |
| `src/polygateway/transports/_openai_completion.py` | 聚合器: null 优先、未知字段合并、标签键、冲突路径、finish/usage 位置记录、reasoning 别名;`parse_chat_completion` 容忍 null |
| `src/polygateway/transports/openai_compat.py` | 两条路径改走判定;有界告警节流;`finish_reason`/`stream_end` 填充;投影别名 |
| `src/polygateway/middleware/retry.py` | `_build_response` 搬运两字段 |
| `src/polygateway/middleware/cache.py` | 写/读按 `_is_cacheable`;`stream_end` 校验;finish 回退 |
| `src/polygateway/ports.py` | `record_llm_call` 追加 `stream_end` |
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage.stream_end`、`_record` 搬运、两 emit 显式 None |
| `src/polygateway/telemetry/schema.py`、`sqlite.py`、`postgres.py` | 新列 + 补列 |
| tests | 新增 `tests/unit/test_completion_verdict.py`;改写/扩充 `test_openai_completion.py`、`test_openai_fidelity.py`、`test_openai_compat.py`、`test_cache.py`、`test_telemetry.py`、`test_types.py`、`test_ports.py`、`tests/integration/test_postgres_telemetry.py` 及各 `record_llm_call` 替身 |
| 文档 | README、CHANGELOG、ARCHITECTURE §6.2/§7/§14、1.3.9 设计修订指针、`schemas/llm-calls.md`、`migrations/` 三份、pyproject 版本 |
## 2. 跨任务接口(T1 定义,后续任务只消费)
```python
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
def _classify_finish(value: str | None) -> FinishClass: ...
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool: ...
def _stream_is_complete(
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
) -> StreamEnd | None: ...
```
```python
@dataclass(frozen=True)
class _OutputSummary:
any_output: bool # 设计 §4.3 "任何产物"
body_output: bool # 设计 §4.3 "正文产物"
tools_present: bool # 至少一个非 null 工具条目(仅保真有意义)
tools_ok: bool # tools_present 时 _tool_snapshot_ok 结果;无工具恒 True
@dataclass(frozen=True)
class _Verdict:
deliver: bool
error_kind: Literal["transient", "result_invalid"] | None # deliver 时为 None
code: str | None # 设计 §4.3 原因码
```
```python
def _summarize_text(content: str, thinking: str) -> _OutputSummary: ...
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary: ...
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict: ...
def _is_cacheable(
*, mode: ResponseMode, content: str, finish_reason: str | None,
stream_end: str | None, completion: OpenAIChatCompletion | None,
) -> bool: ...
```
缓存读写共用(T1 定义,T4 消费),顶层优先、回退 `completion.finish_reason`,避免读写口径分叉:
```python
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None: ...
```
`types.py` 无 `from __future__ import annotations`: `StreamEnd`/`FinishClass` 必须定义在 `TransportResult`/`LLMResponse` 之前。
聚合器对外只读属性(T2 定义,T3 消费): `finish_reason: str | None`、`finish_seen: bool`、`usage_after_finish: bool`、`conflicts: frozenset[str]`(归一路径)、`raw_message() -> dict[str, Any]`(未校验的合并消息深拷贝,含按 index 排序组装的 `tool_calls`,与 `finish()` 同一组装函数,供 `_tool_snapshot_ok` 判定)、`finish() -> OpenAIChatCompletion`(保真快照,校验失败抛 Transient,与 1.3.9 同)。`ChatDelta` 追加字段 `reasoning: str`(别名片段,默认 `""`)。
## 3. 任务
### T1 内核判定函数与公共字段
- 文件: `src/polygateway/types.py`、`src/polygateway/__init__.py`;测试 `tests/unit/test_completion_verdict.py`(新)、`tests/unit/test_types.py`、`tests/unit/test_package.py`(导出)。
- 行为:
- `_classify_finish` 按设计 §4.1 表,大小写敏感;None → `absent`。
- `_tool_snapshot_ok`: `tool_calls` 缺省或 None → True;否则必须是 list,每个非 None 元素满足 1.3.9 规则(非空唯一 id、`type == "function"`、非空 `function.name`、字符串 `arguments`);list 中 None 元素跳过。`OpenAIChatCompletion.__post_init__` 改为调用它(并拆分以降复杂度),`tool_calls: None` 合法且原样保留键。
- `_stream_is_complete`: `done` → `"done"`;否则 `finish_reason is not None and usage_after_finish and usage_measured` → `"eof_after_finish"`;否则 None。
- `_summarize_text`: any = `content != "" or thinking != ""`;body = `content.strip() != ""`;tools_present False、tools_ok True。
- `_summarize_message`: any = 递归遍历 message 中除 `role`、`tool_calls` 外所有值存在非空字符串,或 tools_present;tools_present = `tool_calls` 为含非 None 元素的 list;tools_ok = `_tool_snapshot_ok(message)`;body = `projected_content.strip() != ""` 或 (tools_present 且 tools_ok)。
- `_judge_outcome`: 严格按设计 §4.3 九行首匹配;原因码字面量: `upstream_finish_error`、`tool_truncated`、`tool_incomplete`、`length_without_content`、`filtered_without_content`、`empty_completion`。行 2 仅 `mode == "openai"` 时生效。
- `_is_cacheable`: 设计 §6 四条,任一成立 False;`stream_end` 不在 `StreamEnd` 域且非 None → False;openai 模式且 `completion is None` → False。
- `TransportResult`、`LLMResponse` 末尾追加 `finish_reason: str | None = None`、`stream_end: StreamEnd | None = None`(放在现有最后一个字段之后,不扰动位置构造);docstring 写清 None 语义与 `usage_source` 分工。
- 包根导出 `StreamEnd`;`_has_completion_output` 本任务**保留不动**(T3/T4 迁移后删除)。
- 测试(新文件): `_classify_finish` 全值表参数化;`_stream_is_complete` 真值表(含 usage 早于 finish、usage 非 measured);`_judge_outcome` 九行 × 两模式参数化(每行至少一例,含"正文非空+坏工具+stop → transient tool_incomplete");`_summarize_message` 对 refusal-only、reasoning-only、whitespace content、未知嵌套字符串、`tool_calls: [None]`;`_is_cacheable` 四类否例 + 正例 + 非法 stream_end。`test_types.py`: `OpenAIChatCompletion` 接受 `tool_calls=None`、拒非 list、`[None]` 合法;两新字段默认 None。
- 验证: `conda run -n PolyGateway pytest tests/unit/test_completion_verdict.py tests/unit/test_types.py tests/unit/test_package.py -q` → 全过;`make lint` 无新增问题。
- [ ] 提交 `feat: add kernel completion verdict functions and response fields`
### T2 聚合器: null 优先、未知字段合并、finish/usage 记录
- 文件: `src/polygateway/transports/_openai_completion.py`;测试 `tests/unit/test_openai_completion.py`。
- 保留 `message`、`tools` 实例属性名与语义(`test_openai_completion.py:70,93` 直接依赖)。
- 行为(设计 §5、§4.2):
- 每帧: 校验 chunk 对象;`usage` 非 null 时记录为 `last_usage`,若 `finish_seen` 或同帧 choice 带非 null finish → `usage_after_finish = True`。
- choices: 保真维持单 choice/index 0 校验;文本模式取 `index` 缺省或为 0 的第一个 choice,其余忽略。
- finish_reason: 保真走 `_identity`(非字符串/冲突 → Transient,同 1.3.9);文本模式首个字符串值生效,非字符串忽略。任一模式见到非 null 即 `finish_seen = True`。
- null 五层(设计 §5.1): chunk 层 `choices: null`、choice 层 `delta: null` 视为缺省;例外: 顶层 `error` 键(含 `error: null`)仍由 `_check_chat_error` 按 error 帧拒绝(不动,保 `test_error_before_done_is_failure`)。
- delta 与工具条目、function 三层: 值为 None 的键 → 若目标无此键则 `setdefault(key, None)`(保真),不做类型校验;`tool_calls` list 中 None 元素跳过。
- 已知字段非 None 时逐字沿用 1.3.9 校验与合并(content/reasoning_content 拼接、role 必须 assistant、工具 id/type `_identity`、name/arguments 拼接)。
- 未知字段(delta 层除 role/content/reasoning_content/tool_calls;工具层除 index/id/type/function;function 层除 name/arguments): 交给 `_merge_unknown(target, key, value, path)`,规则见设计 §5.2;标签键集合 `{"index", "type", "id", "format"}`;冲突 → 从 target 删除该键、把归一路径(数组下标替换为 `[]`,如 `delta.reasoning_details[].format`)加入 `conflicts` 与本尝试"封禁路径"集合,后续同路径值丢弃。`_merge_unknown` 返回"是否并入非空字符串片段"。
- `reasoning`: 在保真模式作为普通未知字符串合并进 message;两模式都把其片段放入 `ChatDelta.reasoning`。
- progress: 1.3.9 已知字段口径 **或** 任一 `_merge_unknown` 返回 True(文本模式也调用合并函数计算活性,但合并结果不对外)。
- 新增只读属性见 §2;`finish()` 改为: 组装 message(含排序后工具)后构造 `OpenAIChatCompletion`(校验失败仍抛 Transient)。
- `parse_chat_completion` 拆为两步: `check_completion_body(body) -> tuple[dict, str | None]`(只校验 body 为对象、恰好一个 choice、index 缺省或为 0、message 为对象、保真下 finish 为字符串或 null,失败抛 Transient;返回原始 message 与 finish)与 `build_completion(message, body) -> OpenAIChatCompletion`(构造快照,失败抛 Transient)。快照构造前剔除 `tool_calls` 中的 None 元素(与流式跳过一致,避免回传 `[null]` 被上游 400);`tool_calls: null` 原样保留。
- `_merge_unknown` 按值类型拆分私有函数(标签键、字符串、标量、对象、index 数组、其他数组),每个 ≤ B。
- 拆分 `add_chunk` 为 `_take_meta`、`_take_choice`、`_take_delta`、`_take_tools`、`_take_tool` 等私有方法,每个 ≤ B。
- 测试: 改写 `test_invalid_delta_is_domain_error` 参数: 删除 `{"audio": {}}`、`{"index":0,"extra":1}` 与 null 已知字段参数(改为正例),保留 role 非 assistant、content 非字符串、tool index 非法等仍应拒绝的参数。新增: `refusal:null`+`tool_calls:null` 帧成功且快照含 `refusal: None`;只有 `reasoning` 的帧 progress=True 且 `ChatDelta.reasoning` 有值;二次构造的 OpenRouter `reasoning_details` 分片(每帧重复 `format`/`id`,`text` 分片)合并后 `format` 不重复、`text` 拼接完整;工具层嵌套对象 `extra_content={"google":{"thought_signature":"s"}}` 原样保留;数值冲突 → 键被删、`conflicts` 含归一路径、调用成功;同路径冲突后再到达的值被丢弃;形态改变(字符串→对象、index 数组→普通数组)即冲突;布尔值冲突;其他数组追加;同帧多个字段/多个未知字符串只产生一次 progress;chunk `choices:null`、choice `delta:null` 视缺省;`role:null`、`arguments:null` 视缺省;全程只有 `role:null` → `finish()` 失败;非流式 `tool_calls:[None, 合法工具]` 快照只含合法工具;`usage_after_finish` 四种顺序(finish 前 usage、finish 同帧、finish 后 usage-only 帧、无 usage);文本模式 index=1 的 choice 被忽略;重复 tool id 不拼接(既有用例保持)。
- 验证: §0 统一验证;快速回路 `conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_fidelity.py tests/unit/test_openai_compat.py -q`。T2 期间 transport 仍调用 `parse_chat_completion` 的组合(保留为两步的薄包装),行为不变。
- [ ] 提交 `fix: merge unknown and null stream fields losslessly in completion accumulator`
### T3 transport 两条路径接入判定
- 文件: `src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_openai_fidelity.py`、`tests/unit/test_openai_compat.py`。
- 行为:
- `_complete_stream`: 流结束后先算 `usage_measured = _resolve_usage(sink.get("usage") or {})[2] == "measured"`,再 `stream_end = _stream_is_complete(done=..., finish_reason=acc.finish_reason, usage_after_finish=acc.usage_after_finish, usage_measured=...)`。
- None 且 openai → Transient `"{源} missing_done: …"`;None 且 text → 沿 `_check_done`(零产物按 `_summarize_text` 的 any 判定 early_eof;`retry` 抛;`salvage` → `stream_end="salvaged"`)。
- `salvaged` 时 usage 沿 `_resolve_stream_usage(..., salvaged=True)`;`done`/`eof_after_finish` 时 salvaged=False。
- 投影: `thinking` = 非空 reasoning_content 片段拼接,否则 `ChatDelta.reasoning` 片段拼接;再走既有 `_finalize_text`(think 标签)。
- 判定: openai → `summary = _summarize_message(acc.raw_message(), content)`;text → `_summarize_text(content, thinking)`;`verdict = _judge_outcome(mode, summary, _classify_finish(acc.finish_reason))`;非 deliver → 按 `error_kind` 抛 `TransientError` 或 `ResultInvalidError`,message 固定格式 `f"{source.name} {code}: finish_reason={fr!r}, thinking_len={len(thinking)}"`,带 `source_name`、`operation="chat"`;`ResultInvalidError` 显式 `raw_text=""`(不回显推理内容)。deliver 且 openai → `acc.finish()` 构造快照。
- 删除 `_reject_empty_completion` 与 1.3.9 的"保真缺 DONE"分支;`_check_done` 只保留文本截断语义。
- `_complete_once`: 跳过第一步。保真: 先 `check_completion_body` 取原始 message 与 finish,用原始 message(剔除 None 工具后)算 `_summarize_message` 与 `_judge_outcome`,**仅 deliver 时**调 `build_completion` 构造快照——否则坏工具 + length 会在构造时抛 Transient,到不了 `tool_truncated`。文本: finish 取 `choices[0].get("finish_reason")`,非字符串记 None;同一判定;`stream_end=None`;投影别名同上(`message.get("reasoning_content") or message.get("reasoning")`,仅取字符串)。
- 两路径 `TransportResult` 填 `finish_reason`、`stream_end`。
- 告警节流: 实例属性 `_warned_fields: set[tuple[str, str]]`,上限 1024,饱和时记一次 `"告警集合已饱和,后续同类告警省略"`;对 `acc.conflicts` 中每个路径、以及 `_classify_finish == "unknown"` 的 finish 值(截断 64 字符,文案写明"该 finish_reason 不缓存")各告警一次;日志只含源名与路径/值,不回显字段内容。
- 拆分 `_complete_stream`/`_complete_once` 至 ≤ B(建议抽 `_consume_stream`、`_verdict_or_raise`、`_project_text`)。
- 测试(假 SSE,复用文件内既有 helper):
- #31 验收 1-6(设计 §10),断言 `stream_end`、`usage_source`、attempts=1(经 GatewayClient 或 RetryMW 的既有测试设施)。
- usage 早于 finish + EOF → 失败;finish 后 usage `{}` → 失败;`network_error`+usage+EOF → Transient。
- 文本模式: stop+usage+EOF 无 DONE(`missing_done="retry"`)→ 成功 `eof_after_finish`;无 finish + EOF → retry 抛、salvage 记 `salvaged`。
- #30 验收: 保真 reasoning-only+length → 交付,`completion.message["content"]` 缺省或空、`reasoning_content` 完整、`finish_reason=="length"`;文本同输入 → `ResultInvalidError` 且 message 含 `length_without_content`;保真 content 空+length(无任何产物)→ 交付;文本同 → ResultInvalid;保真/文本 零增量+DONE(带 stop、不带 finish)→ Transient(`test_stream_zero_content_with_done_is_transient` 保持不改);非流式 message 带 `tool_calls:null`+`refusal:null` → 成功;工具缺 name + length → ResultInvalid `tool_truncated`;content 非空 + 坏工具 + stop → Transient。
- 只有 `reasoning` 字段的流不触发 TTFT 超时(用既有看门狗测试设施设置小 ttft)且文本 `thinking` 等于拼接值;同时有非空 reasoning_content 时以其为准。
- 冲突/未知 finish 告警各只一次(loguru sink: `logger.add(list.append, level="WARNING")`,先例 `test_openai_compat.py:877`;caplog 抓不到 loguru);饱和用例预先往 `_warned_fields` 灌 1024 条再触发一次,断言饱和告警一次。
- 结局表传输层覆盖: 设计 §4.3 九行 × 两模式,每行至少流式一例 + 非流式一例(行 2 只保真);非流式"工具缺 name + length → ResultInvalid `tool_truncated`";文本 length 用例经 GatewayClient 断言 attempts=1 且熔断窗口失败计数为 0;#30 reasoning-only+length 经 GatewayClient 断言 attempts=1。
- 改写 `test_openai_never_salvages`: 断言"无 finish 的 EOF 在 `missing_done=salvage` 下仍失败",并新增"finish+usage+EOF 成功"作为对照。
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过。
- [ ] 提交 `fix: judge stream completion by finish reason and trailing usage`
### T4 retry 搬运与缓存读写判据
- 文件: `src/polygateway/middleware/retry.py`、`src/polygateway/middleware/cache.py`、`src/polygateway/types.py`(删除 `_has_completion_output`);测试 `tests/unit/test_cache.py`、`tests/unit/test_retry.py`、`tests/integration/test_redis_cache.py`。
- 行为:
- `_build_response` 搬运 `finish_reason`、`stream_end`。
- 写路径: 序列化前 `_is_cacheable(mode=request.response_mode, content=response.content, finish_reason=response.finish_reason, stream_end=response.stream_end, completion=response.completion)` 为 False → `logger.debug` 跳写。
- 读路径(`_rehydrate`): 重建 completion 后、`fields.update` 之前: `stored_end = fields.get("stream_end")` 非 None 且不在域 → miss+warning;`finish = fields.get("finish_reason")`,非 None 且非 str → miss;为 None 且 completion 存在 → 回退 `completion.finish_reason`;`_is_cacheable(...)` False → miss+warning;命中覆盖加 `stream_end=None`、`finish_reason=finish`。拆分 `_rehydrate` 至 ≤ B。
- 删除 `_has_completion_output` 及其 import(全仓 grep 为零)。
- 读写判定的 finish 统一取 `_effective_finish(response.finish_reason, response.completion)`(读路径对存储值同样调用)。
- 测试: 不可缓存六类(salvaged、reasoning-only length 保真、文本 length 带正文、unknown finish、保真 reasoning-only + stop(交付但无正文产物)、`content_filter` 带正文)均不写入(fake cache backend 断言 set 未调用)且第二次调用 attempts=1;手工注入上述条目 → 读视 miss;非法 `stream_end` 注入 → miss;1.3.9 旧文本条目(无新键)→ 命中;1.3.9 保真条目(无顶层 finish、completion.finish_reason="length")→ miss;命中返回 `stream_end is None`、`finish_reason` 为存储/回退值;retry 测试断言 LLMResponse 两字段来自 TransportResult。Redis integration 一例往返两新字段。
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过;`conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q`(需本地 Redis)→ 全过。
- [ ] 提交 `fix: gate cache writes and reads on a shared cacheability verdict`
### T5 遥测 `stream_end` 列与 recorder 端口
- 文件: `src/polygateway/ports.py`、`src/polygateway/middleware/telemetry.py`、`src/polygateway/telemetry/schema.py`、`sqlite.py`、`postgres.py`;测试 `tests/unit/test_telemetry.py`、`tests/unit/test_ports.py`、`tests/integration/test_postgres_telemetry.py`,以及全仓 `def record_llm_call` 测试替身(`grep -rn "def record_llm_call" tests`)。
- 行为:
- `TelemetryRecorder.record_llm_call` 在 `stall_count` 之后追加 keyword-only `stream_end: str | None`(无默认),docstring 注明 1.3.10。
- `_AttemptUsage` 加 `stream_end: str | None = None`,`of()` 读 `response.stream_end`;`emit_attempt` 把它传给 `_record`;`_record` 加同名参数并传给 recorder;`emit_cache_hit`、`emit_terminal_failure` 显式 `stream_end=None`。
- `schema.py`: SQLite 与 PG CREATE 末尾 `stream_end TEXT`;`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS` 追加 `("stream_end", "TEXT")`;`COLUMNS` 追加 `"stream_end"`。sqlite/postgres 若有硬编码列数或参数位次随之更新(以 `COLUMNS` 为准)。
- 替身与计数(按审查实测清单): ① 固定字段 fixture `tests/unit/test_telemetry.py:142` `_record_minimal`、`tests/integration/test_postgres_telemetry.py:121` `_minimal_fields` 补 `stream_end: None`(sqlite 按 `fields[col]` 取值,缺键 KeyError);② 硬编码计数 38→39 / 列清单追加: `test_ports.py:325`、`test_telemetry.py:53`(`_EXPECTED_COLUMNS`)`:271`、`:301-303`、`:779`、`:829`,`test_postgres_telemetry.py:33`、`:778`、`:1151`、`:1352`(执行时以 grep 复核行号);③ `test_ports.py:112` `_DummyRecorder` 补参数,`stream_end` 加入 `:332` 无默认值参数化列表;④ `**fields` 替身(14 处)无需改动。源码 docstring "38 字段"同步: `ports.py:293`、`middleware/telemetry.py:373`、`sqlite.py:146`、`schema.py:8`。形状闸 `middleware/telemetry.py:323` `_assert_recorder_shape` 从协议签名派生,不改闸本身。
- 测试: 成功流式行写 `done`/`eof_after_finish`;非流式、缓存命中、失败、取消、终态行为 NULL;旧库(无列)auto 档补列后写入;manual 档缺列 → 裁剪写入且告警点名 `stream_end`;形状闸拒绝缺 `stream_end` 参数的 recorder;PG integration 同上(需本地 PG,沿既有 fixture)。所有替身补参数。
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过;`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -q` → 全过;`conda run -n PolyGateway python -c "import inspect, polygateway.ports as p; print(len(inspect.signature(p.TelemetryRecorder.record_llm_call).parameters) - 1)"` 记录字段数供 T6 使用。
- [ ] 提交 `feat: record stream end mode in telemetry`
### T6 文档、版本与另开 issue
- 文件: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`、`research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md`(顶部加"§4.1/§4.2/§5/§6 部分规则已由 2026-10-14 设计修订"指针)、`research-wiki/schemas/llm-calls.md`、`research-wiki/migrations/*.md`、`pyproject.toml`、`src/polygateway/__init__.py`。
- 内容:
- CHANGELOG `## 1.3.10(未发布)`: 修复段(#31 两模式、#30 两部分、非流式 `tool_calls:null`、reasoning 别名、salvage 不再缓存);**行为变更**段(文本 length/过滤无正文 → ResultInvalid 不再经 `AllSourcesExhausted`;未知 finish 不缓存;活性口径增补);**破坏性**段(`record_llm_call` 新增必填 `stream_end`);提醒(工具快照成立+length 时 arguments 可能半截;冲突删键以告警为准;旧缓存污染迁移 namespace/salt)。
- README: 能力表补完成判定/`stream_end`;安装约束 `1.3.*` 核对;遥测字段数用 T5 实测值;活性口径一句。
- ARCHITECTURE: §6.2 空补全/missing_done 行、§7 `usage_source` 与 `stream_end` 映射、§14 协议/失败/缓存行按设计改写。
- `schemas/llm-calls.md`: 加 `stream_end` 列与取值表,修正陈旧计数。
- `migrations/` 三份: 核对下游对 `ResultInvalidError` 的 catch 点,写一行结论(有/无受影响路径)。
- 版本 1.3.10 两处一致。
- `docs-convention.md` §2 清单逐项过: Gitea Wiki 站点下线,由 README/CHANGELOG/ARCHITECTURE/schemas 承接;`.env.example` 无配置键变更(写明);`research-wiki/ROADMAP.md` 状态同步。
- 另开 Gitea issue(tea CLI): "文本模式 n>1 时多 choice 增量混流";"文本模式有产物无正文 + stop 的证据收集(决定是否改 ResultInvalid)"。
- 验证: `grep -n "1.3.10" pyproject.toml src/polygateway/__init__.py`;`conda run -n PolyGateway pytest tests/unit/test_package.py -q` → 过。
- [ ] 提交 `docs: document completion verdict and bump version to 1.3.10`
### T7 合并前验证、审查与发布
- [ ] `make lint`、`make test` 全绿(覆盖率不降);`tests/integration` 中 Redis/PG 相关全绿。
- [ ] 派全新上下文 verifier subagent(`verification-before-completion`),逐条对照设计 §4-§7、§10 与本计划验收,输出证据表。
- [ ] `requesting-code-review` 整分支审查;按 `receiving-code-review` 核验并修复。
- [ ] slow 子集(按 diff 交集): 动了公开入口与 transport → `pytest -m slow tests/e2e/test_smoke_gateway.py tests/e2e/test_openai_tools_live.py`;不跑 `test_thinking_live.py`(未动 thinking 能力表)。另做一次 magpie/glm 真实探针: 记录是否出现 `eof_after_finish`,作为 #31 外部证据。
- [ ] 按 CLAUDE.md §4.4.1 九步发布 1.3.10(CHANGELOG 定版日期、合并 main `--no-ff`、main 上重跑、tag、构建、上传、下载验证、Release + 包关联 + 页面核对)。
- [ ] 在 #30/#31 评论验收证据与版本,关闭。
## 4. 设计覆盖自查
| 设计条目 | 任务 |
| --- | --- |
| §3 公共字段、`StreamEnd` 导出、`tool_calls:null` | T1 |
| §3 recorder 端口、列 | T5 |
| §4.1 纯函数与 finish 分类 | T1 |
| §4.2 流完整性、usage 位置与 measured | T2(记录)+T3(判定) |
| §4.3 结局表、原因码、错误格式 | T1(判定)+T3(翻译) |
| §4.4 reasoning 别名 | T2+T3 |
| §5.1-5.3 null、合并、标签键、活性 | T2 |
| §5.4 告警节流 | T3 |
| §6 缓存读写 | T4 |
| §7 遥测落点 | T5 |
| §8 旧行为审计中的保留项(零增量+DONE、error 帧、多 choice) | T3 回归测试 |
| §10 文档、另开 issue、发布 | T6+T7 |
## 5. 审查修订记录
| 轮次 | 意见 | 处置 |
| --- | --- | --- |
| P1 | B1 非流式先构造快照导致 `tool_truncated` 不可达 | 采纳: T2 拆 `check_completion_body`/`build_completion`,T3 先判定后构造 |
| P1 | S1 T5 替身/计数清单 | 采纳: T5 逐项列出 |
| P1 | S2 类块复杂度、S3 验证与钩子一致 | 采纳: §0 |
| P1 | S4 保留内部属性、S5 五层 null、S6 缓存漏两类、S7 结局表与熔断断言、S8 合并规则测试 | 采纳: T2/T3/T4 |
| P1 | 建议 1-8 | 全部采纳;建议 4 选"剔除 None 工具元素" |
+16
View File
@@ -184,3 +184,19 @@ OpenAI 保真没有新增遥测表/列;仍经 RetryMW._emit → TelemetryEmitt
error 帧是失败 attempt,正文与思考仍为兼容投影,不新增工具全文列。
本页原标题/表格停留 36 字段;实际 `telemetry/schema.py` 已含 stall_ms/stall_count
共 38 个 INSERT 字段(§7.8 已登记)。本次不改 DDL,真实定义以源码为准。
## 1.3.10 `stream_end` 列(#31,2026-10-14)
`llm_calls` 末尾追加可空 `stream_end TEXT`:INSERT 字段 38 → 39,物理列(含 `created_at`)39 → 40;
上文"36 字段"标题为历史口径,以 `telemetry/schema.py` 的 `COLUMNS` 为准。
| 值 | 含义 | 行 |
| --- | --- | --- |
| `done` | 收到 `[DONE]` | 流式成功 attempt |
| `eof_after_finish` | 无 DONE,finish + 其后实测 usage + 干净 EOF | 流式成功 attempt |
| `salvaged` | 文本 `missing_done=salvage` 打捞 | 流式成功 attempt |
| NULL | 非流式 / 缓存命中 / 失败 / 取消 / 终态行,或补列前旧行 | — |
来源: emitter 经 `_AttemptUsage.of(response)` 读 `LLMResponse.stream_end`(不走 `emit_attempt` kw);
命中与终态入口显式 None。评估复用 `metric:call-telemetry-coverage`;新增观察口径
`SELECT stream_end, count(*) FROM llm_calls WHERE event_kind='attempt' GROUP BY 1` 统计中转不发 DONE 的比例。
+3 -1
View File
@@ -50,11 +50,12 @@ from polygateway.types import (
OpenAIChatCompletion,
ResponseMode,
SourceConfig,
StreamEnd,
TelemetryStatus,
ThinkingObservation,
)
__version__ = "1.3.9"
__version__ = "1.3.10"
__all__ = [
"DEFAULT_PROFILES",
@@ -73,6 +74,7 @@ __all__ = [
"GovernanceBackendError",
"LLMResponse",
"ResponseMode",
"StreamEnd",
"OpenAIChatCompletion",
"ModelPrice",
"OcrClient",
+58 -20
View File
@@ -24,7 +24,8 @@ from polygateway.types import (
OpenAIChatCompletion,
ResponseMode,
ThinkingObservation,
_has_completion_output,
_effective_finish,
_is_cacheable,
)
if TYPE_CHECKING:
@@ -148,6 +149,54 @@ def build_cache_key(
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _response_cacheable(mode: ResponseMode, response: LLMResponse) -> bool:
"""写路径判据;与读路径共用 `_is_cacheable` 与 `_effective_finish`,口径不分叉。"""
return _is_cacheable(
mode=mode,
content=response.content,
finish_reason=_effective_finish(response.finish_reason, response.completion),
stream_end=response.stream_end,
completion=response.completion,
)
def _restore_fields(raw: str, mode: ResponseMode) -> dict[str, Any]:
"""缓存原文 → LLMResponse 字段;不可缓存或形态坏抛 ValueError(调用方按 miss)。
可缓存判定对**存储原值**进行、先于命中覆盖: 覆盖后 `stream_end` 恒 None,
在那之后判会把 salvaged 条目放行(设计 §6)。
"""
data = json.loads(raw)
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
completion = fields.get("completion")
if completion is not None:
if not isinstance(completion, dict):
raise ValueError("缓存 completion 必须为对象")
fields["completion"] = OpenAIChatCompletion(**completion)
finish = _effective_finish(fields.get("finish_reason"), fields.get("completion"))
if finish is not None and not isinstance(finish, str):
raise ValueError("缓存 finish_reason 必须为字符串或 null")
if not _is_cacheable(
mode=mode,
content=fields.get("content", ""),
finish_reason=finish,
stream_end=fields.get("stream_end"),
completion=fields.get("completion"),
):
# 非法 stream_end 也落这里: 它关乎内容完整性,不像 thinking_observation
# 那样可以宽容降级
raise ValueError("缓存条目不可缓存(截断/空正文/非自然结束/非法 stream_end)")
fields["finish_reason"] = finish
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
if "thinking_observation" in fields:
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
if "applied_effort" in fields:
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
return fields
class CacheMW:
"""洋葱第二层(遥测内、结构化外)。
@@ -195,6 +244,11 @@ class CacheMW:
if hit is not None:
return hit
response = await call_next(request)
if not _response_cacheable(request.response_mode, response):
# 交付照常,只是不进 TTL: 截断/空正文/非自然结束固化后会把一次偶然
# 变成必然(设计 §6)。debug 级: 这是常规分流,不是故障
logger.debug("结果不可缓存(截断/空正文/非自然结束),跳过写入")
return response
try:
serialized = self._serialize(response)
except Exception as exc:
@@ -208,31 +262,15 @@ class CacheMW:
def _rehydrate(self, raw: str, request: ChatRequest) -> LLMResponse | None:
"""反序列化 + 按本次调用的 structured 档零网络重建;失败按未命中。"""
try:
data = json.loads(raw)
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
completion = fields.get("completion")
if completion is not None:
if not isinstance(completion, dict):
raise ValueError("缓存 completion 必须为对象")
fields["completion"] = OpenAIChatCompletion(**completion)
if request.response_mode == "openai":
if fields.get("completion") is None:
raise ValueError("保真缓存缺 completion")
if not _has_completion_output(fields.get("content", ""), fields["completion"]):
raise ValueError("保真缓存无有效正文或工具产物")
fields = _restore_fields(raw, request.response_mode)
structured_data = self._rebuild_structured(fields.get("content", ""), request)
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
if "thinking_observation" in fields:
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
if "applied_effort" in fields:
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
fields.update(
cache_hit=True,
latency_ms=0,
ttft_ms=None,
max_inter_token_ms=None,
# 结束方式描述的是"那次流",命中没有流可言(与 ttft_ms 同口径)
stream_end=None,
call_id=str(uuid.uuid4()),
structured_data=structured_data,
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
+2
View File
@@ -741,6 +741,8 @@ class RetryMW:
return LLMResponse(
content=result.content,
completion=result.completion,
finish_reason=result.finish_reason,
stream_end=result.stream_end,
thinking=result.thinking,
model=source.model,
provider=source.provider,
+13 -1
View File
@@ -221,6 +221,9 @@ class _AttemptUsage:
# 两字段留在此处只为让"尝试视图"形状完整,默认 None 即"无沉默观测"
stall_ms: int | None = None
stall_count: int | None = None
# 1.3.10(#31): 与 stall 相反,结束方式**从 response 取**——LLMResponse 本次有
# 该字段,免去约 50 处 emit_attempt 调用点加参;失败尝试 None = 无流可言
stream_end: str | None = None
@classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
@@ -239,6 +242,7 @@ class _AttemptUsage:
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
thinking_observation=response.thinking_observation,
stream_end=response.stream_end,
)
@@ -370,7 +374,7 @@ def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
class TelemetryEmitter:
"""从请求与结果组装 38 字段并写入 recorder;一切写失败降级 warning。"""
"""从请求与结果组装 39 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(
self,
@@ -464,6 +468,7 @@ class TelemetryEmitter:
total_latency_ms=None,
stall_ms=stall_ms,
stall_count=stall_count,
stream_end=usage.stream_end,
)
async def emit_cache_hit(
@@ -514,6 +519,8 @@ class TelemetryEmitter:
# 缓存命中行未产生 transport 观测: 无沉默可言,显式 None
stall_ms=None,
stall_count=None,
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
stream_end=None,
)
async def emit_terminal_failure(
@@ -569,6 +576,8 @@ class TelemetryEmitter:
# 终态行无具体源与 transport 观测: 显式 None
stall_ms=None,
stall_count=None,
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
stream_end=None,
)
async def _record(
@@ -615,6 +624,8 @@ class TelemetryEmitter:
# 本方法只搬运(同 reasoning_effort 先例)——
stall_ms: int | None,
stall_count: int | None,
# —— 1.3.10: 结束方式(#31);入口已定型,本方法只搬运 ——
stream_end: str | None,
) -> None:
try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -685,6 +696,7 @@ class TelemetryEmitter:
total_latency_ms=total_latency_ms,
stall_ms=stall_ms,
stall_count=stall_count,
stream_end=stream_end,
)
except asyncio.CancelledError:
raise
+2 -1
View File
@@ -290,7 +290,7 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable
class TelemetryRecorder(Protocol):
"""遥测后端;38 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8),唯一调用点是 TelemetryEmitter。
"""遥测后端;39 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8/1.3.10),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
@@ -357,4 +357,5 @@ class TelemetryRecorder(Protocol):
total_latency_ms: int | None,
stall_ms: int | None,
stall_count: int | None,
stream_end: str | None,
) -> None: ...
+12 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 38 个 INSERT 字段 +
`created_at` = 39;列数断言一律按物理列数写,两套口径混用是最易错处。
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 39 个 INSERT 字段 +
`created_at` = 40;列数断言一律按物理列数写,两套口径混用是最易错处。
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
互不依赖(import-linter 契约执法)。
@@ -64,7 +64,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
attempts INTEGER,
total_latency_ms INTEGER,
stall_ms INTEGER,
stall_count INTEGER
stall_count INTEGER,
stream_end TEXT
);
"""
@@ -108,7 +109,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
attempts INTEGER,
total_latency_ms INTEGER,
stall_ms INTEGER,
stall_count INTEGER
stall_count INTEGER,
stream_end TEXT
);
"""
@@ -145,6 +147,8 @@ SQLITE_BACKFILL = (
# 没记过这件事",与"观测过且零沉默"(0)是两回事
("stall_ms", "INTEGER"),
("stall_count", "INTEGER"),
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
("stream_end", "TEXT"),
)
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -174,6 +178,8 @@ _PG_BACKFILL_DECLS = (
# 1.3.8 两列,列序与 SQLITE_BACKFILL 逐项对齐(理由同上)
("stall_ms", "INTEGER"),
("stall_count", "INTEGER"),
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
("stream_end", "TEXT"),
)
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -226,6 +232,8 @@ COLUMNS = (
# —— 1.3.8 沉默归因(issue #27)——
"stall_ms",
"stall_count",
# —— 1.3.10 结束方式(#31)——
"stream_end",
)
_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:
"""写一行遥测;字段集合即 38 字段冻结签名(ports.TelemetryRecorder)。
"""写一行遥测;字段集合即 39 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
+361 -80
View File
@@ -1,11 +1,23 @@
"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。"""
"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。
1.3.10(#30): null 一律视为缺省;已知字段沿用 1.3.9 语义;未知字段按 OpenAI 流式
累加惯例无损合并,冲突删键并登记路径——方言是确定性的,拒绝只会让重试重演同一失败。
"""
import copy
from dataclasses import dataclass
from typing import Any
from polygateway.errors import TransientError
from polygateway.types import OpenAIChatCompletion, ResponseMode
_DELTA_KNOWN = frozenset({"role", "content", "reasoning_content", "tool_calls"})
_TOOL_KNOWN = frozenset({"index", "id", "type", "function"})
_FUNCTION_KNOWN = frozenset({"name", "arguments"})
# 标签键: 每帧可能重复下发的身份/格式标记,同值幂等,绝不拼接(拼成 "x-v1x-v1"
# 回传上游会被拒);也不算生成进展
_LABEL_KEYS = frozenset({"index", "type", "id", "format"})
def _invalid(reason: str) -> TransientError:
"""协议形态失败,不回显可能敏感的工具参数。"""
@@ -43,6 +55,14 @@ def _append(target: dict[str, Any], key: str, value: Any) -> str:
return value
def _assemble(message: dict[str, Any], tools: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""按 index 排序组装工具;`finish()` 与 `raw_message()` 共用,判定与快照同源。"""
assembled = copy.deepcopy(message)
if tools:
assembled["tool_calls"] = [copy.deepcopy(tools[index]) for index in sorted(tools)]
return assembled
def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChatCompletion:
"""复用内核最终快照校验并翻译领域错误。"""
try:
@@ -53,13 +73,129 @@ def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChat
raise _invalid(str(exc)) from exc
# —— 未知字段合并(设计 §5.2) ——
class _MergeConflictError(Exception):
"""合并冲突(内部控制流): 由 `_ExtensionMerger.merge_key` 就地收口为删键。"""
def _is_indexed(items: list[Any]) -> bool:
"""数组元素(忽略 null)均为带整数 index 的对象。"""
present = [item for item in items if item is not None]
return bool(present) and all(
isinstance(item, dict) and type(item.get("index")) is int for item in present
)
def _fragment_progress(key: str, value: Any) -> bool:
"""不落盘的活性判据(文本模式): 非标签键上的非空字符串片段。"""
if isinstance(value, str):
return key not in _LABEL_KEYS and value != ""
if isinstance(value, dict):
return any(_fragment_progress(k, v) for k, v in value.items())
if isinstance(value, list):
return any(_fragment_progress(key, item) for item in value)
return False
class _ExtensionMerger:
"""每尝试私有的未知字段合并器;冲突路径即封禁,后续同路径值丢弃。"""
def __init__(self) -> None:
self.conflicts: set[str] = set()
self._array_modes: dict[str, bool] = {}
def merge_key(self, target: dict[str, Any], key: str, value: Any, path: str) -> bool:
"""合并一个键;返回是否并入了非空字符串片段(活性)。"""
if path in self.conflicts:
return False
if value is None:
target.setdefault(key, None)
return False
try:
merged, grew = self._merge(target.get(key), value, key, path)
except _MergeConflictError:
target.pop(key, None)
self.conflicts.add(path)
return False
target[key] = merged
return grew
def _merge(self, old: Any, new: Any, key: str, path: str) -> tuple[Any, bool]:
if isinstance(new, dict):
return self._merge_dict(old, new, path)
if isinstance(new, list):
return self._merge_list(old, new, key, path)
if key in _LABEL_KEYS or not isinstance(new, str):
return self._merge_scalar(old, new)
if old is not None and not isinstance(old, str):
raise _MergeConflictError
return (old or "") + new, new != ""
@staticmethod
def _merge_scalar(old: Any, new: Any) -> tuple[Any, bool]:
"""标签键与数字/布尔: 同值幂等,不同值冲突(不学 SDK 相加,计数相加会造假)。"""
if old is None:
return new, False
if type(old) is type(new) and old == new:
return old, False
raise _MergeConflictError
def _merge_dict(self, old: Any, new: dict[str, Any], path: str) -> tuple[Any, bool]:
if old is not None and not isinstance(old, dict):
raise _MergeConflictError
target = old if old is not None else {}
grew = False
for sub_key, sub_value in new.items():
grew |= self.merge_key(target, sub_key, sub_value, f"{path}.{sub_key}")
return target, grew
def _merge_list(self, old: Any, new: list[Any], key: str, path: str) -> tuple[Any, bool]:
if old is not None and not isinstance(old, list):
raise _MergeConflictError
target = old if old is not None else []
items = [item for item in new if item is not None]
if not items:
return target, False
indexed = _is_indexed(items)
if self._array_modes.setdefault(path, indexed) != indexed:
raise _MergeConflictError # 形态改变: 首次出现决定合并策略
if indexed:
return target, self._merge_indexed(target, items, f"{path}[]")
return target, self._merge_appended(target, items, key, f"{path}[]")
def _merge_appended(self, target: list[Any], items: list[Any], key: str, path: str) -> bool:
grew = False
for item in items:
merged, item_grew = self._merge(None, item, key, path)
target.append(merged)
grew |= item_grew
return grew
def _merge_indexed(self, target: list[Any], items: list[Any], path: str) -> bool:
grew = False
for item in items:
slot = next((e for e in target if e.get("index") == item["index"]), None)
if slot is None:
slot = {}
target.append(slot)
grew |= self._merge_dict(slot, item, path)[1]
return grew
@dataclass(frozen=True)
class ChatDelta:
"""一帧提取结果;progress 表示真实生成进展。"""
"""一帧提取结果;progress 表示真实生成进展。
`reasoning` 是 `reasoning` 字段(vLLM/OpenRouter 新名)的片段,投影 thinking 时
作为 `reasoning_content` 缺席时的别名(设计 §4.4)。
"""
content: str
reasoning_content: str
progress: bool
reasoning: str = ""
class ChatCompletionAccumulator:
@@ -71,104 +207,249 @@ class ChatCompletionAccumulator:
self.message: dict[str, Any] = {}
self.metadata: dict[str, Any] = {}
self.tools: dict[int, dict[str, Any]] = {}
self._merger = _ExtensionMerger()
self._finish_seen = False
self._usage_after_finish = False
# —— 只读视图(transport 判定消费) ——
@property
def finish_reason(self) -> str | None:
"""choice 0 首个非 null finish_reason;未见为 None。"""
return self.metadata.get("finish_reason")
@property
def finish_seen(self) -> bool:
"""是否已收到 finish_reason。"""
return self._finish_seen
@property
def usage_after_finish(self) -> bool:
"""finish 同帧或之后是否收到非 null usage(设计 §4.2)。"""
return self._usage_after_finish
@property
def conflicts(self) -> frozenset[str]:
"""被删除的冲突键路径(数组下标已归一为 `[]`)。"""
return frozenset(self._merger.conflicts)
def raw_message(self) -> dict[str, Any]:
"""未校验的合并消息深拷贝(含排序工具),供构造快照前的结局判定。"""
return _assemble(self.message, self.tools)
# —— 聚合 ——
def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta:
"""聚合一个已解码帧,一帧至多一个活性事件。"""
chunk = _object(chunk, "chunk")
strict = self.mode == "openai"
if strict:
if self.mode == "openai":
_identity(self.metadata, "id", chunk.get("id"))
if chunk.get("usage") is not None:
_object(chunk["usage"], "usage")
self.metadata["usage"] = chunk["usage"]
choices = chunk.get("choices", [])
choice = self._take_choice(chunk.get("choices"))
delta = self._take_delta(choice) if choice is not None else ChatDelta("", "", False)
self._take_usage(chunk.get("usage"))
return delta
def _take_usage(self, usage: Any) -> None:
"""usage 在 finish 处理之后登记,使同帧 finish+usage 算作"之后"。"""
if usage is None:
return
self.metadata["usage"] = _object(usage, "usage")
if self._finish_seen:
self._usage_after_finish = True
def _take_choice(self, choices: Any) -> dict[str, Any] | None:
"""选出本帧 choice 并登记 finish;usage-only 帧返回 None。"""
if choices is None:
return None
if not isinstance(choices, list):
raise _invalid("choices 必须为数组")
if not choices:
return ChatDelta("", "", False)
if strict and len(choices) != 1:
raise _invalid("仅支持单 choice")
choice = _object(choices[0], "choice")
if strict:
return None
if self.mode == "openai":
if len(choices) != 1:
raise _invalid("仅支持单 choice")
choice = _object(choices[0], "choice")
if type(choice.get("index")) is not int or choice["index"] != 0:
raise _invalid("choice index 必须为 0")
_identity(self.metadata, "finish_reason", choice.get("finish_reason"))
delta = _object(choice.get("delta", {}), "delta")
if strict and delta.keys() - {"role", "content", "reasoning_content", "tool_calls"}:
raise _invalid("未知 delta 字段")
if "role" in delta and strict:
if delta["role"] != "assistant":
raise _invalid("role 必须为 assistant")
self.message["role"] = delta["role"]
content = _append(self.message, "content", delta["content"]) if "content" in delta else ""
reasoning = (
_append(self.message, "reasoning_content", delta["reasoning_content"])
if "reasoning_content" in delta
else ""
)
progress = bool(content or reasoning)
else:
choice = self._text_choice(choices)
if choice is None:
return None
self._finish_seen = self._finish_seen or self.finish_reason is not None
return choice
def _text_choice(self, choices: list[Any]) -> dict[str, Any] | None:
"""文本模式取 index 缺省或为 0 的首个 choice,不混入其他 choice(n>1)。"""
for raw in choices:
choice = _object(raw, "choice")
index = choice.get("index")
# null 视缺省(=0);bool 不得冒充 0(`False == 0` 为真)
if index is None or (type(index) is int and index == 0):
finish = choice.get("finish_reason")
if isinstance(finish, str) and "finish_reason" not in self.metadata:
self.metadata["finish_reason"] = finish
return choice
return None
def _take_delta(self, choice: dict[str, Any]) -> ChatDelta:
"""拆分已知与未知字段;已知字段语义逐字沿用 1.3.9。"""
raw_delta = choice.get("delta")
delta = _object(raw_delta if raw_delta is not None else {}, "delta")
if self.mode == "openai" and "role" in delta:
self._take_role(delta["role"])
content = self._take_text(delta, "content")
reasoning_content = self._take_text(delta, "reasoning_content")
alias = delta.get("reasoning") if isinstance(delta.get("reasoning"), str) else ""
progress = bool(content or reasoning_content)
if "tool_calls" in delta:
tools = delta["tool_calls"]
if not isinstance(tools, list):
if strict:
raise _invalid("tool_calls 必须为数组")
return ChatDelta(content, reasoning, progress)
if strict:
self.message.setdefault("tool_calls", [])
for raw in tools:
if not strict:
# 文本只消费可识别的工具进展,不承诺工具完整性。
if isinstance(raw, dict) and type(raw.get("index")) is int:
index = raw["index"]
tool = self.tools.setdefault(index, {})
for key in ("id", "type"):
if isinstance(raw.get(key), str) and raw[key] and key not in tool:
tool[key] = raw[key]
progress = True
fn = raw.get("function")
if isinstance(fn, dict):
progress |= any(
isinstance(fn.get(k), str) and bool(fn[k])
for k in ("name", "arguments")
)
continue
raw = _object(raw, "tool")
index = raw.get("index")
if type(index) is not int or index < 0:
raise _invalid("tool index 必须为非负整数")
if raw.keys() - {"index", "id", "type", "function"}:
raise _invalid("未知工具增量字段")
tool = self.tools.setdefault(index, {})
for key in ("id", "type"):
progress |= _identity(tool, key, raw.get(key))
if "function" in raw:
fn = _object(raw["function"], "function")
if fn.keys() - {"name", "arguments"}:
raise _invalid("未知 function 增量字段")
target = tool.setdefault("function", {})
for key in ("name", "arguments"):
if key in fn:
if not isinstance(fn[key], str):
raise _invalid(f"function.{key} 必须为字符串")
progress |= bool(_append(target, key, fn[key]))
return ChatDelta(content, reasoning, progress)
progress |= self._take_tools(delta["tool_calls"])
progress |= self._take_unknown(delta)
return ChatDelta(content, reasoning_content, progress, alias)
def _take_role(self, role: Any) -> None:
if role is None:
self.message.setdefault("role", None)
return
if role != "assistant":
raise _invalid("role 必须为 assistant")
self.message["role"] = role
def _take_text(self, delta: dict[str, Any], key: str) -> str:
if key not in delta:
return ""
if self.mode == "openai" or delta[key] is not None:
return _append(self.message, key, delta[key])
return ""
def _take_unknown(self, delta: dict[str, Any]) -> bool:
"""未知 delta 键: 保真合并进 message;文本只算活性。"""
grew = False
for key, value in delta.items():
if key in _DELTA_KNOWN:
continue
if self.mode == "openai":
grew |= self._merger.merge_key(self.message, key, value, f"delta.{key}")
else:
grew |= _fragment_progress(key, value)
return grew
def _take_tools(self, tools: Any) -> bool:
if self.mode != "openai":
return isinstance(tools, list) and self._each_tool(tools, self._text_tool_progress)
if tools is None:
self.message.setdefault("tool_calls", None)
return False
if not isinstance(tools, list):
raise _invalid("tool_calls 必须为数组")
present = [tool for tool in tools if tool is not None]
if self.message.get("tool_calls") is None and (present or not tools):
# 显式 [] 原样保留;[null] 不造空数组(回传空 tool_calls 会被上游拒)
self.message["tool_calls"] = []
return self._each_tool(present, lambda raw: self._take_tool(_object(raw, "tool")))
@staticmethod
def _each_tool(tools: list[Any], take: Any) -> bool:
"""逐个非 null 工具增量聚合,返回本帧是否有进展。"""
progress = False
for raw in tools:
if raw is not None:
progress |= take(raw)
return progress
def _text_tool_progress(self, raw: Any) -> bool:
"""文本只消费可识别的工具进展,不承诺工具完整性。"""
if not isinstance(raw, dict) or type(raw.get("index")) is not int:
return False
tool = self.tools.setdefault(raw["index"], {})
progress = False
for key in ("id", "type"):
if isinstance(raw.get(key), str) and raw[key] and key not in tool:
tool[key] = raw[key]
progress = True
fn = raw.get("function")
if isinstance(fn, dict):
progress |= any(
isinstance(fn.get(k), str) and bool(fn[k]) for k in ("name", "arguments")
)
return progress
def _take_tool(self, raw: dict[str, Any]) -> bool:
index = raw.get("index")
if type(index) is not int or index < 0:
raise _invalid("tool index 必须为非负整数")
tool = self.tools.setdefault(index, {})
progress = _identity(tool, "id", raw.get("id"))
progress |= _identity(tool, "type", raw.get("type"))
if raw.get("function") is not None:
progress |= self._take_function(tool, _object(raw["function"], "function"))
for key, value in raw.items():
if key not in _TOOL_KNOWN:
progress |= self._merger.merge_key(tool, key, value, f"tool_calls[].{key}")
return progress
def _take_function(self, tool: dict[str, Any], fn: dict[str, Any]) -> bool:
target = tool.setdefault("function", {})
progress = False
for key, value in fn.items():
if key in _FUNCTION_KNOWN:
progress |= bool(_append(target, key, value))
else:
path = f"tool_calls[].function.{key}"
progress |= self._merger.merge_key(target, key, value, path)
return progress
def finish(self) -> OpenAIChatCompletion:
"""线路完整性由调用者保证;此处只校验最终消息。"""
message = dict(self.message)
if self.tools:
message["tool_calls"] = [self.tools[index] for index in sorted(self.tools)]
return _completion(message, self.metadata)
return _completion(self.raw_message(), self.metadata)
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion:
"""非流式完整消息保留扩展字段,拒绝静默截取多 choice。"""
body = _object(body, "body")
def _strip_null_tools(message: dict[str, Any]) -> dict[str, Any]:
"""null 工具元素剔除: 与流式跳过同口径,原样回传 [null] 会被上游拒。"""
tools = message.get("tool_calls")
if isinstance(tools, list) and None in tools:
kept = [tool for tool in tools if tool is not None]
# 全为 null 视同 tool_calls:null,不造会被上游拒收的空数组
return {**message, "tool_calls": kept or None}
return message
def _single_choice(body: dict[str, Any]) -> dict[str, Any]:
"""恰好一个 choice,index 缺省或为 0;拒绝静默截取多 choice。"""
choices = body.get("choices")
if not isinstance(choices, list) or len(choices) != 1:
raise _invalid("仅支持单 choice")
choice = _object(choices[0], "choice")
if "index" in choice and (type(choice["index"]) is not int or choice["index"] != 0):
raise _invalid("choice index 必须为 0")
metadata = {**body, "finish_reason": choice.get("finish_reason")}
return _completion(_object(choice.get("message"), "message"), metadata)
return choice
def check_completion_body(body: Any) -> tuple[dict[str, Any], str | None]:
"""非流式保真形态校验(不构造快照): 单 choice、index 为 0、finish 为字符串或 null。
与快照构造拆开: 结局判定要先于构造(截断的半截工具须判 ResultInvalid,
构造时抛 Transient 就到不了那一行,设计 §4.3 行 2)。
"""
choice = _single_choice(_object(body, "body"))
finish = choice.get("finish_reason")
if finish is not None and not isinstance(finish, str):
raise _invalid("finish_reason 必须为字符串或 null")
message = _object(choice.get("message"), "message")
if message.get("role") != "assistant":
# 形态失败先于结局判定,否则缺 role 的截断工具会被归成 ResultInvalid
raise _invalid("role 必须为 assistant")
return _strip_null_tools(message), finish
def build_completion(
message: dict[str, Any], body: dict[str, Any], finish: str | None
) -> OpenAIChatCompletion:
"""非流式快照构造;保留消息扩展字段。"""
return _completion(message, {**body, "finish_reason": finish})
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion:
"""非流式完整消息保留扩展字段,拒绝静默截取多 choice。"""
message, finish = check_completion_body(body)
return build_completion(message, body, finish)
+219 -82
View File
@@ -12,7 +12,7 @@ import json
import math
import re
import time
from dataclasses import replace
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any
import httpx
@@ -41,16 +41,21 @@ from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.transports._openai_completion import (
ChatCompletionAccumulator,
ChatDelta,
parse_chat_completion,
build_completion,
check_completion_body,
)
from polygateway.types import (
Effort,
EmbeddingTransportResult,
OpenAIChatCompletion,
ResponseMode,
SourceConfig,
StreamEnd,
TransportResult,
_has_completion_output,
_classify_finish,
_judge_outcome,
_stream_is_complete,
_summarize_message,
_summarize_text,
)
if TYPE_CHECKING:
@@ -58,6 +63,10 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Mapping
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
# 方言告警节流上限与 finish 值截断(设计 §5.4): 键由上游决定,必须有界
_WARN_CAP = 1024
_FINISH_WARN_CHARS = 64
_PATH_WARN_CHARS = 128
# —— SSE 纯函数(VT llm.py 同款)——
@@ -135,6 +144,125 @@ async def _iter_sse_deltas(
yield delta
@dataclass
class _StreamTiming:
"""一次流式尝试的计时与文本片段(每尝试新建,不跨重试/对冲共享)。"""
content_parts: list[str] = field(default_factory=list)
reasoning_parts: list[str] = field(default_factory=list)
alias_parts: list[str] = field(default_factory=list)
chunk_times: list[float] = field(default_factory=list)
ttft_ms: float | None = None
max_gap: float = 0.0
@property
def has_reasoning(self) -> bool:
"""出现过非空 reasoning_content: 别名 `reasoning` 让位(设计 §4.4)。"""
return bool(self.reasoning_parts)
async def _consume_deltas(
guarded: AsyncIterator[ChatDelta],
started: float,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> _StreamTiming:
"""消费进展事件: 计时、置位对冲事件、收集文本片段。"""
timing = _StreamTiming()
last = started
async for delta in guarded:
now = time.monotonic()
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
# 空 chunk/usage 帧/SSE keepalive 已在聚合器被过滤
timing.chunk_times.append(now)
if content_event is not None:
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
content_event.set()
if timing.ttft_ms is None:
timing.ttft_ms = (now - started) * 1000
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
# 与看门狗活性口径一致);None = 调用方未启用对冲,零分支成本
if first_token_event is not None:
first_token_event.set()
else:
timing.max_gap = max(timing.max_gap, (now - last) * 1000)
last = now
for parts, text in (
(timing.content_parts, delta.content),
(timing.reasoning_parts, delta.reasoning_content),
(timing.alias_parts, delta.reasoning),
):
if text:
parts.append(text)
return timing
def _raise_unless_deliverable(
mode: ResponseMode,
message: dict[str, Any] | None,
content: str,
thinking: str,
finish_reason: str | None,
source: SourceConfig,
) -> None:
"""结局判定(设计 §4.3)的唯一翻译点: 判定值 → 领域错误。
原因码以固定格式进 message(遥测 error 列可检索);不回显推理/工具内容。
"""
if mode == "openai" and message is not None:
summary = _summarize_message(message, content)
else:
summary = _summarize_text(content, thinking)
verdict = _judge_outcome(mode=mode, summary=summary, finish=_classify_finish(finish_reason))
if verdict.deliver:
return
text = (
f"{source.name} {verdict.code}: finish_reason={finish_reason!r}, "
f"thinking_len={len(thinking)}"
)
if verdict.error_kind == "result_invalid":
raise ResultInvalidError(text, raw_text="", source_name=source.name, operation="chat")
raise TransientError(text, source_name=source.name, operation="chat")
def _text_choice(body: dict[str, Any], source: SourceConfig) -> tuple[Any, str | None]:
"""文本模式取首个 choice 的 message 与 finish(非字符串 finish 记 None)。"""
choices = body.get("choices") or []
if not choices:
raise TransientError(
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
)
if not isinstance(choices, list) or not isinstance(choices[0], dict):
raise TransientError("chat choices 形态非法", operation="chat")
finish = choices[0].get("finish_reason")
return choices[0].get("message", {}), finish if isinstance(finish, str) else None
def _nonstream_message(
body: dict[str, Any], mode: ResponseMode, source: SourceConfig
) -> tuple[dict[str, Any], str | None]:
"""非流式取单 choice 的原始 message 与 finish;只校验形态,不构造快照。"""
if mode == "openai":
message, finish = check_completion_body(body)
else:
message, finish = _text_choice(body, source)
if not isinstance(message, dict) or any(
message.get(key) is not None and not isinstance(message[key], str)
for key in ("content", "reasoning_content")
):
raise TransientError("chat message 形态非法", operation="chat")
return message, finish
def _reasoning_of(message: dict[str, Any]) -> str:
"""非流式思考投影: 非空 reasoning_content 优先,否则 `reasoning` 别名。"""
primary = message.get("reasoning_content")
if isinstance(primary, str) and primary:
return primary
alias = message.get("reasoning")
return alias if isinstance(alias, str) else ""
# —— 错误翻译(CHS invokers.py 同款)——
@@ -392,6 +520,9 @@ class OpenAICompatTransport:
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
# 分开维护的理由是语义,不是碰撞)
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
# 方言告警(1.3.10)另立集合: 冲突删键/未知 finish 与推理对账语义无关
self._warned_fields: set[tuple[str, str]] = set()
self._warn_saturated = False
self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {}
@@ -636,54 +767,48 @@ class OpenAICompatTransport:
inter_token_s=source.inter_token_timeout_s or source.timeout_s,
total_s=source.timeout_s,
)
content_parts: list[str] = []
thinking_parts: list[str] = []
ttft_ms: float | None = None
last = started
max_gap = 0.0
chunk_times: list[float] = []
async for delta in guarded:
now = time.monotonic()
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
# 空 chunk/usage 帧/SSE keepalive 已在 _sse_data_payload/_sse_delta
# 被过滤,这里逐字追加即可;阈值判定不在热路径,派生收敛在
# _stall_from_chunk_times(设计 §3 派生原则)
chunk_times.append(now)
if content_event is not None:
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
content_event.set()
if ttft_ms is None:
ttft_ms = (now - started) * 1000
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
# 与看门狗活性口径一致);None = 调用方未启用对冲,零分支成本
if first_token_event is not None:
first_token_event.set()
else:
max_gap = max(max_gap, (now - last) * 1000)
last = now
if delta.content:
content_parts.append(delta.content)
if delta.reasoning_content:
thinking_parts.append(delta.reasoning_content)
if response_mode == "openai" and not sink.get("done"):
raise TransientError(
"保真响应缺 [DONE],禁止打捞", source_name=source.name, operation="chat"
)
timing = await _consume_deltas(guarded, started, first_token_event, content_event)
return self._stream_result(sink, accumulator, timing, source, profile, response_mode)
def _stream_result(
self,
sink: dict[str, Any],
accumulator: ChatCompletionAccumulator,
timing: _StreamTiming,
source: SourceConfig,
profile: ProviderProfile,
response_mode: ResponseMode,
) -> TransportResult:
"""流结束后的统一判定(设计 §4.2-§4.3): 完整性 → 结局 → 组装。"""
self._warn_dialect(source, accumulator.conflicts, accumulator.finish_reason)
thinking_parts = timing.reasoning_parts if timing.has_reasoning else timing.alias_parts
content, thinking = self._finalize_text(timing.content_parts, thinking_parts, profile)
stream_end = _stream_is_complete(
done=bool(sink.get("done")),
finish_reason=accumulator.finish_reason,
usage_after_finish=accumulator.usage_after_finish,
usage_measured=_resolve_usage(sink.get("usage") or {})[2] == "measured",
)
if stream_end is None:
stream_end = self._truncated(timing, thinking_parts, source, response_mode)
message = accumulator.raw_message() if response_mode == "openai" else None
_raise_unless_deliverable(
response_mode, message, content, thinking, accumulator.finish_reason, source
)
completion_result = accumulator.finish() if response_mode == "openai" else None
salvaged = self._check_done(sink, content_parts, thinking_parts, source)
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
self._reject_empty_completion(content, completion_result, source)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
prompt, completion, usage_source = _resolve_stream_usage(sink, stream_end == "salvaged")
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
stall_ms, stall_count = _stall_from_chunk_times(chunk_times, source.stall_threshold_ms)
stall_ms, stall_count = _stall_from_chunk_times(
timing.chunk_times, source.stall_threshold_ms
)
return TransportResult(
content=content,
thinking=thinking,
prompt_tokens=prompt,
completion_tokens=completion,
usage_source=usage_source,
ttft_ms=ttft_ms,
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
ttft_ms=timing.ttft_ms,
max_inter_token_ms=(timing.max_gap if timing.ttft_ms is not None else None),
stall_ms=stall_ms,
stall_count=stall_count,
raw={"usage": sink.get("usage")},
@@ -696,40 +821,56 @@ class OpenAICompatTransport:
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
finish_reason=accumulator.finish_reason,
stream_end=stream_end,
)
def _check_done(
def _truncated(
self,
sink: dict[str, Any],
content_parts: list[str],
timing: _StreamTiming,
thinking_parts: list[str],
source: SourceConfig,
) -> bool:
"""缺 [DONE] 语义(设计 §6): 零内容恒 retry;有内容按 missing_done 策略。"""
if sink.get("done"):
return False
response_mode: ResponseMode,
) -> StreamEnd:
"""截断流(设计 §4.2): 保真不打捞;文本按 missing_done,零产物恒 retry。"""
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
if not content_parts and not thinking_parts:
if response_mode == "openai":
raise TransientError(f"{source.name} missing_done: 保真响应截断,禁止打捞", **ctx)
if not any(timing.content_parts) and not any(thinking_parts):
raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx)
if source.missing_done == "retry":
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
return True
return "salvaged"
def _reject_empty_completion(
self, content: str, completion: OpenAIChatCompletion | None, source: SourceConfig
def _warn_dialect(
self, source: SourceConfig, conflicts: frozenset[str], finish_reason: str | None
) -> None:
"""空补全 → 瞬时错误(2026-07-20 人类裁决,M1 验证发现)。
服务 200 且流程完整([DONE]/usage 正常)但 content 为空——MiniMax 等
网关的间歇异常形态。视为服务抖动: 退避重试/换源,**绝不缓存空响应**;
承 CHS "VLM 零 content"归瞬时的先例(invokers.py:309)。
"""
if not _has_completion_output(content, completion):
raise TransientError(
f"{source.name} 空补全(empty_completion): 流程完整但零内容",
source_name=source.name,
operation="chat",
"""方言告警: 冲突删键与未登记 finish 各按 (源, 路径/值) 只喊一次。"""
for raw_path in sorted(conflicts):
path = raw_path[:_PATH_WARN_CHARS] # 键名由上游决定,长度同样须有界
self._warn_once(
source.name, f"field:{path}", f"字段 {path} 合并冲突已删除,保真结果缺该字段"
)
if finish_reason is not None and _classify_finish(finish_reason) == "unknown":
value = finish_reason[:_FINISH_WARN_CHARS]
self._warn_once(
source.name,
f"finish:{value}",
f"未登记的 finish_reason {value!r},按正常结束交付,该值的结果不缓存",
)
def _warn_once(self, source_name: str, key: str, text: str) -> None:
"""有界节流: 键由上游决定,长跑 worker 不能让集合无限增长(设计 §5.4)。"""
if (source_name, key) in self._warned_fields:
return
if len(self._warned_fields) >= _WARN_CAP:
if not self._warn_saturated:
self._warn_saturated = True
logger.warning("方言告警节流集合已饱和({}),后续同类告警省略", _WARN_CAP)
return
self._warned_fields.add((source_name, key))
# 单参数传入 loguru: 文案可能含上游给的花括号(同 `_warn_on_thinking_mismatch`)
logger.warning("源 {} —— {}", source_name, text)
def _finalize_text(
self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile
@@ -759,6 +900,9 @@ class OpenAICompatTransport:
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8)
同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走
TransportResult 默认 None(排除条款)。
无流完整性可言,直接以 body 的 finish_reason 走结局判定;保真模式**先判定
后构造快照**,否则半截工具在构造时就抛 Transient,到不了 `tool_truncated`。
"""
resp = await client.post(url, json=payload)
if resp.status_code != 200:
@@ -772,24 +916,16 @@ class OpenAICompatTransport:
f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat"
) from exc
_check_chat_error(body)
completion_result = parse_chat_completion(body) if response_mode == "openai" else None
choices = body.get("choices") or []
if not choices:
raise TransientError(
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
)
if not isinstance(choices, list) or not isinstance(choices[0], dict):
raise TransientError("chat choices 形态非法", operation="chat")
message = choices[0].get("message", {})
if not isinstance(message, dict) or any(
message.get(key) is not None and not isinstance(message[key], str)
for key in ("content", "reasoning_content")
):
raise TransientError("chat message 形态非法", operation="chat")
message, finish = _nonstream_message(body, response_mode, source)
content, thinking = self._finalize_text(
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile
[message.get("content") or ""], [_reasoning_of(message)], profile
)
self._warn_dialect(source, frozenset(), finish)
raw_message = message if response_mode == "openai" else None
_raise_unless_deliverable(response_mode, raw_message, content, thinking, finish, source)
completion_result = (
build_completion(message, body, finish) if response_mode == "openai" else None
)
self._reject_empty_completion(content, completion_result, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
return TransportResult(
@@ -810,6 +946,7 @@ class OpenAICompatTransport:
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
finish_reason=finish,
)
async def aclose(self) -> None:
+263 -42
View File
@@ -425,6 +425,13 @@ class _CallContext:
ResponseMode = Literal["text", "openai"]
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
"""流式结束方式(1.3.10, #31): `done` = 收到 `[DONE]`;`eof_after_finish` = 未发
`[DONE]` 但 finish_reason 与其后的实测 usage 齐全、连接干净关闭;`salvaged` = 文本
模式按 `missing_done=salvage` 打捞的截断结果。非流式/缓存命中/失败行为 `None`。"""
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
@dataclass(frozen=True)
class OpenAIChatCompletion:
@@ -438,56 +445,256 @@ class OpenAIChatCompletion:
def __post_init__(self) -> None:
"""校验协议快照;transport 翻译错误,缓存坏条目按 miss。"""
message = self.message
if not isinstance(message, dict) or message.get("role") != "assistant":
raise ValueError("completion message 必须含 assistant role")
for key in ("content", "reasoning_content"):
if message.get(key) is not None and not isinstance(message[key], str):
raise ValueError(f"completion {key} 必须为字符串或 null")
for value in (self.finish_reason, self.response_id):
if value is not None and not isinstance(value, str):
raise ValueError("completion 身份/结束原因必须为字符串或 null")
if self.usage is not None and not isinstance(self.usage, dict):
raise ValueError("completion usage 必须为对象或 null")
if "tool_calls" in message:
tools = message["tool_calls"]
if not isinstance(tools, list):
raise ValueError("completion tool_calls 必须为数组")
seen: set[str] = set()
for tool in tools:
if not isinstance(tool, dict):
raise ValueError("completion tool 必须为对象")
identity = tool.get("id")
fn = tool.get("function")
if (
not isinstance(identity, str)
or not identity
or identity in seen
or tool.get("type") != "function"
or not isinstance(fn, dict)
or not isinstance(fn.get("name"), str)
or not fn["name"]
or not isinstance(fn.get("arguments"), str)
):
raise ValueError("completion tool 身份/函数字段缺失、冲突或非法")
seen.add(identity)
_check_completion_shape(message, self.finish_reason, self.response_id, self.usage)
json.dumps({"message": message, "usage": self.usage}, allow_nan=False)
object.__setattr__(self, "message", copy.deepcopy(message))
object.__setattr__(self, "usage", copy.deepcopy(self.usage))
def _has_completion_output(content: str, completion: OpenAIChatCompletion | None) -> bool:
"""成功须有投影正文或完整工具;协议原文为空时不信任缓存旧投影。
def _check_completion_shape(message: Any, finish_reason: Any, response_id: Any, usage: Any) -> None:
"""快照字段形态校验;违约抛 ValueError(由 transport/缓存各自翻译)。"""
if not isinstance(message, dict) or message.get("role") != "assistant":
raise ValueError("completion message 必须含 assistant role")
if not all(_str_or_none(message.get(key)) for key in ("content", "reasoning_content")):
raise ValueError("completion content/reasoning_content 必须为字符串或 null")
if not (_str_or_none(finish_reason) and _str_or_none(response_id)):
raise ValueError("completion 身份/结束原因必须为字符串或 null")
if not isinstance(usage, dict | None):
raise ValueError("completion usage 必须为对象或 null")
if not isinstance(message.get("tool_calls", []), list | None):
raise ValueError("completion tool_calls 必须为数组或 null")
if not _tool_snapshot_ok(message):
raise ValueError("completion tool 身份/函数字段缺失、冲突或非法")
只判定产物存在性,不替代协议结构校验,也不收紧快照类型的构造用途。
transport 与缓存共用此规则,避免空响应从缓存旁路成功。
def _str_or_none(value: Any) -> bool:
"""协议字符串字段的形态判据: 字符串或 null。"""
return value is None or isinstance(value, str)
def _tool_ok(tool: Any, seen: set[str]) -> bool:
"""单个工具条目是否满足快照不变式;成立时登记其 id 供查重。"""
if not isinstance(tool, dict):
return False
identity, fn = tool.get("id"), tool.get("function")
ok = (
isinstance(identity, str)
and bool(identity)
and identity not in seen
and tool.get("type") == "function"
and isinstance(fn, dict)
and isinstance(fn.get("name"), str)
and bool(fn["name"])
and isinstance(fn.get("arguments"), str)
)
if ok:
seen.add(identity)
return ok
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool:
"""工具快照不变式(1.3.9 规则): 缺省/null 视无工具;null 元素跳过。
抽成纯判据而非留在快照构造里: 结局判定(#30)要在构造**之前**知道工具是否
成立——截断的半截工具归 ResultInvalid,构造时抛 Transient 就到不了那一行。
"""
if completion is not None:
if completion.message.get("tool_calls"):
return True
raw_content = completion.message.get("content")
if not isinstance(raw_content, str) or not raw_content.strip():
tools = message.get("tool_calls")
if tools is None:
return True
if not isinstance(tools, list):
return False
seen: set[str] = set()
return all(tool is None or _tool_ok(tool, seen) for tool in tools)
_FINISH_CLASSES: Mapping[str, FinishClass] = MappingProxyType(
{
"stop": "natural",
"tool_calls": "natural",
"function_call": "natural",
"length": "truncated",
"content_filter": "filtered",
"sensitive": "filtered", # GLM: 安全审核拦截
"network_error": "upstream_error", # GLM: 模型推理异常
"error": "upstream_error",
}
)
def _classify_finish(value: str | None) -> FinishClass:
"""finish_reason → 类别;大小写敏感(上游原值),未登记非空值为 unknown。"""
if value is None:
return "absent"
return _FINISH_CLASSES.get(value, "unknown")
def _stream_is_complete(
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
) -> StreamEnd | None:
"""流完整性(设计 §4.2): None = 截断。
缺 `[DONE]` 时只认"上游声明结束 + 其后到达的实测 usage": 前者证明生成结束,
后者证明流走到了 include_usage 的自然末尾(finish 之前的累计 usage 不算)。
"""
if done:
return "done"
if finish_reason is not None and usage_after_finish and usage_measured:
return "eof_after_finish"
return None
@dataclass(frozen=True)
class _OutputSummary:
"""一次结果的产物摘要(设计 §4.3 定义),判定的唯一输入形态。"""
any_output: bool
body_output: bool
tools_present: bool
tools_ok: bool
null_arguments: bool = False
"""工具快照仅因 `function.arguments` 全程 null 而不成立(无参函数方言,审查 I2)。"""
def _summarize_text(content: str, thinking: str) -> _OutputSummary:
"""文本模式: 产物只看投影正文与思考;空白也算产物,正文须非空白。"""
return _OutputSummary(
any_output=content != "" or thinking != "",
body_output=content.strip() != "",
tools_present=False,
tools_ok=True,
)
def _has_string(value: Any) -> bool:
"""递归查找任一非空字符串片段。"""
if isinstance(value, str):
return value != ""
if isinstance(value, dict):
return any(_has_string(item) for item in value.values())
if isinstance(value, list):
return any(_has_string(item) for item in value)
return False
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary:
"""保真模式: 任一非空字符串字段(role 除外)或工具条目即产物。"""
tools = message.get("tool_calls")
tools_present = isinstance(tools, list) and any(tool is not None for tool in tools)
tools_ok = _tool_snapshot_ok(message)
fields = {k: v for k, v in message.items() if k not in ("role", "tool_calls")}
return _OutputSummary(
any_output=tools_present or _has_string(fields),
body_output=projected_content.strip() != "" or (tools_present and tools_ok),
tools_present=tools_present,
tools_ok=tools_ok,
null_arguments=not tools_ok and _tool_snapshot_ok(_fill_null_arguments(message)),
)
def _fill_null_arguments(message: Mapping[str, Any]) -> dict[str, Any]:
"""仅供判定: 把 null arguments 视作空串后再验快照,区分"只缺参数"与其他缺陷。
不改写交付值——保真结果原样;此函数的产物只用于分类。
"""
tools = message.get("tool_calls")
if not isinstance(tools, list):
return dict(message)
filled = []
for tool in tools:
fn = tool.get("function") if isinstance(tool, dict) else None
if isinstance(fn, dict) and "arguments" in fn and fn["arguments"] is None:
tool = {**tool, "function": {**fn, "arguments": ""}}
filled.append(tool)
return {**message, "tool_calls": filled}
@dataclass(frozen=True)
class _Verdict:
"""结局判定;`deliver` 为真时另两字段为 None。"""
deliver: bool
error_kind: Literal["transient", "result_invalid"] | None
code: str | None
_DELIVER = _Verdict(True, None, None)
_EMPTY = _Verdict(False, "transient", "empty_completion")
_WITHOUT_CONTENT: Mapping[str, str] = MappingProxyType(
{"truncated": "length_without_content", "filtered": "filtered_without_content"}
)
def _judge_broken(
*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass
) -> _Verdict | None:
"""行 1-2: 上游自报失败、坏工具——先于产物判断,正文不得掩盖。"""
if finish == "upstream_error":
return _Verdict(False, "transient", "upstream_finish_error")
if mode == "openai" and summary.tools_present and not summary.tools_ok:
if finish == "truncated":
return _Verdict(False, "result_invalid", "tool_truncated")
if summary.null_arguments:
# 无参函数只发 arguments:null 是确定性方言: 重拉同样结果,不重试也不改写
return _Verdict(False, "result_invalid", "tool_arguments_null")
return _Verdict(False, "transient", "tool_incomplete")
return None
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict:
"""结局分类(设计 §4.3),自上而下首个匹配行生效。
确定性结局(length/过滤)在文本模式归 ResultInvalid 而非 Transient: 重抽只会
再花一份钱,还会把健康源推向熔断;保真模式原样交回,由下游按 finish 决定。
"""
broken = _judge_broken(mode=mode, summary=summary, finish=finish)
if broken is not None:
return broken
if summary.body_output:
return _DELIVER
if finish in _WITHOUT_CONTENT: # 行 3/6/7: 确定性结局
if mode == "openai":
return _DELIVER
return _Verdict(False, "result_invalid", _WITHOUT_CONTENT[finish])
# 行 4/8/9: 零产物恒为抖动;有产物无正文时,只有保真且上游明确声明结束才交回
if summary.any_output and mode == "openai" and finish != "absent":
return _DELIVER
return _EMPTY
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None:
"""缓存读写共用的 finish 口径: 顶层优先,缺省回退协议快照(1.3.9 条目无顶层)。"""
if finish_reason is None and completion is not None:
return completion.finish_reason
return finish_reason
def _is_cacheable(
*,
mode: ResponseMode,
content: str,
finish_reason: str | None,
stream_end: str | None,
completion: OpenAIChatCompletion | None,
) -> bool:
"""可缓存 ⊂ 可交付(设计 §6): 截断、空正文、非自然结束一律不进 TTL。
缓存回放的是"同一请求的确定答案";length/过滤/未知结束带随机性或不可证明
是自然结束,固化进 TTL 会把一次偶然变成必然(无缓存毒化铁律)。
"""
if stream_end not in (None, "done", "eof_after_finish"):
return False
if _classify_finish(finish_reason) not in ("natural", "absent"):
return False
if mode == "openai":
# 判原文而非投影: 协议原文为空时不信任缓存里的旧投影(1.3.9 同口径)
if completion is None:
return False
return isinstance(content, str) and bool(content.strip())
raw = completion.message.get("content")
summary = _summarize_message(completion.message, raw if isinstance(raw, str) else "")
# 原文与投影都须有正文: 原文空不信任旧投影;投影空(think 标签剥净)不固化空回答
has_tools = summary.tools_present and summary.tools_ok
return summary.body_output and (content.strip() != "" or has_tools)
return content.strip() != ""
@dataclass(frozen=True)
@@ -557,6 +764,13 @@ class LLMResponse:
completion: OpenAIChatCompletion | None = None
finish_reason: str | None = None
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
stream_end: StreamEnd | None = None
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
@dataclass(frozen=True)
class ChatRequest:
@@ -701,6 +915,13 @@ class TransportResult:
completion: OpenAIChatCompletion | None = None
finish_reason: str | None = None
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
stream_end: StreamEnd | None = None
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
@dataclass(frozen=True)
class SourceConfig:
+12 -4
View File
@@ -72,6 +72,8 @@ _EXPECTED_COLUMNS = [
# —— 1.3.8 沉默归因两列(issue #27)——
"stall_ms",
"stall_count",
# —— 1.3.10 结束方式(#31)——
"stream_end",
]
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
@@ -173,6 +175,8 @@ def _minimal_fields(call_id: str | None = None, **overrides) -> dict[str, object
# —— 1.3.8 两列: 沉默归因只属流式 chat 成功 attempt 行(issue #27),普通行 NULL ——
"stall_ms": None,
"stall_count": None,
# —— 1.3.10: 结束方式只属流式成功 attempt 行,普通行 NULL ——
"stream_end": None,
}
fields.update(overrides)
return fields
@@ -775,7 +779,7 @@ class TestCallerDimensionsAcceptance:
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 22 → 38 个 recorder 字段(加 created_at 共 39 个物理列),且新列追加在末尾
# 22 → 39 个 recorder 字段(加 created_at 共 40 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch(
schema_dsn,
@@ -1106,7 +1110,7 @@ class TestCallObservabilityColumnsAcceptance:
"""
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
"""27 列旧表 auto 补齐到 39 列(1.3.5 十列 + 1.3.8 两列),新行两类取值读得回,**历史行新增列一律 NULL**。
"""27 列旧表 auto 补齐到 40 列(1.3.5 十列 + 1.3.8 两列 + 1.3.10 一列),新行两类取值读得回,**历史行新增列一律 NULL**。
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
@@ -1148,7 +1152,7 @@ class TestCallObservabilityColumnsAcceptance:
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 26 → 38 个 recorder 字段(加 created_at 共 39 个物理列),新列追加在末尾:
# 26 → 39 个 recorder 字段(加 created_at 共 40 个物理列),新列追加在末尾:
# 列序与新建库一致才不会让两条升级路径分叉
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
@@ -1176,6 +1180,8 @@ class TestCallObservabilityColumnsAcceptance:
# 1.3.8 两列: 普通成功 attempt 行无沉默观测 → NULL
"stall_ms": None,
"stall_count": None,
# 1.3.10: 该行未传结束方式 → NULL
"stream_end": None,
}
assert dict(by_id["term"]) == {
"call_id": "term",
@@ -1192,6 +1198,8 @@ class TestCallObservabilityColumnsAcceptance:
# 1.3.8 两列: 终态行无 transport 观测 → NULL
"stall_ms": None,
"stall_count": None,
# 1.3.10: 终态行无流可言 → NULL
"stream_end": None,
}
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
assert dict(by_id["pre135"]) == {
@@ -1349,7 +1357,7 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 38 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
# 物理列 = 39 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS
+24
View File
@@ -30,6 +30,13 @@ def _resp(content="cached"):
return LLMResponse(content, "", "m", "p", 1, 2, 30, None, None, False, "orig")
def _const(response):
async def terminal(request):
return response
return terminal
@pytest.fixture
async def redis_cache():
import redis.asyncio as aioredis
@@ -82,6 +89,23 @@ class TestRealRedisRoundtrip:
assert first.cache_hit is False and second.cache_hit is True
assert calls["n"] == 1
async def test_completion_verdict_fields_roundtrip(self, redis_cache, namespace):
"""1.3.10: finish_reason/stream_end 经真实 Redis 往返;不可缓存结果不落库。"""
mw = CacheMW(
backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60
)
from dataclasses import replace
ok = replace(_resp(), finish_reason="stop", stream_end="eof_after_finish")
first = await mw(ChatRequest(messages=_MSGS), _const(ok))
second = await mw(ChatRequest(messages=_MSGS), _const(ok))
assert first.cache_hit is False and second.cache_hit is True
assert second.finish_reason == "stop" and second.stream_end is None
truncated = replace(_resp(), stream_end="salvaged")
other = [{"role": "user", "content": "integration-salvaged"}]
await mw(ChatRequest(messages=other), _const(truncated))
assert await redis_cache.get(build_cache_key("m", other, namespace, None)) is None
async def test_namespace_isolation_on_shared_instance(self, redis_cache, namespace):
"""多项目共用同一 Redis 时,namespace 不同绝不互相命中(防毒化铁律)。"""
other = f"pgw:test:{uuid.uuid4().hex}"
+125
View File
@@ -1002,3 +1002,128 @@ async def test_openai_tool_only_cache_hits_without_origin():
hit = await mw(request, terminal)
assert hit.cache_hit and terminal.calls == 0
assert hit.completion == completion
# —— 1.3.10(#30/#31): 可缓存 ⊂ 可交付,读写共用同一判据(设计 §6)——
_OK_COMPLETION = OpenAIChatCompletion({"role": "assistant", "content": "ok"}, "stop", "up", None)
_THINK_LENGTH = OpenAIChatCompletion(
{"role": "assistant", "reasoning_content": "t"}, "length", "up", None
)
_THINK_STOP = OpenAIChatCompletion(
{"role": "assistant", "reasoning_content": "t"}, "stop", "up", None
)
@pytest.mark.parametrize(
("mode", "response"),
[
("text", _resp(stream_end="salvaged")),
("text", _resp(finish_reason="length")),
("text", _resp(finish_reason="content_filter")),
("text", _resp(finish_reason="end_turn")),
("openai", _resp(content="", finish_reason="length", completion=_THINK_LENGTH)),
("openai", _resp(content="", finish_reason="stop", completion=_THINK_STOP)),
],
)
async def test_uncacheable_results_are_delivered_but_not_written(mode, response):
"""截断/空正文/非自然结束: 照常交回,但不写缓存,下次回源。"""
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(response)
request = ChatRequest(messages=_MSGS, response_mode=mode)
assert (await mw(request, terminal)) is response
assert (await mw(request, terminal)).cache_hit is False
assert terminal.calls == 2 and not backend._store
def _stored(mode, **fields):
"""直接注入一条缓存原文(模拟旧版本或别的进程写入)。"""
backend = InMemoryCache()
data = dataclasses.asdict(_resp(**fields))
data.pop("structured_data", None)
data.pop("call_stats", None)
key = build_cache_key("m", _MSGS, "proj", None, response_mode=mode)
backend._store[key] = (float("inf"), json.dumps(data))
return backend, key
async def _hit(backend, mode):
terminal = _Terminal(_resp(content="fresh", completion=_OK_COMPLETION, finish_reason="stop"))
response = await _mw(backend)(ChatRequest(messages=_MSGS, response_mode=mode), terminal)
return response, terminal.calls
@pytest.mark.parametrize(
("mode", "fields"),
[
("text", {"stream_end": "salvaged"}),
("text", {"stream_end": "bogus"}),
("text", {"finish_reason": "length"}),
("text", {"finish_reason": 5}),
("openai", {"content": "", "completion": _THINK_LENGTH, "finish_reason": "length"}),
],
)
async def test_injected_uncacheable_entries_read_as_miss(mode, fields):
"""读路径对存储原值判定(覆盖之前): 不可缓存即 miss 回源。"""
backend, _ = _stored(mode, **fields)
response, calls = await _hit(backend, mode)
assert response.cache_hit is False and calls == 1
async def test_legacy_fidelity_length_entry_falls_back_to_completion_finish():
"""1.3.9 保真条目无顶层 finish: 回退 completion.finish_reason 识别为不可缓存。"""
completion = OpenAIChatCompletion({"role": "assistant", "content": "a"}, "length", None, None)
backend, key = _stored("openai", completion=completion)
data = json.loads(backend._store[key][1])
for name in ("finish_reason", "stream_end"):
data.pop(name)
backend._store[key] = (float("inf"), json.dumps(data))
response, calls = await _hit(backend, "openai")
assert response.cache_hit is False and calls == 1
async def test_legacy_text_entry_without_new_fields_still_hits():
"""1.3.9 文本条目无新键: 仍命中;命中 stream_end=None。"""
backend, key = _stored("text")
data = json.loads(backend._store[key][1])
for name in ("finish_reason", "stream_end"):
data.pop(name)
backend._store[key] = (float("inf"), json.dumps(data))
response, calls = await _hit(backend, "text")
assert response.cache_hit is True and calls == 0
assert response.stream_end is None and response.finish_reason is None
async def test_hit_resets_stream_end_and_keeps_effective_finish():
"""命中: stream_end 置 None;finish_reason 取判定所用值(顶层缺省时回退)。"""
backend, key = _stored("openai", completion=_OK_COMPLETION, stream_end="eof_after_finish")
data = json.loads(backend._store[key][1])
data.pop("finish_reason")
backend._store[key] = (float("inf"), json.dumps(data))
response, calls = await _hit(backend, "openai")
assert response.cache_hit is True and calls == 0
assert response.stream_end is None and response.finish_reason == "stop"
async def test_new_fields_roundtrip_through_cache():
"""两新字段随序列化往返;命中只重置 stream_end。"""
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(_resp(finish_reason="stop", stream_end="eof_after_finish"))
await mw(ChatRequest(messages=_MSGS), terminal)
stored = json.loads(next(iter(backend._store.values()))[1])
assert stored["finish_reason"] == "stop" and stored["stream_end"] == "eof_after_finish"
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.cache_hit is True and hit.finish_reason == "stop" and hit.stream_end is None
async def test_fidelity_think_only_projection_not_cached():
"""审查 I1: qwen think 标签剥离后投影为空的保真结果不写缓存。"""
completion = OpenAIChatCompletion(
{"role": "assistant", "content": "<think>x</think>"}, "stop", "up", None
)
response = _resp(content="", thinking="x", finish_reason="stop", completion=completion)
backend = InMemoryCache()
await _mw(backend)(ChatRequest(messages=_MSGS, response_mode="openai"), _Terminal(response))
assert not backend._store
+453
View File
@@ -0,0 +1,453 @@
"""统一完成判定在 transport 两条路径上的落点(#30/#31,设计 §4.2-§4.4、§5.4)。
假 SSE 按 magpie/glm 实测形态二次构造: 末块 finish_reason + usage 后干净关闭、
不发 [DONE];以及只有思考的 length 截断、null/未知字段等方言。
"""
import asyncio
import json
import httpx
import pytest
from loguru import logger
from polygateway.errors import AllSourcesExhausted, ResultInvalidError, TransientError
from polygateway.transports.openai_compat import OpenAICompatTransport
from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for
_USAGE = {"prompt_tokens": 11, "completion_tokens": 7}
_TOOL = {
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": 1}'},
}
def _f(delta=None, finish=None, usage=None, choices=True):
"""单 choice 帧;`choices=False` 构造 usage-only 帧。"""
body = {"id": "up-1"}
if choices:
choice = {"index": 0, "delta": delta or {}}
if finish is not None:
choice["finish_reason"] = finish
body["choices"] = [choice]
else:
body["choices"] = []
if usage is not None:
body["usage"] = usage
return f"data: {json.dumps(body)}\n\n"
def _assistant(**delta):
return _f({"role": "assistant", **delta})
async def _run(frames, *, done, mode="text", **source_kw):
transport = _transport_for(lambda _: _sse_stream(*frames, done=done))
try:
return await _complete(transport, _source(**source_kw), response_mode=mode)
finally:
await transport.aclose()
async def _run_once(body, *, mode="text"):
transport = _transport_for(lambda _: httpx.Response(200, json=body))
try:
return await _complete(transport, _source(), stream=False, response_mode=mode)
finally:
await transport.aclose()
# —— #31: 缺 [DONE] 但 finish + 其后实测 usage 完整 ——
@pytest.mark.parametrize("mode", ["text", "openai"])
@pytest.mark.parametrize("finish", ["stop", "length"])
async def test_eof_after_finish_with_usage_succeeds(mode, finish):
"""验收 1: 末块 finish + usage 后干净关闭 → 成功、measured、eof_after_finish。"""
frames = [_assistant(content="hel"), _f({"content": "lo"}, finish=finish, usage=_USAGE)]
result = await _run(frames, done=False, mode=mode)
assert result.content == "hello" and result.stream_end == "eof_after_finish"
assert result.usage_source == "measured" and result.finish_reason == finish
assert (result.prompt_tokens, result.completion_tokens) == (11, 7)
if mode == "openai":
assert result.completion.finish_reason == finish
async def test_eof_after_finish_usage_only_frame_and_tools():
"""验收 2: tool_calls 结束、usage 在其后的 usage-only 帧 → 工具完整交回。"""
frames = [
_assistant(content=None, tool_calls=[_TOOL]),
_f({}, finish="tool_calls"),
_f(choices=False, usage=_USAGE),
]
result = await _run(frames, done=False, mode="openai")
tool = result.completion.message["tool_calls"][0]
assert tool["function"] == {"name": "lookup", "arguments": '{"q": 1}'}
assert result.stream_end == "eof_after_finish" and result.usage_source == "measured"
@pytest.mark.parametrize(
"frames",
[
[_assistant(content="partial"), _f(choices=False, usage=_USAGE)], # 验收 3: 无 finish
[_assistant(content="partial"), _f({}, finish="stop")], # 验收 4: 无 usage
[_assistant(content="a", **{}), _f({}, usage=_USAGE), _f({}, finish="stop")], # usage 早
[_assistant(content="a"), _f({}, finish="stop", usage={})], # usage 不可用
],
)
async def test_incomplete_eof_still_fails(frames):
"""截断不放宽: 保真仍不打捞,文本 retry 档仍抛。"""
with pytest.raises(TransientError, match="missing_done"):
await _run(frames, done=False, mode="openai", missing_done="salvage")
with pytest.raises(TransientError, match="missing_done"):
await _run(frames, done=False)
async def test_text_salvage_marks_stream_end():
"""文本 salvage 打捞 → stream_end=salvaged,usage 降为 estimated。"""
frames = [_assistant(content="partial"), _f(choices=False, usage=_USAGE)]
result = await _run(frames, done=False, missing_done="salvage")
assert result.stream_end == "salvaged" and result.usage_source == "estimated"
async def test_read_error_mid_stream_fails():
"""验收 5: 读取中途 RemoteProtocolError → 失败,不当干净 EOF。"""
class Broken(httpx.AsyncByteStream):
async def __aiter__(self):
yield _f({"role": "assistant", "content": "a"}, finish="stop", usage=_USAGE).encode()
raise httpx.RemoteProtocolError("peer closed")
async def aclose(self):
return None
transport = _transport_for(lambda _: httpx.Response(200, stream=Broken()))
try:
with pytest.raises(TransientError, match="网络错误"):
await _complete(transport, _source(), response_mode="openai")
finally:
await transport.aclose()
@pytest.mark.parametrize("mode", ["text", "openai"])
async def test_done_stream_marks_done(mode):
"""验收 6: 带 [DONE] 的正常流行为不变,stream_end=done。"""
result = await _run([_assistant(content="ok"), _f({}, "stop", _USAGE)], done=True, mode=mode)
assert result.stream_end == "done" and result.finish_reason == "stop"
@pytest.mark.parametrize("done", [True, False])
@pytest.mark.parametrize("mode", ["text", "openai"])
async def test_upstream_error_finish_is_transient(done, mode):
"""GLM network_error: 上游自报失败,有正文也不交付。"""
frames = [_assistant(content="half"), _f({}, finish="network_error", usage=_USAGE)]
with pytest.raises(TransientError, match="upstream_finish_error"):
await _run(frames, done=done, mode=mode)
# —— #30 与结局表(设计 §4.3)——
_THINK = [_assistant(reasoning_content="long thought")]
@pytest.mark.parametrize("done", [True, False])
async def test_openai_reasoning_only_length_delivered(done):
"""#30 验收: 只有思考 + length → 交回,content 空,思考完整。"""
result = await _run([*_THINK, _f({}, "length", _USAGE)], done=done, mode="openai")
message = result.completion.message
assert message.get("content") in (None, "") and message["reasoning_content"] == "long thought"
assert result.finish_reason == "length" and result.thinking == "long thought"
@pytest.mark.parametrize(
("frames", "finish", "openai_ok", "text_error"),
[
(_THINK, "length", True, (ResultInvalidError, "length_without_content")),
([_assistant()], "length", True, (ResultInvalidError, "length_without_content")),
(_THINK, "content_filter", True, (ResultInvalidError, "filtered_without_content")),
([_assistant()], "sensitive", True, (ResultInvalidError, "filtered_without_content")),
(_THINK, "stop", True, (TransientError, "empty_completion")),
([_assistant(refusal="no")], "stop", True, (TransientError, "empty_completion")),
([_assistant()], "stop", False, (TransientError, "empty_completion")),
],
)
async def test_outcome_rows_stream(frames, finish, openai_ok, text_error):
"""行 3/4/6/7/8: 两模式的分叉。"""
full = [*frames, _f({}, finish, _USAGE)]
if openai_ok:
assert (await _run(full, done=True, mode="openai")).finish_reason == finish
else:
with pytest.raises(TransientError, match="empty_completion"):
await _run(full, done=True, mode="openai")
error, code = text_error
with pytest.raises(error, match=code) as caught:
await _run(full, done=True)
assert caught.type is error # ResultInvalid 不得被 Transient 子类掩盖
@pytest.mark.parametrize("mode", ["text", "openai"])
async def test_reasoning_only_without_finish_is_transient(mode):
"""行 9: 只靠 DONE 收尾、无 finish → 无法区分抖动与结局,维持瞬时。"""
with pytest.raises(TransientError, match="empty_completion"):
await _run([*_THINK, _f(choices=False, usage=_USAGE)], done=True, mode=mode)
async def test_openai_whitespace_content_stop_delivered():
"""正文只有空白 + stop: 保真交回(#30 一-2)。"""
result = await _run(
[_assistant(content=" "), _f({}, "stop", _USAGE)], done=True, mode="openai"
)
assert result.completion.message["content"] == " "
async def test_truncated_tool_is_result_invalid():
"""行 2: length 截断的半截工具(缺 name)→ ResultInvalid tool_truncated。"""
broken = {"index": 0, "id": "c", "type": "function", "function": {"arguments": '{"q'}}
frames = [_assistant(tool_calls=[broken]), _f({}, "length", _USAGE)]
with pytest.raises(ResultInvalidError, match="tool_truncated"):
await _run(frames, done=True, mode="openai")
async def test_content_cannot_mask_broken_tool():
"""行 2: 正文非空 + 坏工具 + stop → Transient tool_incomplete。"""
broken = {"index": 0, "id": "c", "type": "function", "function": {"arguments": "{}"}}
frames = [_assistant(content="text", tool_calls=[broken]), _f({}, "stop", _USAGE)]
with pytest.raises(TransientError, match="tool_incomplete"):
await _run(frames, done=True, mode="openai")
async def test_null_refusal_and_tool_calls_stream():
"""#30 验收二: delta 带 refusal:null 与 tool_calls:null → 正常交回。"""
frames = [_assistant(content="ok", refusal=None, tool_calls=None), _f({}, "stop", _USAGE)]
result = await _run(frames, done=True, mode="openai")
assert result.completion.message["content"] == "ok"
# —— 非流式镜像 ——
def _body(message, finish):
choice = {"index": 0, "message": {"role": "assistant", **message}}
if finish is not None:
choice["finish_reason"] = finish
return {"id": "up", "choices": [choice], "usage": _USAGE}
async def test_nonstream_null_tool_calls_and_refusal():
"""非流式 message 带 tool_calls:null + refusal:null → 成功。"""
body = _body({"content": "hi", "tool_calls": None, "refusal": None}, "stop")
result = await _run_once(body, mode="openai")
assert result.completion.message["tool_calls"] is None and result.stream_end is None
@pytest.mark.parametrize(
("message", "finish", "openai_expect", "text_expect"),
[
(
{"reasoning_content": "t"},
"length",
None,
(ResultInvalidError, "length_without_content"),
),
({"content": ""}, "content_filter", None, (ResultInvalidError, "filtered_without_content")),
({"reasoning_content": "t"}, "stop", None, (TransientError, "empty_completion")),
({"reasoning_content": "t"}, None, (TransientError, "empty_completion"), None),
({"content": "a"}, "network_error", (TransientError, "upstream_finish_error"), None),
({"content": "a"}, "length", None, None),
],
)
async def test_outcome_rows_nonstream(message, finish, openai_expect, text_expect):
"""非流式跳过流完整性,直接以 body finish 走结局表。"""
body = _body(message, finish)
for mode, expect in (("openai", openai_expect), ("text", text_expect or openai_expect)):
if expect is None:
result = await _run_once(body, mode=mode)
assert result.finish_reason == finish and result.stream_end is None
else:
with pytest.raises(expect[0], match=expect[1]) as caught:
await _run_once(body, mode=mode)
assert caught.type is expect[0]
async def test_nonstream_truncated_tool_is_result_invalid():
"""非流式先判定后构造快照: 缺 name + length → tool_truncated 而非构造期 Transient。"""
tool = {"id": "c", "type": "function", "function": {"arguments": "{"}}
with pytest.raises(ResultInvalidError, match="tool_truncated"):
await _run_once(_body({"tool_calls": [tool]}, "length"), mode="openai")
# —— reasoning 别名(设计 §4.4)——
async def test_reasoning_alias_projects_thinking():
"""只有 reasoning 字段: 文本 thinking 取别名;非空 reasoning_content 优先。"""
alias = await _run(
[_assistant(reasoning="r1"), _f({"content": "a"}, "stop", _USAGE)], done=True
)
assert alias.thinking == "r1"
both = [
_assistant(reasoning="r1", reasoning_content="rc"),
_f({"content": "a"}, "stop", _USAGE),
]
assert (await _run(both, done=True)).thinking == "rc"
empty_rc = [_assistant(reasoning="r1", reasoning_content=""), _f({"content": "a"}, "stop")]
assert (await _run(empty_rc, done=True)).thinking == "r1"
nonstream = await _run_once(_body({"content": "a", "reasoning": "nr"}, "stop"))
assert nonstream.thinking == "nr"
async def test_reasoning_alias_keeps_ttft_alive():
"""只有 reasoning 的思考期刷新活性,不被 TTFT 看门狗误杀。"""
class Slow(httpx.AsyncByteStream):
async def __aiter__(self):
yield _assistant().encode()
for _ in range(5): # 0.5s 只有 reasoning,远超 ttft,但每帧间隔 < inter_token
yield _f({"reasoning": "t"}).encode()
await asyncio.sleep(0.1)
yield _f({"content": "answer"}, "stop", _USAGE).encode()
yield b"data: [DONE]\n\n"
async def aclose(self):
return None
transport = _transport_for(lambda _: httpx.Response(200, stream=Slow()))
try:
source = _source(ttft_timeout_s=0.3, inter_token_timeout_s=0.25)
result = await _complete(transport, source)
assert result.content == "answer" and result.thinking == "ttttt"
finally:
await transport.aclose()
# —— 有界告警节流(设计 §5.4)——
def _sink():
records = []
handle = logger.add(lambda m: records.append(m.record["message"]), level="WARNING")
return records, handle
async def test_conflict_and_unknown_finish_warn_once():
"""同源同路径冲突、同一未知 finish 值各只告警一次,不回显字段值。"""
frames = [
_assistant(content="a", ext=1),
_f({"ext": 2}),
_f({}, "end_turn", _USAGE),
]
transport = _transport_for(lambda _: _sse_stream(*frames))
records, handle = _sink()
try:
for _ in range(2):
result = await _complete(transport, _source(), response_mode="openai")
assert "ext" not in result.completion.message
finally:
logger.remove(handle)
await transport.aclose()
conflict = [r for r in records if "delta.ext" in r]
unknown = [r for r in records if "end_turn" in r]
assert len(conflict) == 1 and len(unknown) == 1 and "不缓存" in unknown[0]
async def test_warning_set_is_bounded():
"""节流集合饱和后只记一次饱和告警,不再增长。"""
transport = OpenAICompatTransport()
transport._warned_fields.update((f"s{i}", "k") for i in range(1024))
records, handle = _sink()
try:
transport._warn_once("qwen_1", "field:x", "a")
transport._warn_once("qwen_1", "field:y", "b")
finally:
logger.remove(handle)
await transport.aclose()
assert len(transport._warned_fields) == 1024
assert sum("饱和" in r for r in records) == 1
# —— 治理链: 确定性结局不重试、不计熔断 ——
async def test_text_length_without_content_not_retried_nor_breaker_failure():
"""文本 length 无正文 → ResultInvalid 直抛,attempts=1,熔断不记失败。"""
from tests.unit.test_client import _client
calls = []
def handler(_):
calls.append(1)
return _sse_stream(*_THINK, _f({}, "length", _USAGE))
client = _client(handler=handler)
failures = []
original = client._breaker_backend.record_failure
async def spy(*args, **kwargs):
failures.append(args)
return await original(*args, **kwargs)
client._breaker_backend.record_failure = spy
try:
with pytest.raises(ResultInvalidError, match="length_without_content"):
await client.chat([{"role": "user", "content": "hi"}])
finally:
await client._transport.aclose()
assert len(calls) == 1 and failures == []
async def test_openai_reasoning_length_single_attempt_via_client():
"""#30 验收经治理链: attempts=1;#31 形态经治理链不再重试到耗尽。"""
from tests.unit.test_client import _client
calls = []
def handler(_):
calls.append(1)
return _sse_stream(*_THINK, _f({}, "length", _USAGE), done=False)
client = _client(handler=handler)
try:
response = await client.chat([{"role": "user", "content": "hi"}], response_mode="openai")
except AllSourcesExhausted: # pragma: no cover - 失败即回归
pytest.fail("reasoning-only length 不应重试到耗尽")
finally:
await client._transport.aclose()
assert len(calls) == 1 and response.call_stats.attempts == 1
assert response.stream_end == "eof_after_finish" and response.finish_reason == "length"
async def test_eof_after_finish_stop_single_attempt_via_client():
"""#31 验收 1 经治理链: stop + usage 无 DONE,attempts=1、不重试到耗尽。"""
from tests.unit.test_client import _client
calls = []
def handler(_):
calls.append(1)
return _sse_stream(_assistant(content="ok"), _f({}, "stop", _USAGE), done=False)
client = _client(handler=handler)
try:
response = await client.chat([{"role": "user", "content": "hi"}])
finally:
await client._transport.aclose()
assert len(calls) == 1 and response.call_stats.attempts == 1
assert response.stream_end == "eof_after_finish" and response.usage_source == "measured"
async def test_text_salvage_with_length_partial_content_delivered():
"""文本 salvage + length + 部分正文(finish 后无 usage): 交付、salvaged。"""
frames = [_assistant(content="part"), _f({}, finish="length")]
result = await _run(frames, done=False, missing_done="salvage")
assert result.content == "part" and result.stream_end == "salvaged"
assert result.finish_reason == "length"
async def test_null_arguments_tool_is_result_invalid_stream():
"""审查 I2: 无参函数 arguments 全程 null → ResultInvalid,不重试。"""
tool = {"index": 0, "id": "c", "type": "function", "function": {"name": "f", "arguments": None}}
frames = [_assistant(tool_calls=[tool]), _f({}, "tool_calls", _USAGE)]
with pytest.raises(ResultInvalidError, match="tool_arguments_null"):
await _run(frames, done=True, mode="openai")
+265
View File
@@ -0,0 +1,265 @@
"""统一完成判定(#30/#31): 内核纯函数真值表。"""
import pytest
from polygateway.types import (
LLMResponse,
OpenAIChatCompletion,
TransportResult,
_classify_finish,
_effective_finish,
_is_cacheable,
_judge_outcome,
_OutputSummary,
_stream_is_complete,
_summarize_message,
_summarize_text,
_tool_snapshot_ok,
)
_TOOL = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
@pytest.mark.parametrize(
("value", "expected"),
[
("stop", "natural"),
("tool_calls", "natural"),
("function_call", "natural"),
("length", "truncated"),
("content_filter", "filtered"),
("sensitive", "filtered"),
("network_error", "upstream_error"),
("error", "upstream_error"),
("end_turn", "unknown"),
("STOP", "unknown"),
(None, "absent"),
],
)
def test_classify_finish(value, expected):
assert _classify_finish(value) == expected
@pytest.mark.parametrize(
("done", "finish", "after", "measured", "expected"),
[
(True, None, False, False, "done"),
(True, "stop", True, True, "done"),
(False, "stop", True, True, "eof_after_finish"),
(False, "length", True, True, "eof_after_finish"),
(False, None, True, True, None), # 无 finish 即截断
(False, "stop", False, True, None), # usage 早于 finish(累计 usage)
(False, "stop", True, False, None), # finish 后 usage 不可用
],
)
def test_stream_is_complete(done, finish, after, measured, expected):
got = _stream_is_complete(
done=done, finish_reason=finish, usage_after_finish=after, usage_measured=measured
)
assert got == expected
def test_tool_snapshot_ok():
assert _tool_snapshot_ok({"role": "assistant"})
assert _tool_snapshot_ok({"tool_calls": None})
assert _tool_snapshot_ok({"tool_calls": [None, _TOOL]})
assert not _tool_snapshot_ok({"tool_calls": {}})
assert not _tool_snapshot_ok({"tool_calls": [{"id": "t1", "type": "function"}]})
assert not _tool_snapshot_ok({"tool_calls": [_TOOL, _TOOL]}) # 重复 id
def test_summarize_text():
assert _summarize_text("", "") == _OutputSummary(False, False, False, True)
assert _summarize_text(" ", "") == _OutputSummary(True, False, False, True)
assert _summarize_text("", "think") == _OutputSummary(True, False, False, True)
assert _summarize_text("hi", "") == _OutputSummary(True, True, False, True)
@pytest.mark.parametrize(
("message", "projected", "expected"),
[
({"role": "assistant"}, "", _OutputSummary(False, False, False, True)),
({"role": "assistant", "content": None}, "", _OutputSummary(False, False, False, True)),
({"role": "assistant", "refusal": "no"}, "", _OutputSummary(True, False, False, True)),
(
{"role": "assistant", "reasoning_content": "r"},
"",
_OutputSummary(True, False, False, True),
),
({"role": "assistant", "content": " "}, " ", _OutputSummary(True, False, False, True)),
(
{"role": "assistant", "x": {"y": ["", "z"]}},
"",
_OutputSummary(True, False, False, True),
),
(
{"role": "assistant", "tool_calls": [None]},
"",
_OutputSummary(False, False, False, True),
),
(
{"role": "assistant", "tool_calls": [_TOOL]},
"",
_OutputSummary(True, True, True, True),
),
(
{"role": "assistant", "tool_calls": [{"id": "t"}]},
"",
_OutputSummary(True, False, True, False),
),
({"role": "assistant", "content": "a"}, "a", _OutputSummary(True, True, False, True)),
],
)
def test_summarize_message(message, projected, expected):
assert _summarize_message(message, projected) == expected
_NONE = _OutputSummary(False, False, False, True)
_THINK = _OutputSummary(True, False, False, True)
_BODY = _OutputSummary(True, True, False, True)
_BAD_TOOL = _OutputSummary(True, True, True, False)
@pytest.mark.parametrize(
("mode", "summary", "finish", "expected"),
[
# 行 1: upstream_error 优先于一切
("openai", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
("text", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
# 行 2: 坏工具(仅保真),正文不得掩盖
("openai", _BAD_TOOL, "truncated", ("result_invalid", "tool_truncated")),
("openai", _BAD_TOOL, "natural", ("transient", "tool_incomplete")),
("text", _BAD_TOOL, "natural", None),
# 行 3: 无产物 + truncated/filtered
("openai", _NONE, "truncated", None),
("openai", _NONE, "filtered", None),
("text", _NONE, "truncated", ("result_invalid", "length_without_content")),
("text", _NONE, "filtered", ("result_invalid", "filtered_without_content")),
# 行 4: 零增量抖动(2026-07-20 裁决)
("openai", _NONE, "natural", ("transient", "empty_completion")),
("openai", _NONE, "absent", ("transient", "empty_completion")),
("openai", _NONE, "unknown", ("transient", "empty_completion")),
("text", _NONE, "absent", ("transient", "empty_completion")),
# 行 5: 有正文
("openai", _BODY, "truncated", None),
("text", _BODY, "filtered", None),
("text", _BODY, "absent", None),
# 行 6-9: 有产物无正文
("openai", _THINK, "truncated", None),
("text", _THINK, "truncated", ("result_invalid", "length_without_content")),
("openai", _THINK, "filtered", None),
("text", _THINK, "filtered", ("result_invalid", "filtered_without_content")),
("openai", _THINK, "natural", None),
("openai", _THINK, "unknown", None),
("text", _THINK, "natural", ("transient", "empty_completion")),
("openai", _THINK, "absent", ("transient", "empty_completion")),
("text", _THINK, "absent", ("transient", "empty_completion")),
],
)
def test_judge_outcome_rows(mode, summary, finish, expected):
verdict = _judge_outcome(mode=mode, summary=summary, finish=finish)
if expected is None:
assert verdict.deliver and verdict.error_kind is None and verdict.code is None
else:
assert not verdict.deliver
assert (verdict.error_kind, verdict.code) == expected
def _completion(content="a", finish="stop", tools=None):
message = {"role": "assistant", "content": content}
if tools is not None:
message["tool_calls"] = tools
return OpenAIChatCompletion(message, finish, None, None)
@pytest.mark.parametrize(
("kwargs", "expected"),
[
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "done"}, True),
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": None}, True),
(
{
"mode": "text",
"content": "a",
"finish_reason": "stop",
"stream_end": "eof_after_finish",
},
True,
),
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": "salvaged"}, False),
({"mode": "text", "content": " ", "finish_reason": "stop", "stream_end": "done"}, False),
({"mode": "text", "content": "a", "finish_reason": "length", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "sensitive", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "end_turn", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "bogus"}, False),
],
)
def test_is_cacheable_text(kwargs, expected):
assert _is_cacheable(completion=None, **kwargs) is expected
def test_is_cacheable_openai():
ok = _completion()
assert _is_cacheable(
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=ok
)
assert not _is_cacheable(
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=None
)
# 原文为空且无工具: 不信任旧投影
assert not _is_cacheable(
mode="openai",
content="a",
finish_reason="stop",
stream_end="done",
completion=_completion(content=""),
)
tool_only = _completion(content=None, finish="tool_calls", tools=[_TOOL])
assert _is_cacheable(
mode="openai", content="", finish_reason="tool_calls", stream_end=None, completion=tool_only
)
def test_effective_finish():
assert _effective_finish("stop", _completion(finish="length")) == "stop"
assert _effective_finish(None, _completion(finish="length")) == "length"
assert _effective_finish(None, None) is None
def test_completion_accepts_null_tool_calls():
snap = OpenAIChatCompletion({"role": "assistant", "tool_calls": None}, "stop", None, None)
assert "tool_calls" in snap.message and snap.message["tool_calls"] is None
with pytest.raises(ValueError):
OpenAIChatCompletion({"role": "assistant", "tool_calls": {}}, "stop", None, None)
def test_response_fields_default_none():
result = TransportResult("a", "", 0, 0, "unavailable", None, None, {})
assert result.finish_reason is None and result.stream_end is None
response = LLMResponse("a", "", "m", "p", 0, 0, 0, None, None, False, "c")
assert response.finish_reason is None and response.stream_end is None
def test_stream_end_exported():
import polygateway
assert "StreamEnd" in polygateway.__all__
def test_openai_cacheable_requires_projected_body():
"""审查 I1: think 标签剥离后投影为空,即使原文非空也不可缓存。"""
think_only = _completion(content="<think>x</think>")
assert not _is_cacheable(
mode="openai", content="", finish_reason="stop", stream_end="done", completion=think_only
)
def test_null_arguments_only_tool_is_result_invalid():
"""审查 I2: 无参函数 arguments 全程 null(其余齐全)→ 确定性方言,ResultInvalid。"""
tool = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": None}}
summary = _summarize_message({"role": "assistant", "tool_calls": [tool]}, "")
assert summary.null_arguments and not summary.tools_ok
verdict = _judge_outcome(mode="openai", summary=summary, finish="natural")
assert (verdict.error_kind, verdict.code) == ("result_invalid", "tool_arguments_null")
broken = {"id": "t1", "type": "function", "function": {"arguments": None}}
assert not _summarize_message({"role": "assistant", "tool_calls": [broken]}, "").null_arguments
+244 -3
View File
@@ -99,13 +99,13 @@ def test_incomplete_tool_is_not_success(change):
"delta",
[
[],
{"audio": {}},
{"tool_calls": {}},
{"content": []},
{"role": "user"},
{"tool_calls": [{"index": True}]},
{"tool_calls": [{"index": -1}]},
{"tool_calls": [{"index": 0, "extra": 1}]},
{"tool_calls": [{"index": 0, "function": {"arguments": None}}]},
{"tool_calls": [{"index": None}]},
{"tool_calls": [{"index": 0, "function": {"arguments": 12}}]},
],
)
def test_invalid_delta_is_domain_error(delta):
@@ -225,3 +225,244 @@ def test_fragmented_name_and_arguments_with_usage():
assert result.message["tool_calls"][0]["function"] == {"name": "echo", "arguments": " {} "}
assert result.usage["extra"]["nested"] == 9
assert result.finish_reason == "length"
# —— 1.3.10(#30): null 优先、未知字段无损合并、finish/usage 位置 ——
def _feed(state, *chunks):
"""依次喂入已解码帧,返回每帧的 ChatDelta。"""
return [state.add_chunk(c) for c in chunks]
def _frame(delta, finish=None, **top):
"""单 choice 已解码帧。"""
choice = {"index": 0, "delta": delta}
if finish is not None:
choice["finish_reason"] = finish
return {"choices": [choice], **top}
def test_null_known_and_unknown_fields_are_absent():
"""refusal/tool_calls/role/arguments 为 null 不判失败,键按缺省保留。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(
state,
_frame({"role": None, "refusal": None, "tool_calls": None, "content": None}),
_frame({"role": "assistant", "content": "hi"}, finish="stop"),
)
message = state.finish().message
assert message["refusal"] is None and message["content"] == "hi"
assert message["role"] == "assistant"
def test_role_only_null_fails_at_finish():
"""全程只有 role:null 即缺 role,照旧失败。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(state, _frame({"role": None, "content": "x"}, finish="stop"))
with pytest.raises(TransientError, match="role"):
state.finish()
def test_null_arguments_and_null_tool_elements():
"""arguments:null 视缺省;tool_calls 中 null 元素跳过。"""
state = ChatCompletionAccumulator(response_mode="openai")
tool = {"index": 0, "id": "a", "type": "function", "function": {"name": "f"}}
_feed(
state,
_frame({"role": "assistant", "tool_calls": [None, tool]}),
_frame({"tool_calls": [{"index": 0, "function": {"arguments": None}}]}),
_frame({"tool_calls": [{"index": 0, "function": {"arguments": "{}"}}]}, "tool_calls"),
)
assert state.finish().message["tool_calls"][0]["function"] == {"name": "f", "arguments": "{}"}
def test_null_choices_and_delta_are_absent():
"""chunk 层 choices:null 与 choice 层 delta:null 视缺省。"""
state = ChatCompletionAccumulator(response_mode="openai")
assert not state.add_chunk({"choices": None}).progress
assert not state.add_chunk({"choices": [{"index": 0, "delta": None}]}).progress
def test_reasoning_alias_is_progress_and_preserved():
"""只有 reasoning 字段: 计活性、交别名片段、保真原键保留。"""
for mode in ("openai", "text"):
state = ChatCompletionAccumulator(response_mode=mode)
delta = state.add_chunk(_frame({"role": "assistant", "reasoning": "think"}))
assert delta.progress and delta.reasoning == "think" and delta.reasoning_content == ""
state.add_chunk(_frame({"reasoning": " more"}))
openai = ChatCompletionAccumulator(response_mode="openai")
_feed(openai, _frame({"role": "assistant", "reasoning": "a"}), _frame({"reasoning": "b"}))
assert openai.raw_message()["reasoning"] == "ab"
def _detail(text, **extra):
return {"type": "reasoning.text", "index": 0, "format": "x-v1", "text": text, **extra}
def test_reasoning_details_merge_by_index_without_label_concat():
"""OpenRouter reasoning_details 分片: index 合并,format/id/type 不拼接。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(
state,
_frame({"role": "assistant", "reasoning_details": [_detail("he", id="r1")]}),
_frame({"reasoning_details": [_detail("llo", id="r1")]}),
_frame({"content": "ok"}, finish="stop"),
)
details = state.finish().message["reasoning_details"]
assert details == [
{"type": "reasoning.text", "index": 0, "format": "x-v1", "text": "hello", "id": "r1"}
]
assert not state.conflicts
def test_tool_level_nested_extension_preserved():
"""工具层未知嵌套对象(如 thought signature)原样保留以便回传。"""
state = ChatCompletionAccumulator(response_mode="openai")
extra = {"google": {"thought_signature": "sig"}}
tool = {"index": 0, "id": "a", "type": "function", "extra_content": extra}
_feed(
state,
_frame({"role": "assistant", "tool_calls": [{**tool, "function": {"name": "f"}}]}),
_frame({"tool_calls": [{"index": 0, "function": {"arguments": "{}", "strict": True}}]}),
)
snapshot = state.finish().message["tool_calls"][0]
assert snapshot["extra_content"] == extra and snapshot["function"]["strict"] is True
@pytest.mark.parametrize(
("first", "second"),
[(1, 2), (True, False), ("s", {"o": 1}), ([{"index": 0, "t": "a"}], ["plain"])],
)
def test_conflict_drops_key_and_bans_path(first, second):
"""数值/布尔/形态冲突: 删键、登记路径、后续同路径丢弃、调用成功。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(
state,
_frame({"role": "assistant", "content": "a", "ext": first}),
_frame({"ext": second}),
_frame({"ext": first}, finish="stop"),
)
assert "ext" not in state.finish().message
assert state.conflicts == frozenset({"delta.ext"})
def test_plain_arrays_append_and_same_scalar_idempotent():
"""普通数组追加;同值标量幂等;追加数组中的字符串算活性。"""
state = ChatCompletionAccumulator(response_mode="openai")
deltas = _feed(
state,
_frame({"role": "assistant", "notes": ["a"], "n": 1}),
_frame({"notes": ["b", None], "n": 1}),
)
message = state.raw_message()
assert message["notes"] == ["a", "b"] and message["n"] == 1
assert all(d.progress for d in deltas[1:])
def test_one_progress_per_frame_with_many_fragments():
"""同帧多个字段只产生一次进展事件(ChatDelta 单值)。"""
state = ChatCompletionAccumulator(response_mode="text")
delta = state.add_chunk(_frame({"content": "a", "reasoning_content": "b", "x": "c"}))
assert delta.progress is True
def test_label_only_unknown_is_not_progress():
"""未知层仅标签键(type/format/id/index)不刷新活性。"""
state = ChatCompletionAccumulator(response_mode="openai")
assert not state.add_chunk(_frame({"meta": {"type": "x", "format": "y"}})).progress
@pytest.mark.parametrize(
("frames", "after"),
[
([_frame({"content": "a"}, usage={"x": 1}), _frame({}, finish="stop")], False),
([_frame({"content": "a"}, finish="stop", usage={"x": 1})], True),
([_frame({"content": "a"}, finish="stop"), {"choices": [], "usage": {"x": 1}}], True),
([_frame({"content": "a"}, finish="stop")], False),
],
)
def test_usage_after_finish_tracking(frames, after):
"""usage 必须在 finish 同帧或之后到达才算流走到自然末尾。"""
for mode in ("openai", "text"):
state = ChatCompletionAccumulator(response_mode=mode)
_feed(state, *frames)
assert state.finish_seen and state.finish_reason == "stop"
assert state.usage_after_finish is after
def test_text_mode_ignores_other_choice_index():
"""文本模式只取 index 缺省或为 0 的 choice,不混入 index=1。"""
state = ChatCompletionAccumulator(response_mode="text")
delta = state.add_chunk(
{
"choices": [
{"index": 1, "delta": {"content": "B"}, "finish_reason": "length"},
{"index": 0, "delta": {"content": "A"}},
]
}
)
assert delta.content == "A" and state.finish_reason is None
def test_text_mode_finish_non_string_ignored():
"""文本模式 finish 非字符串记 None,不判失败。"""
state = ChatCompletionAccumulator(response_mode="text")
state.add_chunk(_frame({"content": "a"}, finish=5))
assert state.finish_reason is None and not state.finish_seen
def test_nonstream_null_tool_elements_stripped():
"""非流式 tool_calls 中 null 元素剔除(回传 [null] 会被上游拒),null 列表原样保留。"""
tool = {"id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}
body = {
"choices": [{"message": {"role": "assistant", "tool_calls": [None, tool], "refusal": None}}]
}
assert parse_chat_completion(body).message["tool_calls"] == [tool]
body["choices"][0]["message"]["tool_calls"] = None
assert parse_chat_completion(body).message["tool_calls"] is None
@pytest.mark.parametrize(("index", "taken"), [(None, True), (0, True), (False, False), (1, False)])
def test_text_mode_choice_index_null_is_zero(index, taken):
"""审查 I3: 文本模式 index:null 视缺省(=0);bool 不冒充 0。"""
state = ChatCompletionAccumulator(response_mode="text")
delta = state.add_chunk(
{"choices": [{"index": index, "delta": {"content": "hi"}, "finish_reason": "stop"}]}
)
assert (delta.content == "hi") is taken and (state.finish_reason == "stop") is taken
def test_null_only_tool_elements_do_not_create_empty_list():
"""审查 Minor1: tool_calls null 后 [null] 不得产出会被上游拒收的空数组。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(
state,
_frame({"role": "assistant", "content": "a", "tool_calls": None}),
_frame({"tool_calls": [None]}, finish="stop"),
)
assert state.finish().message["tool_calls"] is None
body = {"choices": [{"message": {"role": "assistant", "content": "a", "tool_calls": [None]}}]}
assert parse_chat_completion(body).message["tool_calls"] is None
def test_tool_calls_null_then_list_merges():
"""tool_calls 先 null 后列表: 正常聚合工具。"""
state = ChatCompletionAccumulator(response_mode="openai")
tool = {"index": 0, "id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}
_feed(
state,
_frame({"role": "assistant", "tool_calls": None}),
_frame({"tool_calls": [tool]}, finish="tool_calls"),
)
assert state.finish().message["tool_calls"][0]["id"] == "a"
def test_nonstream_role_checked_before_verdict():
"""审查 Minor3: 非流式缺 role 是形态失败,先于结局判定。"""
from polygateway.transports._openai_completion import check_completion_body
with pytest.raises(TransientError, match="role"):
check_completion_body(
{"choices": [{"message": {"content": "a"}, "finish_reason": "length"}]}
)
+24 -2
View File
@@ -146,10 +146,10 @@ async def test_invalid_mode_combinations_fail_before_http(kwargs):
async def test_openai_never_salvages():
"""已收完整工具也必须有 DONE。"""
"""无 finish 后 usage 的截断流即使收全工具也不打捞(1.3.10: 判据由 DONE 改为完成判定)。"""
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), done=False))
try:
with pytest.raises(TransientError, match="DONE"):
with pytest.raises(TransientError, match="missing_done"):
await transport.complete(
messages=[],
source=_source(missing_done="salvage"),
@@ -165,6 +165,28 @@ async def test_openai_never_salvages():
await transport.aclose()
async def test_openai_finish_with_trailing_usage_is_complete_without_done():
"""对照: 同一工具流补上 finish 之后的 usage 帧,即使无 DONE 也完整交回(#31)。"""
usage = 'data: {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 4}}\n\n'
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), usage, done=False))
try:
result = await transport.complete(
messages=[],
source=_source(),
stream=True,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="openai",
)
finally:
await transport.aclose()
assert result.stream_end == "eof_after_finish"
assert [t["id"] for t in result.completion.message["tool_calls"]] == ["a", "b"]
async def test_tool_progress_sets_events():
"""工具首进展驱动两个事件与时间观测。"""
import asyncio
+3 -1
View File
@@ -150,6 +150,7 @@ class _DummyRecorder:
total_latency_ms,
stall_ms,
stall_count,
stream_end,
) -> None: ...
@@ -322,7 +323,7 @@ class TestTelemetryRecorderSignature:
"attempts",
"total_latency_ms",
} <= set(params)
assert len(params) - 1 == 38 # 减掉 self
assert len(params) - 1 == 39 # 减掉 self
@pytest.mark.parametrize(
"name",
@@ -341,6 +342,7 @@ class TestTelemetryRecorderSignature:
"error_body",
"attempts",
"total_latency_ms",
"stream_end",
],
)
def test_caller_dimensions_have_no_default(self, name):
+109 -13
View File
@@ -92,6 +92,8 @@ _EXPECTED_COLUMNS = [
# —— 1.3.8 沉默归因两列(issue #27)——
"stall_ms",
"stall_count",
# —— 1.3.10 结束方式(#31)——
"stream_end",
]
# 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用
@@ -187,6 +189,8 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
# —— 1.3.8 沉默归因两列: 默认 None = 非流式/无沉默观测(合法事实)——
"stall_ms": None,
"stall_count": None,
# —— 1.3.10 结束方式: 默认 None = 非流式/缓存命中/失败行 ——
"stream_end": None,
}
fields.update(overrides)
await recorder.record_llm_call(**fields)
@@ -268,7 +272,7 @@ class TestSchemaModule:
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
assert len(COLUMNS) == 38
assert len(COLUMNS) == 39
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
for ddl in (SQLITE_DDL, PG_DDL):
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
@@ -282,8 +286,8 @@ class TestSchemaModule:
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
)
assert PG_BACKFILL[-1] == (
"stall_count",
"ALTER TABLE llm_calls ADD COLUMN stall_count INTEGER",
"stream_end",
"ALTER TABLE llm_calls ADD COLUMN stream_end TEXT",
)
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
@@ -297,10 +301,10 @@ class TestSchemaModule:
assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT
assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT
# 全量 38 列: 占位符随列数增长,且不留空洞
assert insert_sql("postgres", COLUMNS).count("$") == 38
assert "$38)" in insert_sql("postgres", COLUMNS)
assert insert_sql("sqlite", COLUMNS).count("?") == 38
# 全量 39 列: 占位符随列数增长,且不留空洞
assert insert_sql("postgres", COLUMNS).count("$") == 39
assert "$39)" in insert_sql("postgres", COLUMNS)
assert insert_sql("sqlite", COLUMNS).count("?") == 39
# 裁剪列表按位置占位符重新编号,不留空洞
assert insert_sql("postgres", ["call_id", "model"]) == (
"INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING"
@@ -377,13 +381,14 @@ class TestBackendColumnParity:
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
from polygateway.telemetry.schema import COLUMNS
assert COLUMNS[-6:] == (
assert COLUMNS[-7:] == (
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
"stall_ms",
"stall_count",
"stream_end",
)
@@ -776,7 +781,7 @@ class TestSQLiteCallerDimensionsAcceptance:
conn = sqlite3.connect(db)
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
assert cols == _EXPECTED_COLUMNS # 22 → 38 个 recorder 字段(+ created_at 共 39 物理列)
assert cols == _EXPECTED_COLUMNS # 22 → 39 个 recorder 字段(+ created_at 共 40 物理列)
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
assert rows["new-row"] == "tenant-a"
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
@@ -826,7 +831,7 @@ class TestSQLiteSchemaMode:
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
补齐后 38 + `created_at` = 39。混用 INSERT 字段数与物理列数是本处最易错的地方。
补齐后 39 + `created_at` = 40。混用 INSERT 字段数与物理列数是本处最易错的地方。
"""
def _physical_columns(self, db: Path) -> list[str]:
@@ -865,7 +870,7 @@ class TestSQLiteSchemaMode:
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 39。"""
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 40。"""
db = tmp_path / "auto_legacy.db"
_make_pre_tenant_db(db)
@@ -874,10 +879,10 @@ class TestSQLiteSchemaMode:
recorder.close()
assert self._physical_columns(db) == _EXPECTED_COLUMNS
assert len(self._physical_columns(db)) == 39
assert len(self._physical_columns(db)) == 40
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
"""manual 只管 ALTER,不管 CREATE: 全新库照建,39 个物理列齐全(设计 §4.2)。"""
"""manual 只管 ALTER,不管 CREATE: 全新库照建,40 个物理列齐全(设计 §4.2)。"""
db = tmp_path / "manual_fresh.db"
recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
@@ -1117,6 +1122,8 @@ class TestPostgresBackfillDiscipline:
# 1.3.8 两列: 稳态定义随补列前移,否则每进程首写都去抢锁
"stall_ms",
"stall_count",
# 1.3.10 一列,理由同上
"stream_end",
]
def _recorder(self, conn):
@@ -3586,3 +3593,92 @@ class TestTerminalRowSqlSemantics:
).fetchone()[0]
== 0
)
# 1.3.8 形态(1.3.10 补列前)的表: 38 个 INSERT 字段 + created_at = 39 物理列。
# 冻结字面量(由 _PRE_138_DDL 末行补两列),不从常量切片派生,理由同 _PRE_138_DDL。
_PRE_1310_DDL = _PRE_138_DDL.replace(
" total_latency_ms INTEGER\n);",
" total_latency_ms INTEGER,\n stall_ms INTEGER,\n"
" stall_count INTEGER\n);",
)
class TestStreamEndColumn:
"""1.3.10 结束方式列(#31): 落行、缺省 NULL、补列幂等、manual 告警、各入口取值。"""
async def test_value_round_trips_and_default_is_null(self, tmp_path):
db = tmp_path / "stream_end.db"
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id="c-eof", stream_end="eof_after_finish")
await _record_minimal(recorder, call_id="c-none")
recorder.close()
conn = sqlite3.connect(db)
rows = dict(conn.execute("SELECT call_id, stream_end FROM llm_calls").fetchall())
conn.close()
assert rows == {"c-eof": "eof_after_finish", "c-none": None}
async def test_auto_migrate_backfills_from_138_table(self, tmp_path):
db = tmp_path / "pre1310.db"
conn = sqlite3.connect(db)
conn.execute(_PRE_1310_DDL)
conn.commit()
conn.close()
for call_id in ("c1", "c2"): # 二次初始化幂等
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id=call_id, stream_end="done")
recorder.close()
conn = sqlite3.connect(db)
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
conn.close()
async def test_manual_mode_names_the_new_alter(self, tmp_path, captured_warnings):
db = tmp_path / "manual1310.db"
conn = sqlite3.connect(db)
conn.execute(_PRE_1310_DDL)
conn.commit()
conn.close()
recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-manual", stream_end="done")
recorder.close()
conn = sqlite3.connect(db)
assert len([r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]) == 39
assert conn.execute("SELECT call_id FROM llm_calls").fetchone() == ("c-manual",)
conn.close()
assert "ALTER TABLE llm_calls ADD COLUMN stream_end TEXT" in captured_warnings[0]
async def test_attempt_takes_stream_end_from_response(self):
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None, scope="LLM").emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(stream_end="eof_after_finish"),
error=None,
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["stream_end"] == "eof_after_finish"
async def test_failed_hit_and_terminal_rows_are_null(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=None,
error="boom",
reasoning_applies=True,
operation="chat",
)
# 命中行显式不信任 response 上的值(即使上游误带)
await emitter.emit_cache_hit(
request=_REQ, response=_resp(cache_hit=True, stream_end="done"), operation="chat"
)
await emitter.emit_terminal_failure(
request=_REQ, call_id="c", error="dead", operation="chat", stats=_MIGRATED_STATS
)
assert [row["stream_end"] for row in rec.rows] == [None, None, None]