diff --git a/.claude/scripts/hooks/pre-commit-guard.sh b/.claude/scripts/hooks/pre-commit-guard.sh index a3b1441..f570777 100755 --- a/.claude/scripts/hooks/pre-commit-guard.sh +++ b/.claude/scripts/hooks/pre-commit-guard.sh @@ -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 diff --git a/.env.example b/.env.example index 520c97d..09feb02 100644 --- a/.env.example +++ b/.env.example @@ -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=...);受控实验固定解码用它,免得漏传 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4859cca..3600cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 快照;支持流式聚合及非流式。 diff --git a/README.md b/README.md index 309d8c3..fb37713 100644 --- a/README.md +++ b/README.md @@ -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 已明确授权; diff --git a/pyproject.toml b/pyproject.toml index cae2f35..26ebfdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 在打包时被固化进产物,发布后再改无效。 diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index 7628936..48c4430 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -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`,逆命题不成立。诚实边界:中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分。 diff --git a/research-wiki/ROADMAP.md b/research-wiki/ROADMAP.md index b9fd4a2..47a2e0c 100644 --- a/research-wiki/ROADMAP.md +++ b/research-wiki/ROADMAP.md @@ -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. 开放决策依赖 | 决策 | 阻塞点 | 需拍板时间 | diff --git a/research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md b/research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md index 0c86232..d2d1d79 100644 --- a/research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md +++ b/research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md @@ -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)。 diff --git a/research-wiki/designs/2026-10-14-issue30-31-completion-verdict-design.md b/research-wiki/designs/2026-10-14-issue30-31-completion-verdict-design.md new file mode 100644 index 0000000..14c996c --- /dev/null +++ b/research-wiki/designs/2026-10-14-issue30-31-completion-verdict-design.md @@ -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 提醒 | diff --git a/research-wiki/migrations/chsanalyzer.md b/research-wiki/migrations/chsanalyzer.md index 5d6db1c..37dd93c 100644 --- a/research-wiki/migrations/chsanalyzer.md +++ b/research-wiki/migrations/chsanalyzer.md @@ -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]` 而完整的流从此直接成功。 diff --git a/research-wiki/migrations/govdoc-saas.md b/research-wiki/migrations/govdoc-saas.md index 6411773..4b41bc5 100644 --- a/research-wiki/migrations/govdoc-saas.md +++ b/research-wiki/migrations/govdoc-saas.md @@ -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)从此直接成功。 diff --git a/research-wiki/migrations/video-tree-trm5.md b/research-wiki/migrations/video-tree-trm5.md index 7f56d4d..3349a2d 100644 --- a/research-wiki/migrations/video-tree-trm5.md +++ b/research-wiki/migrations/video-tree-trm5.md @@ -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 视完整。 diff --git a/research-wiki/plans/2026-10-14-issue30-31-completion-verdict.md b/research-wiki/plans/2026-10-14-issue30-31-completion-verdict.md new file mode 100644 index 0000000..132268b --- /dev/null +++ b/research-wiki/plans/2026-10-14-issue30-31-completion-verdict.md @@ -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 工具元素" | diff --git a/research-wiki/schemas/llm-calls.md b/research-wiki/schemas/llm-calls.md index 0aa0a3d..3645b96 100644 --- a/research-wiki/schemas/llm-calls.md +++ b/research-wiki/schemas/llm-calls.md @@ -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 的比例。 diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index c76eca6..d015636 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -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", diff --git a/src/polygateway/middleware/cache.py b/src/polygateway/middleware/cache.py index e199f62..e46440d 100644 --- a/src/polygateway/middleware/cache.py +++ b/src/polygateway/middleware/cache.py @@ -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` diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 03c4f96..b4867ef 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -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, diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index ec730f0..fb49c18 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -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 diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index 1eae955..b8b9610 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -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: ... diff --git a/src/polygateway/telemetry/schema.py b/src/polygateway/telemetry/schema.py index 9ed9615..47ec510 100644 --- a/src/polygateway/telemetry/schema.py +++ b/src/polygateway/telemetry/schema.py @@ -5,8 +5,8 @@ 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。 **`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 -`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 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) diff --git a/src/polygateway/telemetry/sqlite.py b/src/polygateway/telemetry/sqlite.py index 05f8107..309fee4 100644 --- a/src/polygateway/telemetry/sqlite.py +++ b/src/polygateway/telemetry/sqlite.py @@ -143,7 +143,7 @@ class SQLiteRecorder: logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) async def record_llm_call(self, **fields: object) -> None: - """写一行遥测;字段集合即 38 字段冻结签名(ports.TelemetryRecorder)。 + """写一行遥测;字段集合即 39 字段冻结签名(ports.TelemetryRecorder)。 取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的 占位符同序——两者必须一起改,分开改就是把值写进错位的列。 diff --git a/src/polygateway/transports/_openai_completion.py b/src/polygateway/transports/_openai_completion.py index 39d0640..d3d9fe9 100644 --- a/src/polygateway/transports/_openai_completion.py +++ b/src/polygateway/transports/_openai_completion.py @@ -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) diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 3956bcc..9ab56f5 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -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"(.*?)", 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: diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 58ee7fd..aa5322a 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -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: diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index 3ce3b8c..3a17582 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -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 diff --git a/tests/integration/test_redis_cache.py b/tests/integration/test_redis_cache.py index 341c69b..ce9f32f 100644 --- a/tests/integration/test_redis_cache.py +++ b/tests/integration/test_redis_cache.py @@ -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}" diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index 959474a..1c944fa 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -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": "x"}, "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 diff --git a/tests/unit/test_completion_transport.py b/tests/unit/test_completion_transport.py new file mode 100644 index 0000000..0ebd1fd --- /dev/null +++ b/tests/unit/test_completion_transport.py @@ -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") diff --git a/tests/unit/test_completion_verdict.py b/tests/unit/test_completion_verdict.py new file mode 100644 index 0000000..03bc8fb --- /dev/null +++ b/tests/unit/test_completion_verdict.py @@ -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="x") + 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 diff --git a/tests/unit/test_openai_completion.py b/tests/unit/test_openai_completion.py index af97462..70141f3 100644 --- a/tests/unit/test_openai_completion.py +++ b/tests/unit/test_openai_completion.py @@ -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"}]} + ) diff --git a/tests/unit/test_openai_fidelity.py b/tests/unit/test_openai_fidelity.py index bc7e77f..87f892f 100644 --- a/tests/unit/test_openai_fidelity.py +++ b/tests/unit/test_openai_fidelity.py @@ -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 diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index be90f5e..7e352b5 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -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): diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index cd87404..8778747 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -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]