mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
fix(conversion): OpenAI CLI 流式转换 tool 调用与文本输出 block index 不再复用
引入统一的 block index 分配器(_allocate_block_index / _ensure_text_block_index), 避免工具调用后紧跟文本输出时 Claude block index 冲突。
This commit is contained in:
@@ -596,6 +596,8 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
ss["message_started"] = True
|
ss["message_started"] = True
|
||||||
ss.setdefault("text_block_started", False)
|
ss.setdefault("text_block_started", False)
|
||||||
ss.setdefault("text_block_stopped", False)
|
ss.setdefault("text_block_stopped", False)
|
||||||
|
ss.setdefault("text_block_index", None)
|
||||||
|
ss.setdefault("next_block_index", 0)
|
||||||
events.append(MessageStartEvent(message_id=msg_id, model=model))
|
events.append(MessageStartEvent(message_id=msg_id, model=model))
|
||||||
|
|
||||||
handler = self._CHUNK_HANDLERS.get(etype)
|
handler = self._CHUNK_HANDLERS.get(etype)
|
||||||
@@ -626,10 +628,16 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
delta_text = str(delta.get("text") or "")
|
delta_text = str(delta.get("text") or "")
|
||||||
|
|
||||||
if delta_text:
|
if delta_text:
|
||||||
|
text_block_index = self._ensure_text_block_index(ss)
|
||||||
if not ss.get("text_block_started"):
|
if not ss.get("text_block_started"):
|
||||||
ss["text_block_started"] = True
|
ss["text_block_started"] = True
|
||||||
events.append(ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT))
|
events.append(
|
||||||
events.append(ContentDeltaEvent(block_index=0, text_delta=delta_text))
|
ContentBlockStartEvent(
|
||||||
|
block_index=text_block_index,
|
||||||
|
block_type=ContentType.TEXT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
events.append(ContentDeltaEvent(block_index=text_block_index, text_delta=delta_text))
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _handle_output_text_done(
|
def _handle_output_text_done(
|
||||||
@@ -638,7 +646,7 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
events: list[InternalStreamEvent] = []
|
events: list[InternalStreamEvent] = []
|
||||||
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
|
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
|
||||||
ss["text_block_stopped"] = True
|
ss["text_block_stopped"] = True
|
||||||
events.append(ContentBlockStopEvent(block_index=0))
|
events.append(ContentBlockStopEvent(block_index=self._ensure_text_block_index(ss)))
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _handle_response_completed(
|
def _handle_response_completed(
|
||||||
@@ -651,7 +659,7 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
|
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
|
||||||
ss["text_block_stopped"] = True
|
ss["text_block_stopped"] = True
|
||||||
events.append(ContentBlockStopEvent(block_index=0))
|
events.append(ContentBlockStopEvent(block_index=self._ensure_text_block_index(ss)))
|
||||||
|
|
||||||
# 补齐所有已开始但未结束的 tool_call block
|
# 补齐所有已开始但未结束的 tool_call block
|
||||||
active_tools = ss.get("active_tool_blocks")
|
active_tools = ss.get("active_tool_blocks")
|
||||||
@@ -701,7 +709,7 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
item_id = str(item.get("id") or "")
|
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 = self._allocate_block_index(ss)
|
||||||
|
|
||||||
# 先注册别名,再 resolve,确保后续 delta 能正确映射
|
# 先注册别名,再 resolve,确保后续 delta 能正确映射
|
||||||
self._register_tool_alias(
|
self._register_tool_alias(
|
||||||
@@ -736,7 +744,6 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
ss=ss,
|
ss=ss,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ss["block_index"] = block_index + 1
|
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _handle_output_item_done(
|
def _handle_output_item_done(
|
||||||
@@ -754,7 +761,7 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
ss=ss,
|
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, self._last_block_index(ss))
|
||||||
events.extend(
|
events.extend(
|
||||||
self._sync_tool_arguments_snapshot(
|
self._sync_tool_arguments_snapshot(
|
||||||
tool_id=tool_id,
|
tool_id=tool_id,
|
||||||
@@ -778,7 +785,7 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
ss,
|
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, self._last_block_index(ss))
|
||||||
|
|
||||||
# 累积参数
|
# 累积参数
|
||||||
tool_calls = ss.setdefault("tool_calls", {})
|
tool_calls = ss.setdefault("tool_calls", {})
|
||||||
@@ -802,7 +809,7 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
ss,
|
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, self._last_block_index(ss)))
|
||||||
return self._sync_tool_arguments_snapshot(
|
return self._sync_tool_arguments_snapshot(
|
||||||
tool_id=tool_id,
|
tool_id=tool_id,
|
||||||
block_index=block_index,
|
block_index=block_index,
|
||||||
@@ -1418,6 +1425,25 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
ss["seq"] = seq
|
ss["seq"] = seq
|
||||||
return seq
|
return seq
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _allocate_block_index(ss: dict[str, Any]) -> int:
|
||||||
|
idx = ss.get("next_block_index", 0)
|
||||||
|
ss["next_block_index"] = idx + 1
|
||||||
|
return idx
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _ensure_text_block_index(cls, ss: dict[str, Any]) -> int:
|
||||||
|
idx = ss.get("text_block_index")
|
||||||
|
if idx is not None:
|
||||||
|
return idx
|
||||||
|
idx = cls._allocate_block_index(ss)
|
||||||
|
ss["text_block_index"] = idx
|
||||||
|
return idx
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _last_block_index(ss: dict[str, Any]) -> int:
|
||||||
|
return max(ss.get("next_block_index", 1) - 1, 0)
|
||||||
|
|
||||||
def _unwrap_response_object(self, response: dict[str, Any]) -> dict[str, Any]:
|
def _unwrap_response_object(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||||
if not isinstance(response, dict):
|
if not isinstance(response, dict):
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -753,6 +753,93 @@ def test_stream_openai_cli_function_call_events() -> None:
|
|||||||
assert events3[-1].get("type") == "content_block_stop"
|
assert events3[-1].get("type") == "content_block_stop"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_openai_cli_tool_then_text_uses_distinct_claude_block_indices() -> None:
|
||||||
|
"""Responses 工具调用后再输出文本时,Claude block index 不能复用。"""
|
||||||
|
reg = _make_registry_with_cli()
|
||||||
|
state = StreamState()
|
||||||
|
|
||||||
|
cli_chunks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "response.created",
|
||||||
|
"response": {
|
||||||
|
"id": "resp_tool_then_text",
|
||||||
|
"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_tool_then_text",
|
||||||
|
"id": "fc_tool_then_text",
|
||||||
|
"name": "read_file",
|
||||||
|
"status": "in_progress",
|
||||||
|
"arguments": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.function_call_arguments.delta",
|
||||||
|
"output_index": 0,
|
||||||
|
"item_id": "fc_tool_then_text",
|
||||||
|
"delta": '{"path":"README.md"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_text.delta",
|
||||||
|
"output_index": 1,
|
||||||
|
"item_id": "msg_tool_then_text",
|
||||||
|
"content_index": 0,
|
||||||
|
"delta": "done",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.done",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": {
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_tool_then_text",
|
||||||
|
"id": "fc_tool_then_text",
|
||||||
|
"name": "read_file",
|
||||||
|
"status": "completed",
|
||||||
|
"arguments": '{"path":"README.md"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.completed",
|
||||||
|
"response": {
|
||||||
|
"id": "resp_tool_then_text",
|
||||||
|
"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", "claude:cli", state=state))
|
||||||
|
|
||||||
|
starts = [e for e in all_events if e.get("type") == "content_block_start"]
|
||||||
|
assert len(starts) >= 2
|
||||||
|
|
||||||
|
tool_start = next(e for e in starts if (e.get("content_block") or {}).get("type") == "tool_use")
|
||||||
|
text_start = next(e for e in starts if (e.get("content_block") or {}).get("type") == "text")
|
||||||
|
assert tool_start["index"] != text_start["index"]
|
||||||
|
|
||||||
|
text_delta = next(
|
||||||
|
e
|
||||||
|
for e in all_events
|
||||||
|
if e.get("type") == "content_block_delta"
|
||||||
|
and (e.get("delta") or {}).get("type") == "text_delta"
|
||||||
|
)
|
||||||
|
assert text_delta["index"] == text_start["index"]
|
||||||
|
assert text_delta["index"] != tool_start["index"]
|
||||||
|
|
||||||
|
|
||||||
def test_stream_openai_cli_function_call_done_without_delta_emits_full_args() -> None:
|
def test_stream_openai_cli_function_call_done_without_delta_emits_full_args() -> None:
|
||||||
"""无 arguments.delta 时,done 快照也应补出完整 tool arguments。"""
|
"""无 arguments.delta 时,done 快照也应补出完整 tool arguments。"""
|
||||||
reg = _make_registry_with_cli()
|
reg = _make_registry_with_cli()
|
||||||
|
|||||||
Reference in New Issue
Block a user