diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93d64eb..ea13863 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,8 @@
- **文本模式 length/过滤且无正文 → `ResultInvalidError`**(原 `TransientError` 重试到耗尽): 不重试、不换源、熔断记成功不计窗口;**不经 `AllSourcesExhausted` 包装**,直接到调用方。原因码见异常消息(`length_without_content` / `filtered_without_content`)。文本模式"只有思考 + stop"维持瞬时(证据收集中)。
- **finish_reason 为 `network_error`/`error`**(GLM 等上游自报失败)即使有正文也判瞬时。
-- **保真模式**: 只有思考/拒答/空白 + 明确 finish(非上游错误)→ 原样交回;length 截断的半截工具(缺 id/name)→ `ResultInvalidError` `tool_truncated`。
+- **保真模式**: 只有思考/拒答/空白 + 明确 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` 计量随之覆盖。
@@ -32,7 +33,7 @@
### 提醒
- 保真模式交付 `finish_reason=length` 的工具调用时 arguments 可能是半截 JSON: **执行工具前先查 `finish_reason`**。
-- 未知字段合并冲突时会**删除该字段**并按(源, 路径)告警一次: 结果可能缺字段,以告警为准。
+- 未知字段合并冲突时会**删除该字段**并按(源, 路径)告警一次: 结果可能缺字段,以告警为准。典型形态: 带 `index` 的数组(如 `reasoning_details`)后续帧元素缺 `index` 即判形态改变,**整个字段被删**。
- 诚实边界: 中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分;防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
- 旧版本写入的 salvage/length 文本缓存无法辨认: 受影响集合须在升级后首次读写前更换 namespace/salt(同 1.3.9 提醒)。
diff --git a/README.md b/README.md
index 1157d7f..fb37713 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@
| 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(含 `reasoning` 别名)刷活性不计结果;截断流(无 `[DONE]` 且无 finish+其后 usage)判瞬时不入缓存 |
+| 流式看门狗 | 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` 与遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 39 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
@@ -704,8 +704,9 @@ except TimeoutError:
`ResultInvalidError` 不经 `AllSourcesExhausted` 包装,直接到调用方;原因码在异常消息里
(`length_without_content` / `filtered_without_content` / `tool_truncated` 等,遥测 `error`
-列可检索)。只有自然结束(stop/tool_calls)且有正文的结果才写缓存;未登记的 finish 值按
-正常交付、不缓存并告警一次。文本模式 `thinking` 在无非空 `reasoning_content` 时取
+列可检索)。只有自然结束(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` 同值幂等;冲突时**删除该字段**并按(源, 路径)
告警一次——结果可能缺字段,以告警为准。诚实边界:中转在上游出错后主动正常关闭连接与真
diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md
index 396e9e2..48c4430 100644
--- a/research-wiki/ARCHITECTURE.md
+++ b/research-wiki/ARCHITECTURE.md
@@ -568,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` 还原。
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
index 591f6a0..14c996c 100644
--- 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
@@ -245,3 +245,7 @@ null 规则**优先于**已知字段类型校验: chunk、choice、delta、工
| 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/plans/2026-10-14-issue30-31-completion-verdict.md b/research-wiki/plans/2026-10-14-issue30-31-completion-verdict.md
index 18853fd..132268b 100644
--- a/research-wiki/plans/2026-10-14-issue30-31-completion-verdict.md
+++ b/research-wiki/plans/2026-10-14-issue30-31-completion-verdict.md
@@ -17,7 +17,7 @@ date: 2026-10-14
## 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 及以下;钩子按 radon 全部块判定,**类块**(`ChatCompletionAccumulator` C16、`OpenAIChatCompletion` D24)同样须降到 B 及以下。不改 `OpenAICompatTransport.complete()`(C14),新增逻辑一律放在拆出的 helper 中。
+- 分支 `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)。
diff --git a/src/polygateway/transports/_openai_completion.py b/src/polygateway/transports/_openai_completion.py
index 4a3e7b6..d3d9fe9 100644
--- a/src/polygateway/transports/_openai_completion.py
+++ b/src/polygateway/transports/_openai_completion.py
@@ -283,7 +283,9 @@ class ChatCompletionAccumulator:
"""文本模式取 index 缺省或为 0 的首个 choice,不混入其他 choice(n>1)。"""
for raw in choices:
choice = _object(raw, "choice")
- if choice.get("index", 0) == 0:
+ 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
@@ -333,24 +335,26 @@ class ChatCompletionAccumulator:
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:
- if self.mode == "openai":
- self.message.setdefault("tool_calls", None)
+ self.message.setdefault("tool_calls", None)
return False
if not isinstance(tools, list):
- if self.mode == "openai":
- raise _invalid("tool_calls 必须为数组")
- return False
- if self.mode == "openai" and self.message.get("tool_calls") is None:
+ 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 None:
- continue
- if self.mode == "openai":
- progress |= self._take_tool(_object(raw, "tool"))
- else:
- progress |= self._text_tool_progress(raw)
+ if raw is not None:
+ progress |= take(raw)
return progress
def _text_tool_progress(self, raw: Any) -> bool:
@@ -404,7 +408,9 @@ 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:
- return {**message, "tool_calls": [tool for tool in tools if tool is not None]}
+ kept = [tool for tool in tools if tool is not None]
+ # 全为 null 视同 tool_calls:null,不造会被上游拒收的空数组
+ return {**message, "tool_calls": kept or None}
return message
@@ -429,7 +435,11 @@ def check_completion_body(body: Any) -> tuple[dict[str, Any], str | None]:
finish = choice.get("finish_reason")
if finish is not None and not isinstance(finish, str):
raise _invalid("finish_reason 必须为字符串或 null")
- return _strip_null_tools(_object(choice.get("message"), "message")), finish
+ 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(
diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py
index d19f951..9ab56f5 100644
--- a/src/polygateway/transports/openai_compat.py
+++ b/src/polygateway/transports/openai_compat.py
@@ -66,6 +66,7 @@ _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 同款)——
@@ -845,7 +846,8 @@ class OpenAICompatTransport:
self, source: SourceConfig, conflicts: frozenset[str], finish_reason: str | None
) -> None:
"""方言告警: 冲突删键与未登记 finish 各按 (源, 路径/值) 只喊一次。"""
- for path in sorted(conflicts):
+ for raw_path in sorted(conflicts):
+ path = raw_path[:_PATH_WARN_CHARS] # 键名由上游决定,长度同样须有界
self._warn_once(
source.name, f"field:{path}", f"字段 {path} 合并冲突已删除,保真结果缺该字段"
)
diff --git a/src/polygateway/types.py b/src/polygateway/types.py
index 94aa0ec..aa5322a 100644
--- a/src/polygateway/types.py
+++ b/src/polygateway/types.py
@@ -551,6 +551,8 @@ class _OutputSummary:
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:
@@ -585,9 +587,27 @@ def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _O
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。"""
@@ -613,6 +633,9 @@ def _judge_broken(
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
@@ -667,9 +690,10 @@ def _is_cacheable(
if completion is None:
return False
raw = completion.message.get("content")
- return _summarize_message(
- completion.message, raw if isinstance(raw, str) else ""
- ).body_output
+ 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() != ""
diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py
index 6afabd0..1c944fa 100644
--- a/tests/unit/test_cache.py
+++ b/tests/unit/test_cache.py
@@ -1116,3 +1116,14 @@ async def test_new_fields_roundtrip_through_cache():
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
index a5efe93..0ebd1fd 100644
--- a/tests/unit/test_completion_transport.py
+++ b/tests/unit/test_completion_transport.py
@@ -85,7 +85,7 @@ async def test_eof_after_finish_usage_only_frame_and_tools():
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"
+ assert result.stream_end == "eof_after_finish" and result.usage_source == "measured"
@pytest.mark.parametrize(
@@ -416,3 +416,38 @@ async def test_openai_reasoning_length_single_attempt_via_client():
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
index 50234e2..03bc8fb 100644
--- a/tests/unit/test_completion_verdict.py
+++ b/tests/unit/test_completion_verdict.py
@@ -244,3 +244,22 @@ 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 de9482b..70141f3 100644
--- a/tests/unit/test_openai_completion.py
+++ b/tests/unit/test_openai_completion.py
@@ -421,3 +421,48 @@ def test_nonstream_null_tool_elements_stripped():
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"}]}
+ )