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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user