mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(openai-cli): 补全 function_call 的 arguments 快照同步,修复无 delta 时参数丢失
在 output_item.done 和 function_call_arguments.done 事件中,通过 args snapshot 对比已发送的增量,补发缺失的 tool arguments delta,确保客户端收到完整参数。
This commit is contained in:
@@ -648,6 +648,14 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
events.extend(
|
||||||
|
self._sync_tool_arguments_snapshot(
|
||||||
|
tool_id=tool_id,
|
||||||
|
block_index=block_index,
|
||||||
|
args_snapshot=item.get("arguments"),
|
||||||
|
ss=ss,
|
||||||
|
)
|
||||||
|
)
|
||||||
ss["block_index"] = block_index + 1
|
ss["block_index"] = block_index + 1
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@@ -662,6 +670,14 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
tool_id = str(item.get("call_id") or item.get("id") or "")
|
tool_id = str(item.get("call_id") or item.get("id") or "")
|
||||||
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(
|
||||||
|
self._sync_tool_arguments_snapshot(
|
||||||
|
tool_id=tool_id,
|
||||||
|
block_index=int(block_index),
|
||||||
|
args_snapshot=item.get("arguments"),
|
||||||
|
ss=ss,
|
||||||
|
)
|
||||||
|
)
|
||||||
events.append(ContentBlockStopEvent(block_index=block_index))
|
events.append(ContentBlockStopEvent(block_index=block_index))
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@@ -690,6 +706,19 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
)
|
)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
def _handle_function_call_done(
|
||||||
|
self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
|
||||||
|
) -> list[InternalStreamEvent]:
|
||||||
|
tool_id = str(chunk.get("item_id") or ss.get("current_tool_id", ""))
|
||||||
|
active_tools = ss.get("active_tool_blocks", {})
|
||||||
|
block_index = int(active_tools.get(tool_id, ss.get("block_index", 1) - 1))
|
||||||
|
return self._sync_tool_arguments_snapshot(
|
||||||
|
tool_id=tool_id,
|
||||||
|
block_index=block_index,
|
||||||
|
args_snapshot=chunk.get("arguments"),
|
||||||
|
ss=ss,
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_noop(
|
def _handle_noop(
|
||||||
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]:
|
||||||
@@ -713,13 +742,55 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
"response.output_item.added": _handle_output_item_added,
|
"response.output_item.added": _handle_output_item_added,
|
||||||
"response.output_item.done": _handle_output_item_done,
|
"response.output_item.done": _handle_output_item_done,
|
||||||
"response.function_call_arguments.delta": _handle_function_call_delta,
|
"response.function_call_arguments.delta": _handle_function_call_delta,
|
||||||
"response.function_call_arguments.done": _handle_noop,
|
"response.function_call_arguments.done": _handle_function_call_done,
|
||||||
"response.content_part.added": _handle_noop,
|
"response.content_part.added": _handle_noop,
|
||||||
"response.content_part.done": _handle_noop,
|
"response.content_part.done": _handle_noop,
|
||||||
"response.reasoning_summary_text.delta": _handle_as_unknown,
|
"response.reasoning_summary_text.delta": _handle_as_unknown,
|
||||||
"response.reasoning_summary_text.done": _handle_as_unknown,
|
"response.reasoning_summary_text.done": _handle_as_unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _sync_tool_arguments_snapshot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tool_id: str,
|
||||||
|
block_index: int,
|
||||||
|
args_snapshot: Any,
|
||||||
|
ss: dict[str, Any],
|
||||||
|
) -> list[InternalStreamEvent]:
|
||||||
|
if not tool_id:
|
||||||
|
return []
|
||||||
|
if not isinstance(args_snapshot, str) or not args_snapshot:
|
||||||
|
return []
|
||||||
|
|
||||||
|
tool_calls = ss.setdefault("tool_calls", {})
|
||||||
|
entry = tool_calls.setdefault(tool_id, {"name": "", "args": ""})
|
||||||
|
current_args = str(entry.get("args") or "")
|
||||||
|
|
||||||
|
if args_snapshot == current_args:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if current_args and not args_snapshot.startswith(current_args):
|
||||||
|
logger.debug(
|
||||||
|
"[OpenAICliNormalizer] function_call args snapshot is not a prefix extension: "
|
||||||
|
"tool_id={}, current_len={}, snapshot_len={}",
|
||||||
|
tool_id,
|
||||||
|
len(current_args),
|
||||||
|
len(args_snapshot),
|
||||||
|
)
|
||||||
|
delta = args_snapshot
|
||||||
|
else:
|
||||||
|
delta = args_snapshot[len(current_args) :] if current_args else args_snapshot
|
||||||
|
entry["args"] = args_snapshot
|
||||||
|
if not delta:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
ToolCallDeltaEvent(
|
||||||
|
block_index=block_index,
|
||||||
|
tool_id=tool_id,
|
||||||
|
input_delta=delta,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def stream_event_from_internal(
|
def stream_event_from_internal(
|
||||||
self,
|
self,
|
||||||
event: InternalStreamEvent,
|
event: InternalStreamEvent,
|
||||||
|
|||||||
@@ -471,7 +471,82 @@ def test_stream_openai_cli_function_call_events() -> None:
|
|||||||
|
|
||||||
events3 = reg.convert_stream_chunk(output_done_chunk, "openai:cli", "claude:chat", state=state)
|
events3 = reg.convert_stream_chunk(output_done_chunk, "openai:cli", "claude:chat", state=state)
|
||||||
assert isinstance(events3, list) and events3
|
assert isinstance(events3, list) and events3
|
||||||
assert events3[0].get("type") == "content_block_stop"
|
assert events3[0].get("type") == "content_block_delta"
|
||||||
|
assert events3[0].get("delta", {}).get("partial_json") == ' "Beijing"}'
|
||||||
|
assert events3[-1].get("type") == "content_block_stop"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_openai_cli_function_call_done_without_delta_emits_full_args() -> None:
|
||||||
|
"""无 arguments.delta 时,done 快照也应补出完整 tool arguments。"""
|
||||||
|
reg = _make_registry_with_cli()
|
||||||
|
state = StreamState()
|
||||||
|
|
||||||
|
cli_chunks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "response.created",
|
||||||
|
"response": {
|
||||||
|
"id": "resp_done_only",
|
||||||
|
"object": "response",
|
||||||
|
"model": "gpt-5",
|
||||||
|
"status": "in_progress",
|
||||||
|
"output": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": {
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_read_1",
|
||||||
|
"id": "call_read_1",
|
||||||
|
"name": "read",
|
||||||
|
"status": "in_progress",
|
||||||
|
"arguments": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.function_call_arguments.done",
|
||||||
|
"output_index": 0,
|
||||||
|
"item_id": "call_read_1",
|
||||||
|
"arguments": '{"filePath":"/tmp/demo.txt"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.done",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": {
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_read_1",
|
||||||
|
"id": "call_read_1",
|
||||||
|
"name": "read",
|
||||||
|
"status": "completed",
|
||||||
|
"arguments": '{"filePath":"/tmp/demo.txt"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.completed",
|
||||||
|
"response": {
|
||||||
|
"id": "resp_done_only",
|
||||||
|
"object": "response",
|
||||||
|
"model": "gpt-5",
|
||||||
|
"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))
|
||||||
|
|
||||||
|
collected_args = ""
|
||||||
|
for event in all_events:
|
||||||
|
for choice in event.get("choices", []):
|
||||||
|
for tc in choice.get("delta", {}).get("tool_calls") or []:
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
collected_args += str(fn.get("arguments") or "")
|
||||||
|
|
||||||
|
assert collected_args == '{"filePath":"/tmp/demo.txt"}'
|
||||||
|
|
||||||
|
|
||||||
def test_real_claude_cli_stream_response_conversion() -> None:
|
def test_real_claude_cli_stream_response_conversion() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user