fix(openai): tool_call delta 重复携带 function.name,增强严格客户端兼容性

在 ToolCallStart 时记录 block_index 到 tool_name 的映射,
ToolCallDelta 时同步输出 function.name,避免严格客户端丢弃无 name 的 delta。
提取 _ss_dict 辅助方法统一 stream state 中 dict 字段的初始化逻辑。
This commit is contained in:
fawney19
2026-03-15 22:04:21 +08:00
parent d58c27d22d
commit 60842fbbb5
3 changed files with 30 additions and 15 deletions

View File

@@ -797,7 +797,9 @@ class OpenAINormalizer(FormatNormalizer):
tool_name = event.tool_name or ""
tool_index = self._ensure_tool_call_index(ss, tool_id, event.block_index)
if tool_id:
ss.setdefault("block_to_tool_id", {})[int(event.block_index)] = str(tool_id)
self._ss_dict(ss, "block_to_tool_id")[int(event.block_index)] = str(tool_id)
if tool_name:
self._ss_dict(ss, "block_to_tool_name")[int(event.block_index)] = str(tool_name)
out.append(
base_chunk(
{
@@ -828,10 +830,7 @@ class OpenAINormalizer(FormatNormalizer):
# 构造 data URL 格式的图片
data_url = f"data:{image_media_type};base64,{image_data}"
# 存储图片数据,在 ContentBlockStopEvent 时输出
image_blocks = ss.get("image_blocks")
if not isinstance(image_blocks, dict):
image_blocks = {}
ss["image_blocks"] = image_blocks
image_blocks = self._ss_dict(ss, "image_blocks")
image_blocks[int(event.block_index)] = {
"url": data_url,
"media_type": image_media_type,
@@ -865,14 +864,18 @@ class OpenAINormalizer(FormatNormalizer):
if isinstance(event, ToolCallDeltaEvent):
tool_id = str(event.tool_id or "")
block_to_tool_id = ss.setdefault("block_to_tool_id", {})
block_to_tool_id = self._ss_dict(ss, "block_to_tool_id")
if not tool_id:
tool_id = str(block_to_tool_id.get(int(event.block_index)) or "")
else:
block_to_tool_id[int(event.block_index)] = tool_id
tool_name = str(
self._ss_dict(ss, "block_to_tool_name").get(int(event.block_index)) or ""
)
tool_index = self._ensure_tool_call_index(ss, tool_id, event.block_index)
# 对严格客户端重复携带 id/type避免它们把后续 delta 视为无效 tool_call。
# 对严格客户端重复携带 id/type/name,避免它们把后续 delta 视为无效 tool_call。
tc_delta: dict[str, Any] = {
"index": tool_index,
"type": "function",
@@ -880,6 +883,8 @@ class OpenAINormalizer(FormatNormalizer):
}
if tool_id:
tc_delta["id"] = tool_id
if tool_name:
tc_delta["function"]["name"] = tool_name
out.append(base_chunk({"tool_calls": [tc_delta]}))
return out
@@ -1804,11 +1809,17 @@ class OpenAINormalizer(FormatNormalizer):
except ValueError:
return ErrorType.UNKNOWN
@staticmethod
def _ss_dict(ss: dict[str, Any], key: str) -> dict:
"""Get or create a dict entry in stream state."""
val = ss.get(key)
if not isinstance(val, dict):
val = {}
ss[key] = val
return val
def _ensure_tool_block_index(self, ss: dict[str, Any], tool_key: str) -> int:
mapping = ss.get("tool_id_to_block_index")
if not isinstance(mapping, dict):
mapping = {}
ss["tool_id_to_block_index"] = mapping
mapping = self._ss_dict(ss, "tool_id_to_block_index")
if tool_key in mapping:
return int(mapping[tool_key])
@@ -1828,10 +1839,7 @@ class OpenAINormalizer(FormatNormalizer):
ss["next_tool_index"] = 0
# block_index -> tool_index 的辅助映射,用于 tool_id 丢失时回落
block_mapping: dict[int, int] = ss.get("block_to_tool_index") # type: ignore[assignment]
if not isinstance(block_mapping, dict):
block_mapping = {}
ss["block_to_tool_index"] = block_mapping
block_mapping: dict[int, int] = self._ss_dict(ss, "block_to_tool_index") # type: ignore[assignment]
# 优先用 tool_id 查找
if tool_id and tool_id in mapping:

View File

@@ -858,6 +858,7 @@ def test_tool_call_stream_index_stable_across_deltas() -> None:
# 收集所有 tool_calls chunk
tc_indices: list[int] = []
tc_ids: list[str] = []
tc_names: list[str] = []
for event in all_events:
for choice in event.get("choices", []):
tcs = choice.get("delta", {}).get("tool_calls")
@@ -865,6 +866,7 @@ def test_tool_call_stream_index_stable_across_deltas() -> None:
for tc in tcs:
tc_indices.append(tc["index"])
tc_ids.append(str(tc.get("id") or ""))
tc_names.append(str((tc.get("function") or {}).get("name") or ""))
assert len(tc_indices) >= 2, f"expected at least 2 tool_call chunks, got {len(tc_indices)}"
# 同一个 tool call 的所有 chunk 必须使用相同的 index
@@ -875,3 +877,7 @@ def test_tool_call_stream_index_stable_across_deltas() -> None:
"tool_call id should be repeated on every delta for strict OpenAI-compatible clients, "
f"got: {tc_ids}"
)
assert all(tc_name == "get_weather" for tc_name in tc_names), (
"tool_call function.name should be repeated on every delta for strict "
f"OpenAI-compatible clients, got: {tc_names}"
)

View File

@@ -332,6 +332,7 @@ def test_openai_stream_chunk_and_event_roundtrip_basic() -> None:
)
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["id"] == "call_1"
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["index"] == 0
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["function"]["name"] == "get_weather"
# 最终 stop chunk finish_reason=tool_calls
assert out_chunks[-1]["choices"][0]["finish_reason"] == "tool_calls"