diff --git a/src/polygateway/transports/_openai_completion.py b/src/polygateway/transports/_openai_completion.py index 39d0640..4a3e7b6 100644 --- a/src/polygateway/transports/_openai_completion.py +++ b/src/polygateway/transports/_openai_completion.py @@ -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) diff --git a/tests/unit/test_openai_completion.py b/tests/unit/test_openai_completion.py index af97462..de9482b 100644 --- a/tests/unit/test_openai_completion.py +++ b/tests/unit/test_openai_completion.py @@ -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