mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
fix(openai-cli): item_id 与 call_id 不一致时 tool delta 映射错误
Responses API 中 function_call 的 item.id 和 call_id 可能不同, 增加别名注册和解析机制,确保后续 delta/done 事件统一使用 call_id。
This commit is contained in:
@@ -626,10 +626,18 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
item_type = item.get("type")
|
item_type = item.get("type")
|
||||||
if item_type == "function_call":
|
if item_type == "function_call":
|
||||||
|
item_id = str(item.get("id") or "")
|
||||||
tool_id = str(item.get("call_id") or item.get("id") or "")
|
tool_id = str(item.get("call_id") or item.get("id") or "")
|
||||||
tool_name = str(item.get("name") or "")
|
tool_name = str(item.get("name") or "")
|
||||||
block_index = int(ss.get("block_index", 0))
|
block_index = int(ss.get("block_index", 0))
|
||||||
|
|
||||||
|
# 先注册别名,再 resolve,确保后续 delta 能正确映射
|
||||||
|
self._register_tool_alias(
|
||||||
|
item_id=item_id,
|
||||||
|
call_id=tool_id,
|
||||||
|
ss=ss,
|
||||||
|
)
|
||||||
|
|
||||||
# 记录当前活跃的工具调用(支持并行)
|
# 记录当前活跃的工具调用(支持并行)
|
||||||
active_tools = ss.setdefault("active_tool_blocks", {})
|
active_tools = ss.setdefault("active_tool_blocks", {})
|
||||||
active_tools[tool_id] = block_index
|
active_tools[tool_id] = block_index
|
||||||
@@ -667,7 +675,12 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
item_type = item.get("type")
|
item_type = item.get("type")
|
||||||
if item_type == "function_call":
|
if item_type == "function_call":
|
||||||
tool_id = str(item.get("call_id") or item.get("id") or "")
|
tool_id = self._resolve_tool_call_id(item.get("call_id") or item.get("id"), ss)
|
||||||
|
self._register_tool_alias(
|
||||||
|
item_id=str(item.get("id") or ""),
|
||||||
|
call_id=tool_id,
|
||||||
|
ss=ss,
|
||||||
|
)
|
||||||
active_tools = ss.get("active_tool_blocks", {})
|
active_tools = ss.get("active_tool_blocks", {})
|
||||||
block_index = active_tools.pop(tool_id, ss.get("block_index", 1) - 1)
|
block_index = active_tools.pop(tool_id, ss.get("block_index", 1) - 1)
|
||||||
events.extend(
|
events.extend(
|
||||||
@@ -688,7 +701,10 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
delta = chunk.get("delta") or ""
|
delta = chunk.get("delta") or ""
|
||||||
if delta:
|
if delta:
|
||||||
# 确定当前工具调用的 block_index 和 tool_id
|
# 确定当前工具调用的 block_index 和 tool_id
|
||||||
tool_id = str(chunk.get("item_id") or ss.get("current_tool_id", ""))
|
tool_id = self._resolve_tool_call_id(
|
||||||
|
chunk.get("item_id") or ss.get("current_tool_id", ""),
|
||||||
|
ss,
|
||||||
|
)
|
||||||
active_tools = ss.get("active_tool_blocks", {})
|
active_tools = ss.get("active_tool_blocks", {})
|
||||||
block_index = active_tools.get(tool_id, ss.get("block_index", 1) - 1)
|
block_index = active_tools.get(tool_id, ss.get("block_index", 1) - 1)
|
||||||
|
|
||||||
@@ -709,7 +725,10 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
def _handle_function_call_done(
|
def _handle_function_call_done(
|
||||||
self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
|
self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
|
||||||
) -> list[InternalStreamEvent]:
|
) -> list[InternalStreamEvent]:
|
||||||
tool_id = str(chunk.get("item_id") or ss.get("current_tool_id", ""))
|
tool_id = self._resolve_tool_call_id(
|
||||||
|
chunk.get("item_id") or ss.get("current_tool_id", ""),
|
||||||
|
ss,
|
||||||
|
)
|
||||||
active_tools = ss.get("active_tool_blocks", {})
|
active_tools = ss.get("active_tool_blocks", {})
|
||||||
block_index = int(active_tools.get(tool_id, ss.get("block_index", 1) - 1))
|
block_index = int(active_tools.get(tool_id, ss.get("block_index", 1) - 1))
|
||||||
return self._sync_tool_arguments_snapshot(
|
return self._sync_tool_arguments_snapshot(
|
||||||
@@ -791,6 +810,31 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _register_tool_alias(
|
||||||
|
*,
|
||||||
|
item_id: str,
|
||||||
|
call_id: str,
|
||||||
|
ss: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if not item_id or not call_id or item_id == call_id:
|
||||||
|
return
|
||||||
|
alias_map = ss.get("tool_item_to_call_id")
|
||||||
|
if not isinstance(alias_map, dict):
|
||||||
|
alias_map = {}
|
||||||
|
ss["tool_item_to_call_id"] = alias_map
|
||||||
|
alias_map[item_id] = call_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_tool_call_id(tool_ref: Any, ss: dict[str, Any]) -> str:
|
||||||
|
raw = str(tool_ref or "")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
alias_map = ss.get("tool_item_to_call_id")
|
||||||
|
if isinstance(alias_map, dict):
|
||||||
|
return str(alias_map.get(raw) or raw)
|
||||||
|
return raw
|
||||||
|
|
||||||
def stream_event_from_internal(
|
def stream_event_from_internal(
|
||||||
self,
|
self,
|
||||||
event: InternalStreamEvent,
|
event: InternalStreamEvent,
|
||||||
|
|||||||
@@ -549,6 +549,87 @@ def test_stream_openai_cli_function_call_done_without_delta_emits_full_args() ->
|
|||||||
assert collected_args == '{"filePath":"/tmp/demo.txt"}'
|
assert collected_args == '{"filePath":"/tmp/demo.txt"}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_openai_cli_uses_call_id_not_item_id_for_tool_deltas() -> None:
|
||||||
|
"""Responses API 中 item.id 与 call_id 不同时,应统一映射到 call_id。"""
|
||||||
|
reg = _make_registry_with_cli()
|
||||||
|
state = StreamState()
|
||||||
|
|
||||||
|
cli_chunks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "response.created",
|
||||||
|
"response": {
|
||||||
|
"id": "resp_call_alias",
|
||||||
|
"object": "response",
|
||||||
|
"model": "gpt-5.4",
|
||||||
|
"status": "in_progress",
|
||||||
|
"output": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": {
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_CfAXHBAtvuxd1HEHGgHUUscU",
|
||||||
|
"id": "fc_080c89b8d042bd430169b6c420833481918c61ed6112e83344",
|
||||||
|
"name": "bash",
|
||||||
|
"status": "in_progress",
|
||||||
|
"arguments": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.function_call_arguments.delta",
|
||||||
|
"output_index": 0,
|
||||||
|
"item_id": "fc_080c89b8d042bd430169b6c420833481918c61ed6112e83344",
|
||||||
|
"delta": '{"command":"find . -maxdepth 2 | sort","timeout":10}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.done",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": {
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_CfAXHBAtvuxd1HEHGgHUUscU",
|
||||||
|
"id": "fc_080c89b8d042bd430169b6c420833481918c61ed6112e83344",
|
||||||
|
"name": "bash",
|
||||||
|
"status": "completed",
|
||||||
|
"arguments": '{"command":"find . -maxdepth 2 | sort","timeout":10}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.completed",
|
||||||
|
"response": {
|
||||||
|
"id": "resp_call_alias",
|
||||||
|
"object": "response",
|
||||||
|
"model": "gpt-5.4",
|
||||||
|
"status": "completed",
|
||||||
|
"output": [],
|
||||||
|
"usage": {"input_tokens": 8, "output_tokens": 4, "total_tokens": 12},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
all_events: list[dict[str, Any]] = []
|
||||||
|
for chunk in cli_chunks:
|
||||||
|
all_events.extend(reg.convert_stream_chunk(chunk, "openai:cli", "openai:chat", state=state))
|
||||||
|
|
||||||
|
tc_indices: list[int] = []
|
||||||
|
tc_ids: list[str] = []
|
||||||
|
collected_args = ""
|
||||||
|
for event in all_events:
|
||||||
|
for choice in event.get("choices", []):
|
||||||
|
for tc in choice.get("delta", {}).get("tool_calls") or []:
|
||||||
|
tc_indices.append(int(tc.get("index", -1)))
|
||||||
|
tc_ids.append(str(tc.get("id") or ""))
|
||||||
|
collected_args += str((tc.get("function") or {}).get("arguments") or "")
|
||||||
|
|
||||||
|
assert tc_indices
|
||||||
|
assert set(tc_indices) == {0}, f"expected a single tool_call index, got {tc_indices}"
|
||||||
|
assert set(tc_ids) == {"call_CfAXHBAtvuxd1HEHGgHUUscU"}, (
|
||||||
|
"tool deltas should use call_id, not raw item.id, " f"got ids={tc_ids}"
|
||||||
|
)
|
||||||
|
assert collected_args == '{"command":"find . -maxdepth 2 | sort","timeout":10}'
|
||||||
|
|
||||||
|
|
||||||
def test_real_claude_cli_stream_response_conversion() -> None:
|
def test_real_claude_cli_stream_response_conversion() -> None:
|
||||||
"""测试真实的 Claude CLI 流式响应转换(完整事件序列)
|
"""测试真实的 Claude CLI 流式响应转换(完整事件序列)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user