mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate - aether-data 扩展 repository 层:announcements、auth_modules、billing、 candidate_selection、gemini_file_mappings、global_models、management_tokens、 oauth_providers、proxy_nodes、quota、users、wallet 等模块 - aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/ video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块 - 重构 executor decision 和 gateway state 为模块目录结构 - 新增 gateway router、frontdoor 路由层及对应测试 - Python 侧 API 路由重构,新增 compat/support 模块 - 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
@@ -8,6 +8,7 @@ import pytest
|
||||
import src.api.handlers.base.chat_sync_executor as chat_sync_mod
|
||||
from src.api.handlers.base.chat_sync_executor import ChatSyncExecutor
|
||||
from src.core.exceptions import EmbeddedErrorException
|
||||
from src.core.exceptions import ProviderNotAvailableException
|
||||
from src.services.request.executor_plan import (
|
||||
ExecutionPlan,
|
||||
ExecutionPlanBody,
|
||||
@@ -145,15 +146,11 @@ async def test_execute_sync_plan_uses_rust_executor_when_available(
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _should_not_fallback(**kwargs: object) -> dict[str, object]:
|
||||
raise AssertionError("local execution should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _should_not_fallback)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
@@ -184,15 +181,11 @@ async def test_execute_sync_plan_allows_supported_proxy_urls_for_rust(
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _should_not_fallback(**kwargs: object) -> dict[str, object]:
|
||||
raise AssertionError("local execution should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _should_not_fallback)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
@@ -222,15 +215,11 @@ async def test_execute_sync_plan_allows_tunnel_delegate_for_rust(
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _should_not_fallback(**kwargs: object) -> dict[str, object]:
|
||||
raise AssertionError("local execution should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _should_not_fallback)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
@@ -258,15 +247,11 @@ async def test_execute_sync_plan_allows_tls_profile_for_rust(
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _should_not_fallback(**kwargs: object) -> dict[str, object]:
|
||||
raise AssertionError("local execution should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _should_not_fallback)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
@@ -294,15 +279,11 @@ async def test_execute_sync_plan_applies_envelope_postprocessing_after_rust(
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _should_not_fallback(**kwargs: object) -> dict[str, object]:
|
||||
raise AssertionError("local execution should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _should_not_fallback)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
@@ -357,16 +338,12 @@ async def test_execute_sync_plan_applies_format_conversion_after_rust(
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _should_not_fallback(**kwargs: object) -> dict[str, object]:
|
||||
raise AssertionError("local execution should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(chat_sync_mod, "get_format_converter_registry", lambda: _FakeRegistry())
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _should_not_fallback)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
@@ -420,9 +397,6 @@ async def test_execute_sync_plan_aggregates_upstream_stream_after_rust(
|
||||
headers={"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
async def _should_not_fallback(**kwargs: object) -> dict[str, object]:
|
||||
raise AssertionError("local execution should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
@@ -433,7 +407,6 @@ async def test_execute_sync_plan_aggregates_upstream_stream_after_rust(
|
||||
"src.api.handlers.base.upstream_stream_bridge.aggregate_upstream_stream_to_internal_response",
|
||||
_fake_aggregate,
|
||||
)
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _should_not_fallback)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
@@ -467,15 +440,11 @@ async def test_execute_sync_plan_turns_rust_http_error_into_httpx_status_error(
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _should_not_fallback(**kwargs: object) -> dict[str, object]:
|
||||
raise AssertionError("local execution should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _should_not_fallback)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await executor._execute_sync_plan(
|
||||
@@ -529,7 +498,7 @@ async def test_execute_sync_plan_preserves_embedded_error_semantics_from_rust(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_falls_back_to_local_when_rust_unavailable(
|
||||
async def test_execute_sync_plan_raises_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
@@ -541,25 +510,57 @@ async def test_execute_sync_plan_falls_back_to_local_when_rust_unavailable(
|
||||
assert plan.request_id == "req-test"
|
||||
raise RustExecutorClientError("executor down")
|
||||
|
||||
fallback_called = False
|
||||
|
||||
async def _fake_local_execute(**kwargs: object) -> dict[str, object]:
|
||||
nonlocal fallback_called
|
||||
fallback_called = True
|
||||
return {"id": "local-fallback"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(executor, "_execute_sync_plan_locally", _fake_local_execute)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
with pytest.raises(ProviderNotAvailableException) as exc_info:
|
||||
await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert exc_info.value.message == "执行器暂时不可用,请稍后重试"
|
||||
assert exc_info.value.upstream_response == "executor down"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_raises_when_remote_contract_is_ineligible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_prepared_plan()
|
||||
prepared_plan.contract.proxy = chat_sync_mod.ExecutionProxySnapshot(
|
||||
enabled=True,
|
||||
mode="tunnel",
|
||||
label="relay-node",
|
||||
)
|
||||
|
||||
assert fallback_called is True
|
||||
assert response == {"id": "local-fallback"}
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _should_not_call_rust(self: object, plan: ExecutionPlan) -> RustExecutorSyncResult:
|
||||
del self, plan
|
||||
raise AssertionError("rust executor should not be called")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_should_not_call_rust,
|
||||
)
|
||||
|
||||
assert prepared_plan.remote_eligible is False
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException) as exc_info:
|
||||
await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert exc_info.value.message == "执行器暂时不可用,请稍后重试"
|
||||
assert exc_info.value.upstream_response == (
|
||||
"execution contract is not eligible for rust executor"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import src.api.handlers.base.chat_handler_base as chatmod
|
||||
import src.services.proxy_node.resolver as proxymod
|
||||
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.core.exceptions import ProviderNotAvailableException
|
||||
from src.services.request.rust_executor_client import (
|
||||
RustExecutorClientError,
|
||||
RustExecutorStreamResult,
|
||||
@@ -78,6 +79,52 @@ class _FakeStreamProcessor:
|
||||
await response_ctx.__aexit__(None, None, None)
|
||||
|
||||
|
||||
class _FakeParser:
|
||||
def is_error_response(self, response_json: dict[str, Any]) -> bool:
|
||||
del response_json
|
||||
return False
|
||||
|
||||
|
||||
class _FakeInternalUsage:
|
||||
input_tokens = 3
|
||||
output_tokens = 5
|
||||
cache_read_tokens = 1
|
||||
cache_write_tokens = 0
|
||||
|
||||
|
||||
class _FakeInternalResponse:
|
||||
def __init__(self) -> None:
|
||||
self.id = "resp-sync"
|
||||
self.model = ""
|
||||
self.usage = _FakeInternalUsage()
|
||||
|
||||
|
||||
class _FakeSourceNormalizer:
|
||||
def response_to_internal(self, response_json: dict[str, Any]) -> _FakeInternalResponse:
|
||||
assert response_json == {"id": "sync-1", "message": "hello"}
|
||||
return _FakeInternalResponse()
|
||||
|
||||
|
||||
class _FakeTargetNormalizer:
|
||||
def stream_event_from_internal(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
state: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
assert event == {"kind": "chunk"}
|
||||
assert getattr(state, "message_id", "") == "resp-sync"
|
||||
return [{"delta": "hello"}]
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
def get_normalizer(self, format_id: str) -> Any:
|
||||
if format_id == "provider:test":
|
||||
return _FakeSourceNormalizer()
|
||||
if format_id == "openai:chat":
|
||||
return _FakeTargetNormalizer()
|
||||
raise AssertionError(f"unexpected format: {format_id}")
|
||||
|
||||
|
||||
class _DummyChatHandler(ChatHandlerBase):
|
||||
FORMAT_ID = "openai:chat"
|
||||
|
||||
@@ -200,7 +247,13 @@ async def test_execute_stream_request_uses_rust_executor_when_available(
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:chat")
|
||||
ctx.client_api_format = "openai:chat"
|
||||
|
||||
provider = SimpleNamespace(name="provider", id="provider-1", provider_type="", proxy=None)
|
||||
provider = SimpleNamespace(
|
||||
name="provider",
|
||||
id="provider-1",
|
||||
provider_type="",
|
||||
proxy=None,
|
||||
request_timeout=None,
|
||||
)
|
||||
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:chat", base_url="https://x")
|
||||
key = SimpleNamespace(id="key-1", proxy=None)
|
||||
candidate = SimpleNamespace(
|
||||
@@ -251,6 +304,110 @@ async def test_execute_stream_request_uses_rust_executor_when_available(
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_uses_rust_sync_executor_for_non_stream_upstream(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_stream_setup(monkeypatch)
|
||||
monkeypatch.setattr(chatmod.config, "executor_backend", "rust")
|
||||
|
||||
handler = _DummyChatHandler()
|
||||
stream_processor = _FakeStreamProcessor()
|
||||
stream_processor.on_streaming_start = None
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:chat")
|
||||
ctx.client_api_format = "openai:chat"
|
||||
|
||||
provider = SimpleNamespace(
|
||||
name="provider",
|
||||
id="provider-1",
|
||||
provider_type="",
|
||||
proxy=None,
|
||||
request_timeout=None,
|
||||
)
|
||||
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:chat", base_url="https://x")
|
||||
key = SimpleNamespace(id="key-1", proxy=None)
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
|
||||
async def _fake_prepare_provider_request(self: object, **kwargs: Any) -> object:
|
||||
del self, kwargs
|
||||
return chatmod.ProviderRequestResult(
|
||||
request_body={"model": "gpt-test", "messages": [{"role": "user", "content": "hello"}]},
|
||||
url_model="gpt-test",
|
||||
mapped_model=None,
|
||||
envelope=None,
|
||||
extra_headers={},
|
||||
upstream_is_stream=False,
|
||||
needs_conversion=False,
|
||||
provider_api_format="provider:test",
|
||||
client_api_format="openai:chat",
|
||||
auth_info=_DummyAuthInfo(),
|
||||
tls_profile=None,
|
||||
)
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> object:
|
||||
del self
|
||||
assert getattr(plan, "stream") is False
|
||||
return SimpleNamespace(
|
||||
status_code=200,
|
||||
response_json={"id": "sync-1", "message": "hello"},
|
||||
response_body_bytes=None,
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _should_not_call_stream(self: object, plan: object) -> RustExecutorStreamResult:
|
||||
del self, plan
|
||||
raise AssertionError("stream executor should not be used")
|
||||
|
||||
async def _should_not_get_http_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python upstream client should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
_DummyChatHandler,
|
||||
"_prepare_provider_request",
|
||||
_fake_prepare_provider_request,
|
||||
)
|
||||
monkeypatch.setattr(chatmod, "get_format_converter_registry", lambda: _FakeRegistry())
|
||||
monkeypatch.setattr(
|
||||
chatmod,
|
||||
"iter_internal_response_as_stream_events",
|
||||
lambda internal_resp: [{"kind": "chunk"}],
|
||||
)
|
||||
monkeypatch.setattr(chatmod, "get_parser_for_format", lambda _format: _FakeParser())
|
||||
monkeypatch.setattr(chatmod.RustExecutorClient, "execute_sync_json", _fake_execute_sync_json)
|
||||
monkeypatch.setattr(chatmod.RustExecutorClient, "execute_stream", _should_not_call_stream)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_upstream_client",
|
||||
_should_not_get_http_client,
|
||||
)
|
||||
|
||||
stream = await handler._execute_stream_request(
|
||||
ctx,
|
||||
stream_processor,
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
{"model": "gpt-test", "messages": [{"role": "user", "content": "hello"}]},
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
|
||||
received = [chunk async for chunk in stream]
|
||||
|
||||
assert received == [
|
||||
b'data: {"delta": "hello"}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
assert ctx.status_code == 200
|
||||
assert ctx.input_tokens == 3
|
||||
assert ctx.output_tokens == 5
|
||||
assert ctx.cached_tokens == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_accepts_async_generator_stream_processor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -501,7 +658,7 @@ async def test_execute_stream_request_turns_rust_upstream_error_into_http_status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_falls_back_to_python_when_rust_unavailable(
|
||||
async def test_execute_stream_request_raises_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_stream_setup(monkeypatch)
|
||||
@@ -525,12 +682,8 @@ async def test_execute_stream_request_falls_back_to_python_when_rust_unavailable
|
||||
del plan
|
||||
raise RustExecutorClientError("executor down")
|
||||
|
||||
class _FakeHTTPClient:
|
||||
def stream(self, **kwargs: Any) -> Any:
|
||||
raise RuntimeError("local-http-client-used")
|
||||
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> _FakeHTTPClient:
|
||||
return _FakeHTTPClient()
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python fallback should not be used")
|
||||
|
||||
monkeypatch.setattr(chatmod.RustExecutorClient, "execute_stream", _fake_execute_stream)
|
||||
monkeypatch.setattr(
|
||||
@@ -538,7 +691,7 @@ async def test_execute_stream_request_falls_back_to_python_when_rust_unavailable
|
||||
_fake_get_upstream_client,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
with pytest.raises(ProviderNotAvailableException) as exc_info:
|
||||
await handler._execute_stream_request(
|
||||
ctx,
|
||||
object(),
|
||||
@@ -550,4 +703,58 @@ async def test_execute_stream_request_falls_back_to_python_when_rust_unavailable
|
||||
candidate=candidate,
|
||||
)
|
||||
|
||||
assert "local-http-client-used" in str(exc_info.value)
|
||||
assert exc_info.value.message == "执行器暂时不可用,请稍后重试"
|
||||
assert exc_info.value.upstream_response == "executor down"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_raises_when_remote_contract_is_ineligible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_stream_setup(monkeypatch)
|
||||
monkeypatch.setattr(chatmod.config, "executor_backend", "rust")
|
||||
|
||||
handler = _DummyChatHandler()
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:chat")
|
||||
ctx.client_api_format = "openai:chat"
|
||||
|
||||
provider = SimpleNamespace(name="provider", id="provider-1", provider_type="", proxy=None)
|
||||
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:chat", base_url="https://x")
|
||||
key = SimpleNamespace(id="key-1", proxy=None)
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
|
||||
async def _should_not_call_rust(self: object, plan: object) -> RustExecutorStreamResult:
|
||||
del self, plan
|
||||
raise AssertionError("rust executor should not be called")
|
||||
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python fallback should not be used")
|
||||
|
||||
monkeypatch.setattr(chatmod, "is_remote_contract_eligible", lambda plan: False)
|
||||
monkeypatch.setattr(chatmod.RustExecutorClient, "execute_stream", _should_not_call_rust)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_upstream_client",
|
||||
_fake_get_upstream_client,
|
||||
)
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException) as exc_info:
|
||||
await handler._execute_stream_request(
|
||||
ctx,
|
||||
object(),
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
{"model": "gpt-test", "messages": [{"role": "user", "content": "hello"}]},
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
|
||||
assert exc_info.value.message == "执行器暂时不可用,请稍后重试"
|
||||
assert exc_info.value.upstream_response == (
|
||||
"execution contract is not eligible for rust executor"
|
||||
)
|
||||
|
||||
@@ -14,7 +14,9 @@ import src.services.task as taskmod
|
||||
from src.api.handlers.base.cli_stream_mixin import CliStreamMixin
|
||||
from src.api.handlers.base.cli_sync_mixin import CliSyncMixin
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.core.exceptions import ProviderNotAvailableException
|
||||
from src.services.request.rust_executor_client import (
|
||||
RustExecutorClientError,
|
||||
RustExecutorStreamResult,
|
||||
RustExecutorSyncResult,
|
||||
)
|
||||
@@ -46,7 +48,7 @@ class _DummyTelemetry:
|
||||
class _DummySyncHandler(CliSyncMixin):
|
||||
FORMAT_ID = "openai:cli"
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, upstream_is_stream: bool = False) -> None:
|
||||
self.db = None
|
||||
self.redis = None
|
||||
self.user = SimpleNamespace(id="user-1")
|
||||
@@ -62,6 +64,7 @@ class _DummySyncHandler(CliSyncMixin):
|
||||
self.telemetry = _DummyTelemetry()
|
||||
self.perf_metrics = None
|
||||
self._parser = _DummyParser()
|
||||
self._upstream_is_stream = upstream_is_stream
|
||||
|
||||
@property
|
||||
def parser(self) -> _DummyParser:
|
||||
@@ -120,7 +123,7 @@ class _DummySyncHandler(CliSyncMixin):
|
||||
url="https://upstream.test/v1/responses",
|
||||
url_model=str(payload.get("model") or ""),
|
||||
envelope=None,
|
||||
upstream_is_stream=False,
|
||||
upstream_is_stream=self._upstream_is_stream,
|
||||
tls_profile=None,
|
||||
selected_base_url=None,
|
||||
)
|
||||
@@ -299,6 +302,243 @@ async def test_cli_process_sync_uses_rust_executor_when_available(
|
||||
assert json.loads(response.body) == {"id": "resp-rust-cli"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_process_sync_aggregates_upstream_stream_after_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
handler = _DummySyncHandler(upstream_is_stream=True)
|
||||
monkeypatch.setattr(cli_sync_mod.config, "executor_backend", "rust")
|
||||
_patch_proxy_resolver(monkeypatch)
|
||||
|
||||
class _FakeTaskService:
|
||||
def __init__(self, db: Any, redis: Any) -> None:
|
||||
del db, redis
|
||||
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
provider = SimpleNamespace(
|
||||
name="provider",
|
||||
id="provider-1",
|
||||
provider_type="",
|
||||
proxy=None,
|
||||
request_timeout=None,
|
||||
stream_first_byte_timeout=None,
|
||||
)
|
||||
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:cli")
|
||||
key = SimpleNamespace(id="key-1", api_key="sk-test", proxy=None)
|
||||
response = await kwargs["request_func"](provider, endpoint, key, candidate)
|
||||
return SimpleNamespace(
|
||||
response=response,
|
||||
provider_name="provider",
|
||||
request_candidate_id="cand-1",
|
||||
provider_id="provider-1",
|
||||
endpoint_id="endpoint-1",
|
||||
key_id="key-1",
|
||||
pool_summary=None,
|
||||
)
|
||||
|
||||
class _FakeNormalizer:
|
||||
def response_from_internal(self, response: Any, *, requested_model: str) -> dict[str, Any]:
|
||||
return {
|
||||
"aggregated": True,
|
||||
"requested_model": requested_model,
|
||||
"internal_id": response.id,
|
||||
}
|
||||
|
||||
class _FakeRegistry:
|
||||
def get_normalizer(self, format_id: str) -> _FakeNormalizer:
|
||||
assert format_id == "openai:cli"
|
||||
return _FakeNormalizer()
|
||||
|
||||
captured_chunks: list[bytes] = []
|
||||
|
||||
async def _fake_aggregate(
|
||||
byte_iter: object,
|
||||
*,
|
||||
provider_api_format: str,
|
||||
provider_name: str,
|
||||
model: str,
|
||||
request_id: str,
|
||||
envelope: object = None,
|
||||
provider_parser: object = None,
|
||||
) -> object:
|
||||
del envelope, provider_parser
|
||||
async for chunk in byte_iter: # type: ignore[attr-defined]
|
||||
captured_chunks.append(chunk)
|
||||
assert provider_api_format == "openai:cli"
|
||||
assert provider_name == "provider"
|
||||
assert model == "gpt-4.1"
|
||||
assert request_id == "req-cli-sync"
|
||||
return SimpleNamespace(id="agg-cli-1")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> RustExecutorSyncResult:
|
||||
assert getattr(plan, "provider_api_format") == "openai:cli"
|
||||
assert getattr(plan, "stream") is True
|
||||
return RustExecutorSyncResult(
|
||||
status_code=200,
|
||||
response_body_bytes=b"data: {\"id\":\"chunk-1\"}\n\ndata: [DONE]\n\n",
|
||||
headers={"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python fallback should not be used")
|
||||
|
||||
monkeypatch.setattr(taskmod, "TaskService", _FakeTaskService)
|
||||
monkeypatch.setattr(
|
||||
cli_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(cli_sync_mod, "get_format_converter_registry", lambda: _FakeRegistry())
|
||||
monkeypatch.setattr(
|
||||
cli_sync_mod,
|
||||
"aggregate_upstream_stream_to_internal_response",
|
||||
_fake_aggregate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_upstream_client",
|
||||
_fake_get_upstream_client,
|
||||
)
|
||||
|
||||
response = await handler.process_sync(
|
||||
original_request_body={"model": "gpt-4.1", "input": "hello"},
|
||||
original_headers={},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {
|
||||
"aggregated": True,
|
||||
"requested_model": "gpt-4.1",
|
||||
"internal_id": "agg-cli-1",
|
||||
}
|
||||
assert captured_chunks == [b"data: {\"id\":\"chunk-1\"}\n\ndata: [DONE]\n\n"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_process_sync_raises_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
handler = _DummySyncHandler()
|
||||
monkeypatch.setattr(cli_sync_mod.config, "executor_backend", "rust")
|
||||
_patch_proxy_resolver(monkeypatch)
|
||||
|
||||
class _FakeTaskService:
|
||||
def __init__(self, db: Any, redis: Any) -> None:
|
||||
del db, redis
|
||||
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
provider = SimpleNamespace(
|
||||
name="provider",
|
||||
id="provider-1",
|
||||
provider_type="",
|
||||
proxy=None,
|
||||
request_timeout=None,
|
||||
stream_first_byte_timeout=None,
|
||||
)
|
||||
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:cli")
|
||||
key = SimpleNamespace(id="key-1", api_key="sk-test", proxy=None)
|
||||
await kwargs["request_func"](provider, endpoint, key, candidate)
|
||||
raise AssertionError("task service should not reach Python local execution")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> RustExecutorSyncResult:
|
||||
del self, plan
|
||||
raise RustExecutorClientError("executor down")
|
||||
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python fallback should not be used")
|
||||
|
||||
monkeypatch.setattr(taskmod, "TaskService", _FakeTaskService)
|
||||
monkeypatch.setattr(
|
||||
cli_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_upstream_client",
|
||||
_fake_get_upstream_client,
|
||||
)
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException) as exc_info:
|
||||
await handler.process_sync(
|
||||
original_request_body={"model": "gpt-4.1", "input": "hello"},
|
||||
original_headers={},
|
||||
)
|
||||
|
||||
assert exc_info.value.message == "执行器暂时不可用,请稍后重试"
|
||||
assert exc_info.value.upstream_response == "executor down"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_process_sync_raises_when_remote_contract_is_ineligible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
handler = _DummySyncHandler()
|
||||
monkeypatch.setattr(cli_sync_mod.config, "executor_backend", "rust")
|
||||
monkeypatch.setattr(cli_sync_mod, "is_remote_contract_eligible", lambda plan: False)
|
||||
_patch_proxy_resolver(monkeypatch)
|
||||
|
||||
class _FakeTaskService:
|
||||
def __init__(self, db: Any, redis: Any) -> None:
|
||||
del db, redis
|
||||
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
provider = SimpleNamespace(
|
||||
name="provider",
|
||||
id="provider-1",
|
||||
provider_type="",
|
||||
proxy=None,
|
||||
request_timeout=None,
|
||||
stream_first_byte_timeout=None,
|
||||
)
|
||||
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:cli")
|
||||
key = SimpleNamespace(id="key-1", api_key="sk-test", proxy=None)
|
||||
await kwargs["request_func"](provider, endpoint, key, candidate)
|
||||
raise AssertionError("task service should not complete after local upstream attempt")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> RustExecutorSyncResult:
|
||||
raise AssertionError("rust executor should not be used when contract is ineligible")
|
||||
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python fallback should not be used")
|
||||
|
||||
monkeypatch.setattr(taskmod, "TaskService", _FakeTaskService)
|
||||
monkeypatch.setattr(
|
||||
cli_sync_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_upstream_client",
|
||||
_fake_get_upstream_client,
|
||||
)
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException) as exc_info:
|
||||
await handler.process_sync(
|
||||
original_request_body={"model": "gpt-4.1", "input": "hello"},
|
||||
original_headers={},
|
||||
)
|
||||
|
||||
assert exc_info.value.message == "CLI 请求暂不支持当前 Rust executor 契约"
|
||||
assert exc_info.value.upstream_response == "remote_contract_ineligible"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_execute_stream_request_uses_rust_sync_bridge(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -355,11 +595,163 @@ async def test_cli_execute_stream_request_uses_rust_sync_bridge(
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
if hasattr(stream, "__await__"):
|
||||
stream = await stream
|
||||
chunks = [chunk async for chunk in stream]
|
||||
|
||||
assert chunks == [b"data: cli-bridge\n\n"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("upstream_is_stream", [False, True])
|
||||
async def test_cli_execute_stream_request_raises_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
upstream_is_stream: bool,
|
||||
) -> None:
|
||||
handler = _DummyCliStreamHandler(upstream_is_stream=upstream_is_stream)
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:cli")
|
||||
ctx.client_api_format = "openai:cli"
|
||||
|
||||
monkeypatch.setattr(cli_stream_mod.config, "executor_backend", "rust")
|
||||
_patch_proxy_resolver(monkeypatch)
|
||||
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python fallback should not be used")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_upstream_client",
|
||||
_fake_get_upstream_client,
|
||||
)
|
||||
|
||||
provider = SimpleNamespace(
|
||||
name="provider",
|
||||
id="provider-1",
|
||||
provider_type="",
|
||||
proxy=None,
|
||||
request_timeout=None,
|
||||
stream_first_byte_timeout=None,
|
||||
)
|
||||
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:cli", base_url="https://x")
|
||||
key = SimpleNamespace(id="key-1", proxy=None, api_key="sk-test", auth_type="")
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
|
||||
if upstream_is_stream:
|
||||
async def _fake_execute_stream(self: object, plan: object) -> RustExecutorStreamResult:
|
||||
del self, plan
|
||||
raise RustExecutorClientError("executor down")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.RustExecutorClient,
|
||||
"execute_stream",
|
||||
_fake_execute_stream,
|
||||
)
|
||||
else:
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> RustExecutorSyncResult:
|
||||
del self, plan
|
||||
raise RustExecutorClientError("executor down")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException) as exc_info:
|
||||
stream = await handler._execute_stream_request(
|
||||
ctx,
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
{"model": "gpt-test", "input": "hello"},
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
if hasattr(stream, "__await__"):
|
||||
stream = await stream
|
||||
_ = [chunk async for chunk in stream]
|
||||
|
||||
assert exc_info.value.message == "执行器暂时不可用,请稍后重试"
|
||||
assert exc_info.value.upstream_response == "executor down"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("upstream_is_stream", [False, True])
|
||||
async def test_cli_execute_stream_request_raises_when_remote_contract_is_ineligible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
upstream_is_stream: bool,
|
||||
) -> None:
|
||||
handler = _DummyCliStreamHandler(upstream_is_stream=upstream_is_stream)
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:cli")
|
||||
ctx.client_api_format = "openai:cli"
|
||||
|
||||
monkeypatch.setattr(cli_stream_mod.config, "executor_backend", "rust")
|
||||
monkeypatch.setattr(cli_stream_mod, "is_remote_contract_eligible", lambda plan: False)
|
||||
_patch_proxy_resolver(monkeypatch)
|
||||
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python fallback should not be used")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> RustExecutorSyncResult:
|
||||
raise AssertionError("rust sync executor should not be used when contract is ineligible")
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> RustExecutorStreamResult:
|
||||
raise AssertionError("rust stream executor should not be used when contract is ineligible")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_upstream_client",
|
||||
_fake_get_upstream_client,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.RustExecutorClient,
|
||||
"execute_stream",
|
||||
_fake_execute_stream,
|
||||
)
|
||||
|
||||
provider = SimpleNamespace(
|
||||
name="provider",
|
||||
id="provider-1",
|
||||
provider_type="",
|
||||
proxy=None,
|
||||
request_timeout=None,
|
||||
stream_first_byte_timeout=None,
|
||||
)
|
||||
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:cli", base_url="https://x")
|
||||
key = SimpleNamespace(id="key-1", proxy=None, api_key="sk-test", auth_type="")
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException) as exc_info:
|
||||
stream = await handler._execute_stream_request(
|
||||
ctx,
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
{"model": "gpt-test", "input": "hello"},
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
if hasattr(stream, "__await__"):
|
||||
stream = await stream
|
||||
_ = [chunk async for chunk in stream]
|
||||
|
||||
assert exc_info.value.message == "CLI 请求暂不支持当前 Rust executor 契约"
|
||||
assert exc_info.value.upstream_response == "remote_contract_ineligible"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_execute_stream_request_uses_rust_native_stream(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -417,6 +809,8 @@ async def test_cli_execute_stream_request_uses_rust_native_stream(
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
if hasattr(stream, "__await__"):
|
||||
stream = await stream
|
||||
chunks = [chunk async for chunk in stream]
|
||||
|
||||
assert chunks == [
|
||||
|
||||
@@ -166,3 +166,27 @@ async def test_endpoint_checker_proxy_snapshot_falls_back_to_system_proxy(
|
||||
assert snapshot.enabled is True
|
||||
assert snapshot.url == "http://system-proxy.test:8080"
|
||||
assert snapshot.mode == "http"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_checker_returns_503_when_rust_executor_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.handlers.base import endpoint_checker as mod
|
||||
|
||||
monkeypatch.setattr(mod.config, "executor_backend", "python")
|
||||
|
||||
executor = HttpRequestExecutor(timeout=5.0)
|
||||
result = await executor.execute(
|
||||
EndpointCheckRequest(
|
||||
url="https://upstream.test/v1/chat/completions",
|
||||
headers={"authorization": "Bearer test"},
|
||||
json_body={"model": "gpt-test", "messages": [{"role": "user", "content": "hi"}]},
|
||||
api_format="openai:chat",
|
||||
provider_name="openai",
|
||||
model_name="gpt-test",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status_code == 503
|
||||
assert result.error_message == "端点检查仅支持 Rust executor"
|
||||
|
||||
Reference in New Issue
Block a user