mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 实现跨 API 格式自动转换功能
- 新增端点级 format_acceptance_config 配置,控制是否接受跨格式请求 - 重构 EndpointFormDialog 为卡片式布局,支持内联编辑和格式转换开关 - StreamProcessor 实现流式响应的跨格式转换,支持 OpenAI/Claude/Gemini 互转 - CacheAwareScheduler 按端点格式筛选候选,同格式优先于跨格式 - 健康度/熔断按 Provider 端点格式分桶,而非客户端请求格式 - 新增 format_conversion_total 和 format_conversion_duration_seconds 指标 - 新增全局配置 format_conversion_enabled 控制总开关 - Input 组件新增 size="sm" 尺寸选项
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
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 import register_all_converters
|
||||
|
||||
|
||||
class DummyParser(ResponseParser):
|
||||
def parse_sse_line(self, line: str, stats: StreamStats) -> Optional[Any]: # 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():
|
||||
if False: # pragma: no cover
|
||||
yield b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_response_stream_converts_claude_to_openai() -> None:
|
||||
register_all_converters()
|
||||
|
||||
ctx = StreamContext(model="test-model", api_format="OPENAI")
|
||||
ctx.client_api_format = "OPENAI"
|
||||
ctx.provider_api_format = "CLAUDE"
|
||||
ctx.needs_conversion = True
|
||||
|
||||
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
|
||||
|
||||
response_ctx = AsyncMock()
|
||||
response_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
http_client = AsyncMock()
|
||||
http_client.aclose = 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,
|
||||
http_client=http_client,
|
||||
prefetched_chunks=prefetched_chunks,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
text = out.decode("utf-8")
|
||||
assert "event:" not in text
|
||||
|
||||
events = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data: "):
|
||||
events.append(json.loads(line[6:]))
|
||||
|
||||
assert len(events) >= 2
|
||||
assert any(e.get("object") == "chat.completion.chunk" for e in events)
|
||||
assert any(
|
||||
e.get("choices", [{}])[0].get("delta", {}).get("content") == "Hi"
|
||||
for e in events
|
||||
if isinstance(e, dict)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user