fix: merge unknown and null stream fields losslessly in completion accumulator

Null is treated as absent at every layer; unknown delta, tool and
function fields merge by OpenAI streaming conventions (strings concat,
label keys idempotent, indexed arrays by index) and conflicts drop the
key and record its path instead of failing the call. The accumulator
now tracks finish and usage-after-finish in both modes, exposes the
reasoning alias, and non-stream parsing splits shape checks from
snapshot construction.

Red: 20 of 47 tests in tests/unit/test_openai_completion.py failed
before this change (e.g. test_null_known_and_unknown_fields_are_absent
raised TransientError 未知 delta 字段; finish_seen/raw_message missing).
This commit is contained in:
2026-10-14 23:21:05 -04:00
parent e5ad67408a
commit 16743ca11d
2 changed files with 550 additions and 83 deletions
+351 -80
View File
@@ -1,11 +1,23 @@
"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。"""
"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。
1.3.10(#30): null 一律视为缺省;已知字段沿用 1.3.9 语义;未知字段按 OpenAI 流式
累加惯例无损合并,冲突删键并登记路径——方言是确定性的,拒绝只会让重试重演同一失败。
"""
import copy
from dataclasses import dataclass
from typing import Any
from polygateway.errors import TransientError
from polygateway.types import OpenAIChatCompletion, ResponseMode
_DELTA_KNOWN = frozenset({"role", "content", "reasoning_content", "tool_calls"})
_TOOL_KNOWN = frozenset({"index", "id", "type", "function"})
_FUNCTION_KNOWN = frozenset({"name", "arguments"})
# 标签键: 每帧可能重复下发的身份/格式标记,同值幂等,绝不拼接(拼成 "x-v1x-v1"
# 回传上游会被拒);也不算生成进展
_LABEL_KEYS = frozenset({"index", "type", "id", "format"})
def _invalid(reason: str) -> TransientError:
"""协议形态失败,不回显可能敏感的工具参数。"""
@@ -43,6 +55,14 @@ def _append(target: dict[str, Any], key: str, value: Any) -> str:
return value
def _assemble(message: dict[str, Any], tools: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""按 index 排序组装工具;`finish()` 与 `raw_message()` 共用,判定与快照同源。"""
assembled = copy.deepcopy(message)
if tools:
assembled["tool_calls"] = [copy.deepcopy(tools[index]) for index in sorted(tools)]
return assembled
def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChatCompletion:
"""复用内核最终快照校验并翻译领域错误。"""
try:
@@ -53,13 +73,129 @@ def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChat
raise _invalid(str(exc)) from exc
# —— 未知字段合并(设计 §5.2) ——
class _MergeConflictError(Exception):
"""合并冲突(内部控制流): 由 `_ExtensionMerger.merge_key` 就地收口为删键。"""
def _is_indexed(items: list[Any]) -> bool:
"""数组元素(忽略 null)均为带整数 index 的对象。"""
present = [item for item in items if item is not None]
return bool(present) and all(
isinstance(item, dict) and type(item.get("index")) is int for item in present
)
def _fragment_progress(key: str, value: Any) -> bool:
"""不落盘的活性判据(文本模式): 非标签键上的非空字符串片段。"""
if isinstance(value, str):
return key not in _LABEL_KEYS and value != ""
if isinstance(value, dict):
return any(_fragment_progress(k, v) for k, v in value.items())
if isinstance(value, list):
return any(_fragment_progress(key, item) for item in value)
return False
class _ExtensionMerger:
"""每尝试私有的未知字段合并器;冲突路径即封禁,后续同路径值丢弃。"""
def __init__(self) -> None:
self.conflicts: set[str] = set()
self._array_modes: dict[str, bool] = {}
def merge_key(self, target: dict[str, Any], key: str, value: Any, path: str) -> bool:
"""合并一个键;返回是否并入了非空字符串片段(活性)。"""
if path in self.conflicts:
return False
if value is None:
target.setdefault(key, None)
return False
try:
merged, grew = self._merge(target.get(key), value, key, path)
except _MergeConflictError:
target.pop(key, None)
self.conflicts.add(path)
return False
target[key] = merged
return grew
def _merge(self, old: Any, new: Any, key: str, path: str) -> tuple[Any, bool]:
if isinstance(new, dict):
return self._merge_dict(old, new, path)
if isinstance(new, list):
return self._merge_list(old, new, key, path)
if key in _LABEL_KEYS or not isinstance(new, str):
return self._merge_scalar(old, new)
if old is not None and not isinstance(old, str):
raise _MergeConflictError
return (old or "") + new, new != ""
@staticmethod
def _merge_scalar(old: Any, new: Any) -> tuple[Any, bool]:
"""标签键与数字/布尔: 同值幂等,不同值冲突(不学 SDK 相加,计数相加会造假)。"""
if old is None:
return new, False
if type(old) is type(new) and old == new:
return old, False
raise _MergeConflictError
def _merge_dict(self, old: Any, new: dict[str, Any], path: str) -> tuple[Any, bool]:
if old is not None and not isinstance(old, dict):
raise _MergeConflictError
target = old if old is not None else {}
grew = False
for sub_key, sub_value in new.items():
grew |= self.merge_key(target, sub_key, sub_value, f"{path}.{sub_key}")
return target, grew
def _merge_list(self, old: Any, new: list[Any], key: str, path: str) -> tuple[Any, bool]:
if old is not None and not isinstance(old, list):
raise _MergeConflictError
target = old if old is not None else []
items = [item for item in new if item is not None]
if not items:
return target, False
indexed = _is_indexed(items)
if self._array_modes.setdefault(path, indexed) != indexed:
raise _MergeConflictError # 形态改变: 首次出现决定合并策略
if indexed:
return target, self._merge_indexed(target, items, f"{path}[]")
return target, self._merge_appended(target, items, key, f"{path}[]")
def _merge_appended(self, target: list[Any], items: list[Any], key: str, path: str) -> bool:
grew = False
for item in items:
merged, item_grew = self._merge(None, item, key, path)
target.append(merged)
grew |= item_grew
return grew
def _merge_indexed(self, target: list[Any], items: list[Any], path: str) -> bool:
grew = False
for item in items:
slot = next((e for e in target if e.get("index") == item["index"]), None)
if slot is None:
slot = {}
target.append(slot)
grew |= self._merge_dict(slot, item, path)[1]
return grew
@dataclass(frozen=True)
class ChatDelta:
"""一帧提取结果;progress 表示真实生成进展。"""
"""一帧提取结果;progress 表示真实生成进展。
`reasoning` 是 `reasoning` 字段(vLLM/OpenRouter 新名)的片段,投影 thinking 时
作为 `reasoning_content` 缺席时的别名(设计 §4.4)。
"""
content: str
reasoning_content: str
progress: bool
reasoning: str = ""
class ChatCompletionAccumulator:
@@ -71,104 +207,239 @@ 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")
if choice.get("index", 0) == 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 tools is None:
if self.mode == "openai":
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:
self.message["tool_calls"] = []
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)
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:
return {**message, "tool_calls": [tool for tool in tools if tool is not 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")
return _strip_null_tools(_object(choice.get("message"), "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)