fix: address completion verdict review findings

- fidelity cacheability also requires a non-empty projected body, so
  think-tag-only answers are no longer cached
- a no-argument tool sent with arguments null only is a deterministic
  dialect: ResultInvalid tool_arguments_null instead of retried
- text mode treats choice index null as 0 and no longer lets False pose
  as 0
- null-only tool_calls elements no longer produce an empty tool_calls
  list; non-stream fidelity checks role before the verdict; warning
  paths are length-bounded
- docs: ARCHITECTURE 7.8 field counts, README cache/watchdog wording,
  CHANGELOG notes, review record in the design, hook note in the plan

Red: 8 new tests failed before this change (e.g.
test_openai_cacheable_requires_projected_body,
test_null_arguments_tool_is_result_invalid_stream,
test_text_mode_choice_index_null_is_zero[None-True]).
This commit is contained in:
2026-10-15 00:22:07 -04:00
parent 31942f55f1
commit abcdf6930e
12 changed files with 179 additions and 27 deletions
@@ -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(
+3 -1
View File
@@ -66,6 +66,7 @@ _THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
# 方言告警节流上限与 finish 值截断(设计 §5.4): 键由上游决定,必须有界
_WARN_CAP = 1024
_FINISH_WARN_CHARS = 64
_PATH_WARN_CHARS = 128
# —— SSE 纯函数(VT llm.py 同款)——
@@ -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} 合并冲突已删除,保真结果缺该字段"
)
+27 -3
View File
@@ -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() != ""