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 dataclasses import dataclass
from typing import Any from typing import Any
from polygateway.errors import TransientError from polygateway.errors import TransientError
from polygateway.types import OpenAIChatCompletion, ResponseMode 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: def _invalid(reason: str) -> TransientError:
"""协议形态失败,不回显可能敏感的工具参数。""" """协议形态失败,不回显可能敏感的工具参数。"""
@@ -43,6 +55,14 @@ def _append(target: dict[str, Any], key: str, value: Any) -> str:
return value 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: def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChatCompletion:
"""复用内核最终快照校验并翻译领域错误。""" """复用内核最终快照校验并翻译领域错误。"""
try: try:
@@ -53,13 +73,129 @@ def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChat
raise _invalid(str(exc)) from exc 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) @dataclass(frozen=True)
class ChatDelta: class ChatDelta:
"""一帧提取结果;progress 表示真实生成进展。""" """一帧提取结果;progress 表示真实生成进展。
`reasoning` 是 `reasoning` 字段(vLLM/OpenRouter 新名)的片段,投影 thinking 时
作为 `reasoning_content` 缺席时的别名(设计 §4.4)。
"""
content: str content: str
reasoning_content: str reasoning_content: str
progress: bool progress: bool
reasoning: str = ""
class ChatCompletionAccumulator: class ChatCompletionAccumulator:
@@ -71,104 +207,239 @@ class ChatCompletionAccumulator:
self.message: dict[str, Any] = {} self.message: dict[str, Any] = {}
self.metadata: dict[str, Any] = {} self.metadata: dict[str, Any] = {}
self.tools: dict[int, 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: def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta:
"""聚合一个已解码帧,一帧至多一个活性事件。""" """聚合一个已解码帧,一帧至多一个活性事件。"""
chunk = _object(chunk, "chunk") chunk = _object(chunk, "chunk")
strict = self.mode == "openai" if self.mode == "openai":
if strict:
_identity(self.metadata, "id", chunk.get("id")) _identity(self.metadata, "id", chunk.get("id"))
if chunk.get("usage") is not None: choice = self._take_choice(chunk.get("choices"))
_object(chunk["usage"], "usage") delta = self._take_delta(choice) if choice is not None else ChatDelta("", "", False)
self.metadata["usage"] = chunk["usage"] self._take_usage(chunk.get("usage"))
choices = chunk.get("choices", []) 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): if not isinstance(choices, list):
raise _invalid("choices 必须为数组") raise _invalid("choices 必须为数组")
if not choices: if not choices:
return ChatDelta("", "", False) return None
if strict and len(choices) != 1: if self.mode == "openai":
raise _invalid("仅支持单 choice") if len(choices) != 1:
choice = _object(choices[0], "choice") raise _invalid("仅支持单 choice")
if strict: choice = _object(choices[0], "choice")
if type(choice.get("index")) is not int or choice["index"] != 0: if type(choice.get("index")) is not int or choice["index"] != 0:
raise _invalid("choice index 必须为 0") raise _invalid("choice index 必须为 0")
_identity(self.metadata, "finish_reason", choice.get("finish_reason")) _identity(self.metadata, "finish_reason", choice.get("finish_reason"))
delta = _object(choice.get("delta", {}), "delta") else:
if strict and delta.keys() - {"role", "content", "reasoning_content", "tool_calls"}: choice = self._text_choice(choices)
raise _invalid("未知 delta 字段") if choice is None:
if "role" in delta and strict: return None
if delta["role"] != "assistant": self._finish_seen = self._finish_seen or self.finish_reason is not None
raise _invalid("role 必须为 assistant") return choice
self.message["role"] = delta["role"]
content = _append(self.message, "content", delta["content"]) if "content" in delta else "" def _text_choice(self, choices: list[Any]) -> dict[str, Any] | None:
reasoning = ( """文本模式取 index 缺省或为 0 的首个 choice,不混入其他 choice(n>1)。"""
_append(self.message, "reasoning_content", delta["reasoning_content"]) for raw in choices:
if "reasoning_content" in delta choice = _object(raw, "choice")
else "" if choice.get("index", 0) == 0:
) finish = choice.get("finish_reason")
progress = bool(content or reasoning) 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: if "tool_calls" in delta:
tools = delta["tool_calls"] progress |= self._take_tools(delta["tool_calls"])
if not isinstance(tools, list): progress |= self._take_unknown(delta)
if strict: return ChatDelta(content, reasoning_content, progress, alias)
raise _invalid("tool_calls 必须为数组")
return ChatDelta(content, reasoning, progress) def _take_role(self, role: Any) -> None:
if strict: if role is None:
self.message.setdefault("tool_calls", []) self.message.setdefault("role", None)
for raw in tools: return
if not strict: if role != "assistant":
# 文本只消费可识别的工具进展,不承诺工具完整性。 raise _invalid("role 必须为 assistant")
if isinstance(raw, dict) and type(raw.get("index")) is int: self.message["role"] = role
index = raw["index"]
tool = self.tools.setdefault(index, {}) def _take_text(self, delta: dict[str, Any], key: str) -> str:
for key in ("id", "type"): if key not in delta:
if isinstance(raw.get(key), str) and raw[key] and key not in tool: return ""
tool[key] = raw[key] if self.mode == "openai" or delta[key] is not None:
progress = True return _append(self.message, key, delta[key])
fn = raw.get("function") return ""
if isinstance(fn, dict):
progress |= any( def _take_unknown(self, delta: dict[str, Any]) -> bool:
isinstance(fn.get(k), str) and bool(fn[k]) """未知 delta 键: 保真合并进 message;文本只算活性。"""
for k in ("name", "arguments") grew = False
) for key, value in delta.items():
continue if key in _DELTA_KNOWN:
raw = _object(raw, "tool") continue
index = raw.get("index") if self.mode == "openai":
if type(index) is not int or index < 0: grew |= self._merger.merge_key(self.message, key, value, f"delta.{key}")
raise _invalid("tool index 必须为非负整数") else:
if raw.keys() - {"index", "id", "type", "function"}: grew |= _fragment_progress(key, value)
raise _invalid("未知工具增量字段") return grew
tool = self.tools.setdefault(index, {})
for key in ("id", "type"): def _take_tools(self, tools: Any) -> bool:
progress |= _identity(tool, key, raw.get(key)) if tools is None:
if "function" in raw: if self.mode == "openai":
fn = _object(raw["function"], "function") self.message.setdefault("tool_calls", None)
if fn.keys() - {"name", "arguments"}: return False
raise _invalid("未知 function 增量字段") if not isinstance(tools, list):
target = tool.setdefault("function", {}) if self.mode == "openai":
for key in ("name", "arguments"): raise _invalid("tool_calls 必须为数组")
if key in fn: return False
if not isinstance(fn[key], str): if self.mode == "openai" and self.message.get("tool_calls") is None:
raise _invalid(f"function.{key} 必须为字符串") self.message["tool_calls"] = []
progress |= bool(_append(target, key, fn[key])) progress = False
return ChatDelta(content, reasoning, progress) 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: def finish(self) -> OpenAIChatCompletion:
"""线路完整性由调用者保证;此处只校验最终消息。""" """线路完整性由调用者保证;此处只校验最终消息。"""
message = dict(self.message) return _completion(self.raw_message(), self.metadata)
if self.tools:
message["tool_calls"] = [self.tools[index] for index in sorted(self.tools)]
return _completion(message, self.metadata)
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion: def _strip_null_tools(message: dict[str, Any]) -> dict[str, Any]:
"""非流式完整消息保留扩展字段,拒绝静默截取多 choice。""" """null 工具元素剔除: 与流式跳过同口径,原样回传 [null] 会被上游拒。"""
body = _object(body, "body") 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") choices = body.get("choices")
if not isinstance(choices, list) or len(choices) != 1: if not isinstance(choices, list) or len(choices) != 1:
raise _invalid("仅支持单 choice") raise _invalid("仅支持单 choice")
choice = _object(choices[0], "choice") choice = _object(choices[0], "choice")
if "index" in choice and (type(choice["index"]) is not int or choice["index"] != 0): if "index" in choice and (type(choice["index"]) is not int or choice["index"] != 0):
raise _invalid("choice index 必须为 0") raise _invalid("choice index 必须为 0")
metadata = {**body, "finish_reason": choice.get("finish_reason")} return choice
return _completion(_object(choice.get("message"), "message"), metadata)
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)
+199 -3
View File
@@ -99,13 +99,13 @@ def test_incomplete_tool_is_not_success(change):
"delta", "delta",
[ [
[], [],
{"audio": {}},
{"tool_calls": {}}, {"tool_calls": {}},
{"content": []}, {"content": []},
{"role": "user"},
{"tool_calls": [{"index": True}]}, {"tool_calls": [{"index": True}]},
{"tool_calls": [{"index": -1}]}, {"tool_calls": [{"index": -1}]},
{"tool_calls": [{"index": 0, "extra": 1}]}, {"tool_calls": [{"index": None}]},
{"tool_calls": [{"index": 0, "function": {"arguments": None}}]}, {"tool_calls": [{"index": 0, "function": {"arguments": 12}}]},
], ],
) )
def test_invalid_delta_is_domain_error(delta): def test_invalid_delta_is_domain_error(delta):
@@ -225,3 +225,199 @@ def test_fragmented_name_and_arguments_with_usage():
assert result.message["tool_calls"][0]["function"] == {"name": "echo", "arguments": " {} "} assert result.message["tool_calls"][0]["function"] == {"name": "echo", "arguments": " {} "}
assert result.usage["extra"]["nested"] == 9 assert result.usage["extra"]["nested"] == 9
assert result.finish_reason == "length" 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