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,56 +207,158 @@ 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:
|
||||
return None
|
||||
if self.mode == "openai":
|
||||
if len(choices) != 1:
|
||||
raise _invalid("仅支持单 choice")
|
||||
choice = _object(choices[0], "choice")
|
||||
if strict:
|
||||
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"]
|
||||
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 strict:
|
||||
if self.mode == "openai":
|
||||
raise _invalid("tool_calls 必须为数组")
|
||||
return ChatDelta(content, reasoning, progress)
|
||||
if strict:
|
||||
self.message.setdefault("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 not strict:
|
||||
# 文本只消费可识别的工具进展,不承诺工具完整性。
|
||||
if isinstance(raw, dict) and type(raw.get("index")) is int:
|
||||
index = raw["index"]
|
||||
tool = self.tools.setdefault(index, {})
|
||||
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]
|
||||
@@ -128,47 +366,80 @@ class ChatCompletionAccumulator:
|
||||
fn = raw.get("function")
|
||||
if isinstance(fn, dict):
|
||||
progress |= any(
|
||||
isinstance(fn.get(k), str) and bool(fn[k])
|
||||
for k in ("name", "arguments")
|
||||
isinstance(fn.get(k), str) and bool(fn[k]) for k in ("name", "arguments")
|
||||
)
|
||||
continue
|
||||
raw = _object(raw, "tool")
|
||||
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 必须为非负整数")
|
||||
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 增量字段")
|
||||
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", {})
|
||||
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 = 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)
|
||||
|
||||
@@ -99,13 +99,13 @@ def test_incomplete_tool_is_not_success(change):
|
||||
"delta",
|
||||
[
|
||||
[],
|
||||
{"audio": {}},
|
||||
{"tool_calls": {}},
|
||||
{"content": []},
|
||||
{"role": "user"},
|
||||
{"tool_calls": [{"index": True}]},
|
||||
{"tool_calls": [{"index": -1}]},
|
||||
{"tool_calls": [{"index": 0, "extra": 1}]},
|
||||
{"tool_calls": [{"index": 0, "function": {"arguments": None}}]},
|
||||
{"tool_calls": [{"index": None}]},
|
||||
{"tool_calls": [{"index": 0, "function": {"arguments": 12}}]},
|
||||
],
|
||||
)
|
||||
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.usage["extra"]["nested"] == 9
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user