mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +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)
|
||||
)
|
||||
|
||||
161
tests/core/api_format/conversion/test_compatibility.py
Normal file
161
tests/core/api_format/conversion/test_compatibility.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
is_format_compatible 单元测试
|
||||
|
||||
覆盖:
|
||||
- 同格式透传
|
||||
- CLI 格式禁止转换
|
||||
- 全局开关/端点开关/白黑名单
|
||||
- 流式转换开关
|
||||
- 转换器能力校验
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.core.api_format.conversion.compatibility import is_format_compatible
|
||||
|
||||
|
||||
def test_same_format_is_compatible() -> None:
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"CLAUDE",
|
||||
endpoint_format_acceptance_config=None,
|
||||
is_stream=False,
|
||||
global_conversion_enabled=False,
|
||||
registry=MagicMock(),
|
||||
)
|
||||
assert ok is True
|
||||
assert needs_conv is False
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_cli_format_not_convertible() -> None:
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE_CLI",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": True},
|
||||
is_stream=False,
|
||||
global_conversion_enabled=True,
|
||||
registry=MagicMock(),
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "CLI" in reason
|
||||
|
||||
|
||||
def test_global_switch_disabled_blocks_conversion() -> None:
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": True},
|
||||
is_stream=False,
|
||||
global_conversion_enabled=False,
|
||||
registry=MagicMock(),
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "全局" in reason
|
||||
|
||||
|
||||
def test_endpoint_config_none_blocks_conversion() -> None:
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config=None,
|
||||
is_stream=False,
|
||||
global_conversion_enabled=True,
|
||||
registry=MagicMock(),
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "未配置" in reason
|
||||
|
||||
|
||||
def test_endpoint_disabled_blocks_conversion() -> None:
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": False},
|
||||
is_stream=False,
|
||||
global_conversion_enabled=True,
|
||||
registry=MagicMock(),
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "未启用" in reason
|
||||
|
||||
|
||||
def test_accept_formats_allows_only_whitelist() -> None:
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["OPENAI"]},
|
||||
is_stream=False,
|
||||
global_conversion_enabled=True,
|
||||
registry=MagicMock(),
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "不接受" in reason
|
||||
|
||||
|
||||
def test_reject_formats_blocks_blacklist() -> None:
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": True, "reject_formats": ["CLAUDE"]},
|
||||
is_stream=False,
|
||||
global_conversion_enabled=True,
|
||||
registry=MagicMock(),
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "拒绝" in reason
|
||||
|
||||
|
||||
def test_stream_conversion_disabled_blocks_stream() -> None:
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": True, "stream_conversion": False},
|
||||
is_stream=True,
|
||||
global_conversion_enabled=True,
|
||||
registry=MagicMock(),
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "流式" in reason
|
||||
|
||||
|
||||
def test_converter_support_required() -> None:
|
||||
registry = MagicMock()
|
||||
registry.can_convert_full.return_value = False
|
||||
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": True},
|
||||
is_stream=False,
|
||||
global_conversion_enabled=True,
|
||||
registry=registry,
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "转换器" in reason
|
||||
|
||||
|
||||
def test_conversion_allowed_when_converter_supports_full() -> None:
|
||||
registry = MagicMock()
|
||||
registry.can_convert_full.return_value = True
|
||||
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["CLAUDE"]},
|
||||
is_stream=False,
|
||||
global_conversion_enabled=True,
|
||||
registry=registry,
|
||||
)
|
||||
assert ok is True
|
||||
assert needs_conv is True
|
||||
assert reason is None
|
||||
|
||||
@@ -403,4 +403,4 @@ class TestNonStrictConversion:
|
||||
original = {"chunk": "data"}
|
||||
result = registry.convert_stream_chunk(original, "A", "B")
|
||||
|
||||
assert result == original
|
||||
assert result == [original]
|
||||
|
||||
125
tests/services/test_format_conversion_candidate_selection.py
Normal file
125
tests/services/test_format_conversion_candidate_selection.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from src.core.api_format import APIFormat, register_all_converters
|
||||
from src.services.cache.aware_scheduler import CacheAwareScheduler
|
||||
|
||||
|
||||
def _mock_key(key_id: str, api_formats: list[str]) -> MagicMock:
|
||||
key = MagicMock()
|
||||
key.id = key_id
|
||||
key.is_active = True
|
||||
key.api_formats = api_formats
|
||||
key.cache_ttl_minutes = 1
|
||||
key.internal_priority = 1
|
||||
return key
|
||||
|
||||
|
||||
def _mock_endpoint(api_format: str, config: dict | None = None) -> MagicMock:
|
||||
endpoint = MagicMock()
|
||||
endpoint.id = f"ep_{api_format.lower()}"
|
||||
endpoint.is_active = True
|
||||
endpoint.api_format = api_format
|
||||
endpoint.format_acceptance_config = config
|
||||
return endpoint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_candidates_blocks_cross_format_when_global_switch_off() -> None:
|
||||
register_all_converters()
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
|
||||
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[attr-defined]
|
||||
|
||||
provider = MagicMock()
|
||||
provider.name = "p1"
|
||||
provider.endpoints = [
|
||||
_mock_endpoint(
|
||||
"OPENAI",
|
||||
{"enabled": True, "accept_formats": ["CLAUDE"], "stream_conversion": True},
|
||||
)
|
||||
]
|
||||
provider.api_keys = [_mock_key("k1", ["OPENAI"])]
|
||||
|
||||
candidates = await scheduler._build_candidates(
|
||||
db=MagicMock(),
|
||||
providers=[provider],
|
||||
client_format=APIFormat.CLAUDE,
|
||||
model_name="dummy-model",
|
||||
affinity_key=None,
|
||||
global_conversion_enabled=False,
|
||||
)
|
||||
|
||||
assert candidates == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_candidates_includes_cross_format_when_enabled() -> None:
|
||||
register_all_converters()
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
|
||||
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[attr-defined]
|
||||
|
||||
provider = MagicMock()
|
||||
provider.name = "p1"
|
||||
provider.endpoints = [
|
||||
_mock_endpoint(
|
||||
"OPENAI",
|
||||
{"enabled": True, "accept_formats": ["CLAUDE"], "stream_conversion": True},
|
||||
)
|
||||
]
|
||||
provider.api_keys = [_mock_key("k1", ["OPENAI"])]
|
||||
|
||||
candidates = await scheduler._build_candidates(
|
||||
db=MagicMock(),
|
||||
providers=[provider],
|
||||
client_format=APIFormat.CLAUDE,
|
||||
model_name="dummy-model",
|
||||
affinity_key=None,
|
||||
global_conversion_enabled=True,
|
||||
)
|
||||
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].needs_conversion is True
|
||||
assert candidates[0].provider_api_format == "OPENAI"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_matches_rank_before_convertible() -> None:
|
||||
register_all_converters()
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
|
||||
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[attr-defined]
|
||||
|
||||
provider = MagicMock()
|
||||
provider.name = "p1"
|
||||
# 故意把 OPENAI 放在 endpoints[0],验证排序仍然是 CLAUDE(exact)在前
|
||||
provider.endpoints = [
|
||||
_mock_endpoint(
|
||||
"OPENAI",
|
||||
{"enabled": True, "accept_formats": ["CLAUDE"], "stream_conversion": True},
|
||||
),
|
||||
_mock_endpoint("CLAUDE", None),
|
||||
]
|
||||
provider.api_keys = [
|
||||
_mock_key("k_openai", ["OPENAI"]),
|
||||
_mock_key("k_claude", ["CLAUDE"]),
|
||||
]
|
||||
|
||||
candidates = await scheduler._build_candidates(
|
||||
db=MagicMock(),
|
||||
providers=[provider],
|
||||
client_format=APIFormat.CLAUDE,
|
||||
model_name="dummy-model",
|
||||
affinity_key=None,
|
||||
global_conversion_enabled=True,
|
||||
)
|
||||
|
||||
assert len(candidates) == 2
|
||||
assert candidates[0].needs_conversion is False
|
||||
assert candidates[0].provider_api_format == "CLAUDE"
|
||||
assert candidates[1].needs_conversion is True
|
||||
assert candidates[1].provider_api_format == "OPENAI"
|
||||
110
tests/services/test_format_conversion_health_buckets.py
Normal file
110
tests/services/test_format_conversion_health_buckets.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.api_format import APIFormat
|
||||
from src.services.orchestration.error_classifier import ErrorClassifier
|
||||
from src.services.request.executor import RequestExecutor
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_async_cm():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_records_health_by_provider_format() -> None:
|
||||
db = MagicMock()
|
||||
|
||||
concurrency_manager = MagicMock()
|
||||
concurrency_manager.get_current_concurrency = AsyncMock(return_value=(0, 0))
|
||||
concurrency_manager.get_key_rpm_count = AsyncMock(return_value=1)
|
||||
concurrency_manager.rpm_guard = MagicMock(return_value=_noop_async_cm())
|
||||
|
||||
adaptive_manager = MagicMock()
|
||||
|
||||
provider = MagicMock()
|
||||
provider.id = "p1"
|
||||
provider.name = "p1"
|
||||
|
||||
endpoint = MagicMock()
|
||||
endpoint.id = "e1"
|
||||
endpoint.api_format = "OPENAI"
|
||||
|
||||
key = MagicMock()
|
||||
key.id = "k1"
|
||||
key.api_key = "encrypted"
|
||||
key.rpm_limit = 10
|
||||
key.learned_rpm_limit = None
|
||||
key.cache_ttl_minutes = 0
|
||||
|
||||
candidate = MagicMock()
|
||||
candidate.provider = provider
|
||||
candidate.endpoint = endpoint
|
||||
candidate.key = key
|
||||
candidate.is_cached = False
|
||||
|
||||
async def request_func(_provider, _endpoint, _key, _candidate): # noqa: ANN001
|
||||
return {"ok": True}
|
||||
|
||||
with patch("src.services.request.executor.RequestCandidateService.mark_candidate_started"), patch(
|
||||
"src.services.request.executor.RequestCandidateService.mark_candidate_success"
|
||||
), patch("src.services.request.executor.get_adaptive_reservation_manager") as mock_res_mgr, patch(
|
||||
"src.services.request.executor.health_monitor.record_success"
|
||||
) as record_success:
|
||||
mock_res_mgr.return_value.calculate_reservation.return_value = MagicMock(
|
||||
ratio=0.0, phase="stable", confidence=1.0
|
||||
)
|
||||
|
||||
executor = RequestExecutor(db=db, concurrency_manager=concurrency_manager, adaptive_manager=adaptive_manager)
|
||||
await executor.execute(
|
||||
candidate=candidate,
|
||||
candidate_id="c1",
|
||||
candidate_index=0,
|
||||
user_api_key=MagicMock(user_id="u1", id="ak1"),
|
||||
request_func=request_func,
|
||||
request_id="r1",
|
||||
api_format=APIFormat.CLAUDE, # client_format
|
||||
model_name="m",
|
||||
is_stream=False,
|
||||
)
|
||||
|
||||
record_success.assert_called()
|
||||
assert record_success.call_args.kwargs["api_format"] == "OPENAI"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_classifier_records_failure_by_provider_format() -> None:
|
||||
db = MagicMock()
|
||||
classifier = ErrorClassifier(db=db, cache_scheduler=None, adaptive_manager=MagicMock())
|
||||
|
||||
provider = MagicMock()
|
||||
provider.name = "p1"
|
||||
|
||||
endpoint = MagicMock()
|
||||
endpoint.id = "e1"
|
||||
endpoint.api_format = "OPENAI"
|
||||
|
||||
key = MagicMock()
|
||||
key.id = "k1"
|
||||
|
||||
with patch("src.services.orchestration.error_classifier.health_monitor.record_failure") as record_failure:
|
||||
await classifier.handle_retriable_error(
|
||||
error=RuntimeError("boom"),
|
||||
provider=provider,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
affinity_key="aff",
|
||||
api_format=APIFormat.CLAUDE, # client_format
|
||||
global_model_id="gm1",
|
||||
captured_key_concurrent=None,
|
||||
elapsed_ms=None,
|
||||
request_id="r1",
|
||||
attempt=1,
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
record_failure.assert_called()
|
||||
assert record_failure.call_args.kwargs["api_format"] == "OPENAI"
|
||||
|
||||
Reference in New Issue
Block a user