mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 实现 executor 流式执行路径及完善 gateway 多格式 plan 构建
- Rust executor 新增流式 plan 支持,覆盖 openai/claude/gemini 的 chat/cli/video 格式 - Gateway 新增 finalize-sync、report-stream 端点及 stream report 模型 - 新增 video sync 操作(create/cancel/remix/delete)的 plan 构建 - 修正 OpenAI Responses(openai:cli) SSE 格式: 添加 event: 行、移除 [DONE] 哨兵 - 统一 OpenAI CLI normalizer 的 ID 生成方法
This commit is contained in:
38
tests/api/handlers/base/test_cli_event_mixin.py
Normal file
38
tests/api/handlers/base/test_cli_event_mixin.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import Any
|
||||
|
||||
from src.api.handlers.base.cli_event_mixin import CliEventMixin
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
|
||||
|
||||
class _DummyCliEventHandler(CliEventMixin):
|
||||
request_id = "req-test"
|
||||
|
||||
def _process_event_data(
|
||||
self,
|
||||
ctx: StreamContext,
|
||||
event_type: str,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
del ctx, event_type, data
|
||||
|
||||
|
||||
def test_convert_sse_line_drops_done_for_openai_cli() -> None:
|
||||
handler = _DummyCliEventHandler()
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:cli")
|
||||
ctx.client_api_format = "openai:cli"
|
||||
|
||||
lines, converted = handler._convert_sse_line(ctx, "data: [DONE]", [])
|
||||
|
||||
assert lines == []
|
||||
assert converted == []
|
||||
|
||||
|
||||
def test_convert_sse_line_keeps_done_for_openai_chat() -> None:
|
||||
handler = _DummyCliEventHandler()
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:chat")
|
||||
ctx.client_api_format = "openai:chat"
|
||||
|
||||
lines, converted = handler._convert_sse_line(ctx, "data: [DONE]", [])
|
||||
|
||||
assert lines == ["data: [DONE]"]
|
||||
assert converted == []
|
||||
32
tests/api/handlers/base/test_cli_sse_helpers.py
Normal file
32
tests/api/handlers/base/test_cli_sse_helpers.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
|
||||
from src.api.handlers.base.cli_sse_helpers import _format_converted_events_to_sse
|
||||
|
||||
|
||||
def test_format_converted_events_to_sse_uses_event_lines_for_openai_cli() -> None:
|
||||
events = [
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": "msg_123",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": "Hi",
|
||||
"logprobs": [],
|
||||
"sequence_number": 1,
|
||||
}
|
||||
]
|
||||
|
||||
lines = _format_converted_events_to_sse(events, "openai:cli")
|
||||
|
||||
assert lines == [
|
||||
"event: response.output_text.delta\n"
|
||||
f"data: {json.dumps(events[0], ensure_ascii=False)}\n"
|
||||
]
|
||||
|
||||
|
||||
def test_format_converted_events_to_sse_keeps_data_only_for_openai_chat() -> None:
|
||||
events = [{"id": "chatcmpl-123", "object": "chat.completion.chunk", "choices": []}]
|
||||
|
||||
lines = _format_converted_events_to_sse(events, "openai:chat")
|
||||
|
||||
assert lines == [f"data: {json.dumps(events[0], ensure_ascii=False)}\n"]
|
||||
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.response_parser import ParsedResponse, ResponseParser, StreamStats
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
|
||||
|
||||
class _DummyParser(ResponseParser):
|
||||
def parse_sse_line(self, line: str, stats: StreamStats) -> Any | None: # noqa: ANN401
|
||||
return None
|
||||
|
||||
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
|
||||
return ParsedResponse(raw_response=response, status_code=status_code)
|
||||
|
||||
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
def extract_text_content(self, response: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
async def _empty_async_iter() -> Any:
|
||||
if False: # pragma: no cover
|
||||
yield b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_response_stream_converts_claude_to_openai_cli_with_event_lines() -> None:
|
||||
register_default_normalizers()
|
||||
|
||||
ctx = StreamContext(model="test-model", api_format="openai:cli")
|
||||
ctx.client_api_format = "openai:cli"
|
||||
ctx.provider_api_format = "claude:chat"
|
||||
ctx.needs_conversion = True
|
||||
|
||||
processor = StreamProcessor(request_id="test-request", default_parser=_DummyParser())
|
||||
|
||||
response_ctx = AsyncMock()
|
||||
response_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
message_start = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-test",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
},
|
||||
}
|
||||
content_delta = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hi"},
|
||||
}
|
||||
|
||||
prefetched_chunks = [
|
||||
b"event: message_start\n",
|
||||
f"data: {json.dumps(message_start)}\n".encode("utf-8"),
|
||||
b"\n",
|
||||
f"data: {json.dumps(content_delta)}\n".encode("utf-8"),
|
||||
b"\n",
|
||||
]
|
||||
|
||||
out = b"".join(
|
||||
[
|
||||
chunk
|
||||
async for chunk in processor.create_response_stream(
|
||||
ctx,
|
||||
byte_iterator=_empty_async_iter(),
|
||||
response_ctx=response_ctx,
|
||||
prefetched_chunks=prefetched_chunks,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
text = out.decode("utf-8")
|
||||
assert "event: response.output_item.added\n" in text
|
||||
assert "event: response.content_part.added\n" in text
|
||||
assert "event: response.output_text.delta\n" in text
|
||||
assert "data: [DONE]" not in text
|
||||
File diff suppressed because it is too large
Load Diff
@@ -147,9 +147,11 @@ def test_claude_response_to_openai_cli() -> None:
|
||||
|
||||
openai_cli_resp = reg.convert_response(claude_resp, "claude:chat", "openai:cli")
|
||||
assert openai_cli_resp["object"] == "response"
|
||||
assert openai_cli_resp["id"] == "resp_1"
|
||||
assert isinstance(openai_cli_resp.get("output"), list)
|
||||
msg = cast(dict[str, Any], openai_cli_resp["output"][0])
|
||||
assert msg["type"] == "message"
|
||||
assert msg["id"] == "msg_1"
|
||||
assert msg["role"] == "assistant"
|
||||
content = cast(list[dict[str, Any]], msg.get("content") or [])
|
||||
assert content and content[0]["type"] == "output_text"
|
||||
@@ -174,9 +176,66 @@ def test_stream_openai_to_openai_cli_delta() -> None:
|
||||
assert isinstance(out_events, list) and out_events
|
||||
created = [e for e in out_events if e.get("type") == "response.created"]
|
||||
assert created
|
||||
assert cast(dict[str, Any], created[0]["response"])["id"] == "resp_1"
|
||||
deltas = [e for e in out_events if e.get("type") == "response.output_text.delta"]
|
||||
assert deltas
|
||||
assert deltas[0].get("delta") == "hi"
|
||||
assert deltas[0].get("item_id") == "msg_1"
|
||||
assert deltas[0].get("output_index") == 0
|
||||
assert deltas[0].get("content_index") == 0
|
||||
assert deltas[0].get("logprobs") == []
|
||||
|
||||
|
||||
def test_stream_openai_to_openai_cli_usage_only_chunk_does_not_duplicate_completion() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
state = StreamState()
|
||||
|
||||
chunks = [
|
||||
{
|
||||
"id": "chatcmpl-dup-stop",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}}],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-dup-stop",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "delta": {"content": "Hi"}}],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-dup-stop",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-dup-stop",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [],
|
||||
"usage": {
|
||||
"prompt_tokens": 11,
|
||||
"completion_tokens": 11,
|
||||
"total_tokens": 22,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
all_events: list[dict[str, Any]] = []
|
||||
for chunk in chunks:
|
||||
all_events.extend(reg.convert_stream_chunk(chunk, "openai:chat", "openai:cli", state=state))
|
||||
|
||||
assert sum(1 for e in all_events if e.get("type") == "response.output_text.done") == 1
|
||||
assert sum(1 for e in all_events if e.get("type") == "response.content_part.done") == 1
|
||||
assert sum(1 for e in all_events if e.get("type") == "response.output_item.done") == 1
|
||||
assert sum(1 for e in all_events if e.get("type") == "response.completed") == 1
|
||||
completed = next(e for e in all_events if e.get("type") == "response.completed")
|
||||
assert cast(dict[str, Any], completed["response"])["id"] == "resp_dup-stop"
|
||||
|
||||
|
||||
def test_stream_openai_cli_to_openai_delta() -> None:
|
||||
@@ -201,6 +260,94 @@ def test_stream_openai_cli_to_openai_delta() -> None:
|
||||
assert cast(dict[str, Any], delta).get("content") == "hi"
|
||||
|
||||
|
||||
def test_stream_openai_cli_to_openai_accepts_msg_chatcmpl_item_ids() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
state = StreamState()
|
||||
|
||||
cli_chunks: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "chatcmpl-abc123",
|
||||
"object": "response",
|
||||
"model": "gpt-4o-mini",
|
||||
"status": "in_progress",
|
||||
"output": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"id": "msg_chatcmpl-abc123",
|
||||
"type": "message",
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.content_part.added",
|
||||
"item_id": "msg_chatcmpl-abc123",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"part": {"type": "output_text", "text": "", "annotations": []},
|
||||
},
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": "msg_chatcmpl-abc123",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": "hi",
|
||||
"logprobs": [],
|
||||
},
|
||||
{
|
||||
"type": "response.output_text.done",
|
||||
"item_id": "msg_chatcmpl-abc123",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"text": "hi",
|
||||
"logprobs": [],
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"id": "msg_chatcmpl-abc123",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "chatcmpl-abc123",
|
||||
"object": "response",
|
||||
"model": "gpt-4o-mini",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
all_events: list[dict[str, Any]] = []
|
||||
for chunk in cli_chunks:
|
||||
events = reg.convert_stream_chunk(chunk, "openai:cli", "openai:chat", state=state)
|
||||
all_events.extend(events)
|
||||
|
||||
assert any(
|
||||
cast(dict[str, Any], cast(list[Any], e.get("choices") or [])[0])
|
||||
.get("delta", {})
|
||||
.get("content")
|
||||
== "hi"
|
||||
for e in all_events
|
||||
if isinstance(e, dict) and isinstance(e.get("choices"), list) and e.get("choices")
|
||||
)
|
||||
|
||||
|
||||
def test_openai_cli_function_call_to_claude() -> None:
|
||||
"""测试 OpenAI CLI 的 function_call/function_call_output 转换为 Claude tool_use/tool_result"""
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
Reference in New Issue
Block a user