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:
fawney19
2026-03-21 15:51:37 +08:00
parent 53bb23b510
commit 455234e797
14 changed files with 7692 additions and 39 deletions

View 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 == []

View 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"]

View File

@@ -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