fix(conversion): 修复 openai cli tool 调用空 call_id 导致上游失败

- 在 FormatConversionRegistry.convert_request 前统一修复 InternalRequest 中空的 tool_id/tool_use_id

- 为空 ID 自动生成 call_auto_N,并将 tool_result 关联到最近待匹配的 tool_call

- 修正 OpenAICliNormalizer 中 function_call_output 的内部角色为 USER,确保输出 openai:chat 时保留 tool_call_id

- 新增 openai:cli <-> openai:chat 空 call_id 回归测试,覆盖工具调用关联场景
This commit is contained in:
hemo94931
2026-02-13 12:32:29 +08:00
parent 4c83780b5e
commit e80bcabccf
3 changed files with 120 additions and 1 deletions

View File

@@ -1054,7 +1054,7 @@ class OpenAICliNormalizer(FormatNormalizer):
content_text=content_text,
extra={"openai_cli": self._extract_extra(item, {"type", "call_id", "id", "output"})},
)
return InternalMessage(role=Role.TOOL, content=[result_block])
return InternalMessage(role=Role.USER, content=[result_block])
def _parse_reasoning_item(self, item: dict[str, Any]) -> InternalMessage:
summary_parts: list[str] = []

View File

@@ -15,6 +15,7 @@ from collections.abc import Generator
from contextlib import contextmanager
from typing import Any
from src.core.api_format.conversion.internal import InternalRequest, ToolResultBlock, ToolUseBlock
from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.stream_state import StreamState
@@ -60,6 +61,40 @@ class FormatConversionRegistry:
raise FormatConversionError(format_id, format_id, f"未注册 Normalizer: {format_id}")
return normalizer
def _repair_internal_tool_call_ids(self, internal: InternalRequest) -> None:
"""修复 InternalRequest 中空的 tool id/tool_use_id避免上游校验报错。"""
pending_tool_ids: list[str] = []
auto_counter = 0
def next_tool_id() -> str:
nonlocal auto_counter
auto_counter += 1
return f"call_auto_{auto_counter}"
for message in internal.messages:
for block in message.content:
if isinstance(block, ToolUseBlock):
tool_id = str(block.tool_id or "").strip()
if not tool_id:
tool_id = next_tool_id()
block.tool_id = tool_id
pending_tool_ids.append(tool_id)
continue
if isinstance(block, ToolResultBlock):
tool_use_id = str(block.tool_use_id or "").strip()
if tool_use_id:
block.tool_use_id = tool_use_id
if tool_use_id in pending_tool_ids:
pending_tool_ids.remove(tool_use_id)
continue
if pending_tool_ids:
block.tool_use_id = pending_tool_ids.pop(0)
else:
block.tool_use_id = next_tool_id()
# ==================== 请求/响应转换(严格) ====================
def convert_request(
@@ -81,6 +116,7 @@ class FormatConversionRegistry:
):
try:
internal = src.request_to_internal(request)
self._repair_internal_tool_call_ids(internal)
return tgt.request_from_internal(internal, target_variant=target_variant)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e

View File

@@ -181,6 +181,89 @@ def test_openai_cli_function_call_to_claude() -> None:
assert content2[0]["content"] == "file1.txt\nfile2.txt"
def test_openai_cli_empty_call_id_repaired_when_convert_to_openai_chat() -> None:
"""空 call_id 会在转换链路中被自动修复,并保持 tool 调用关联。"""
reg = _make_registry_with_cli()
openai_cli_req = {
"model": "gpt-5",
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "请读取 config"}],
},
{
"type": "function_call",
"name": "read_file",
"arguments": '{"path": "config.yaml"}',
"call_id": "",
},
{
"type": "function_call_output",
"call_id": "",
"output": "ok",
},
],
}
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
messages = cast(list[dict[str, Any]], out.get("messages") or [])
assistant_msg = next((m for m in messages if m.get("role") == "assistant"), {})
tool_calls = cast(list[dict[str, Any]], assistant_msg.get("tool_calls") or [])
assert len(tool_calls) == 1
generated_id = str(tool_calls[0].get("id") or "")
assert generated_id.startswith("call_auto_")
tool_msg = next((m for m in messages if m.get("role") == "tool"), {})
assert tool_msg.get("tool_call_id") == generated_id
def test_openai_chat_empty_tool_call_id_repaired_when_convert_to_openai_cli() -> None:
"""openai:chat 的空 tool_call_id 转 openai:cli 时应生成有效 call_id。"""
reg = _make_registry_with_cli()
openai_chat_req = {
"model": "gpt-5",
"messages": [
{"role": "user", "content": "帮我读取 README"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "",
"type": "function",
"function": {
"name": "read_file",
"arguments": '{"path":"README.md"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "",
"content": "done",
},
],
}
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
input_items = cast(list[dict[str, Any]], out.get("input") or [])
function_call = next((i for i in input_items if i.get("type") == "function_call"), {})
function_call_output = next(
(i for i in input_items if i.get("type") == "function_call_output"), {}
)
generated_id = str(function_call.get("call_id") or "")
assert generated_id.startswith("call_auto_")
assert function_call_output.get("call_id") == generated_id
def test_openai_cli_reasoning_preserved_in_roundtrip() -> None:
"""测试 OpenAI CLI 的 reasoning block 在 roundtrip 中被保留"""
reg = _make_registry_with_cli()