mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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:
235
tests/api/admin/test_admin_gemini_files_shell.py
Normal file
235
tests/api/admin/test_admin_gemini_files_shell.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _normalize_state(adapter: Any) -> dict[str, Any]:
|
||||
state: dict[str, Any] = {}
|
||||
for key, value in dict(getattr(adapter, "__dict__", {})).items():
|
||||
if hasattr(value, "model_dump"):
|
||||
state[key] = value.model_dump()
|
||||
elif hasattr(value, "filename"):
|
||||
state[key] = {
|
||||
"filename": value.filename,
|
||||
"content_type": getattr(value, "content_type", None),
|
||||
}
|
||||
else:
|
||||
state[key] = value
|
||||
return state
|
||||
|
||||
|
||||
def _build_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
pipeline_result: Any,
|
||||
) -> tuple[TestClient, list[dict[str, Any]]]:
|
||||
from src.api.admin import gemini_files as mod
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mod.router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*,
|
||||
adapter: Any,
|
||||
http_request: object,
|
||||
db: object,
|
||||
mode: object,
|
||||
api_format_hint: str | None = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
del http_request, db, api_format_hint, path_params
|
||||
calls.append(
|
||||
{
|
||||
"adapter_type": type(adapter).__name__,
|
||||
"mode": getattr(mode, "value", mode),
|
||||
"adapter_state": _normalize_state(adapter),
|
||||
}
|
||||
)
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", _fake_pipeline_run)
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
def test_admin_gemini_files_list_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
pipeline_result={"items": [], "total": 0, "page": 2, "page_size": 50},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/admin/gemini-files/mappings?page=2&page_size=50&include_expired=true&search=demo"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"items": [], "total": 0, "page": 2, "page_size": 50}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminGeminiFilesListMappingsAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {
|
||||
"page": 2,
|
||||
"page_size": 50,
|
||||
"include_expired": True,
|
||||
"search": "demo",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_admin_gemini_files_stats_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
pipeline_result={
|
||||
"total_mappings": 1,
|
||||
"active_mappings": 1,
|
||||
"expired_mappings": 0,
|
||||
"by_mime_type": {"text/plain": 1},
|
||||
"capable_keys_count": 2,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get("/api/admin/gemini-files/stats")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"total_mappings": 1,
|
||||
"active_mappings": 1,
|
||||
"expired_mappings": 0,
|
||||
"by_mime_type": {"text/plain": 1},
|
||||
"capable_keys_count": 2,
|
||||
}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminGeminiFilesStatsAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_admin_gemini_files_delete_mapping_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
pipeline_result={"message": "Mapping deleted successfully", "file_name": "files/1"},
|
||||
)
|
||||
|
||||
response = client.delete("/api/admin/gemini-files/mappings/mapping-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"message": "Mapping deleted successfully",
|
||||
"file_name": "files/1",
|
||||
}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminGeminiFilesDeleteMappingAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"mapping_id": "mapping-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_admin_gemini_files_cleanup_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
pipeline_result={"message": "Cleaned up 3 expired mappings", "deleted_count": 3},
|
||||
)
|
||||
|
||||
response = client.delete("/api/admin/gemini-files/mappings")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"message": "Cleaned up 3 expired mappings",
|
||||
"deleted_count": 3,
|
||||
}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminGeminiFilesCleanupMappingsAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_admin_gemini_files_capable_keys_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
pipeline_result=[{"id": "key-1", "name": "Key 1", "provider_name": "Gemini"}],
|
||||
)
|
||||
|
||||
response = client.get("/api/admin/gemini-files/capable-keys")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"id": "key-1", "name": "Key 1", "provider_name": "Gemini"}]
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminGeminiFilesCapableKeysAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_admin_gemini_files_upload_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
pipeline_result={
|
||||
"display_name": "example.txt",
|
||||
"mime_type": "text/plain",
|
||||
"size_bytes": 5,
|
||||
"results": [],
|
||||
"success_count": 0,
|
||||
"fail_count": 0,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/admin/gemini-files/upload?key_ids=key-1,key-2",
|
||||
files={"file": ("example.txt", b"hello", "text/plain")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"display_name": "example.txt",
|
||||
"mime_type": "text/plain",
|
||||
"size_bytes": 5,
|
||||
"results": [],
|
||||
"success_count": 0,
|
||||
"fail_count": 0,
|
||||
}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminGeminiFilesUploadAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {
|
||||
"file": {
|
||||
"filename": "example.txt",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"key_ids": "key-1,key-2",
|
||||
},
|
||||
}
|
||||
]
|
||||
164
tests/api/admin/test_provider_oauth_shell.py
Normal file
164
tests/api/admin/test_provider_oauth_shell.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("route_name", "expected_adapter", "expected_state"),
|
||||
[
|
||||
("supported_types", "AdminProviderOAuthUnavailableAdapter", {"operation": "supported_types"}),
|
||||
("start_oauth", "AdminProviderOAuthUnavailableAdapter", {"operation": "start_oauth"}),
|
||||
("complete_oauth", "AdminProviderOAuthUnavailableAdapter", {"operation": "complete_oauth"}),
|
||||
("refresh_oauth", "AdminProviderOAuthUnavailableAdapter", {"operation": "refresh_oauth"}),
|
||||
(
|
||||
"start_provider_oauth",
|
||||
"AdminProviderOAuthUnavailableAdapter",
|
||||
{"operation": "start_provider_oauth"},
|
||||
),
|
||||
(
|
||||
"complete_provider_oauth",
|
||||
"AdminProviderOAuthUnavailableAdapter",
|
||||
{"operation": "complete_provider_oauth"},
|
||||
),
|
||||
(
|
||||
"import_refresh_token",
|
||||
"AdminProviderOAuthUnavailableAdapter",
|
||||
{"operation": "import_refresh_token"},
|
||||
),
|
||||
(
|
||||
"batch_import_oauth",
|
||||
"AdminProviderOAuthUnavailableAdapter",
|
||||
{"operation": "batch_import_oauth"},
|
||||
),
|
||||
(
|
||||
"start_batch_import_oauth_task",
|
||||
"AdminProviderOAuthUnavailableAdapter",
|
||||
{"operation": "start_batch_import_oauth_task"},
|
||||
),
|
||||
(
|
||||
"get_batch_import_oauth_task_status",
|
||||
"AdminProviderOAuthUnavailableAdapter",
|
||||
{"operation": "get_batch_import_oauth_task_status"},
|
||||
),
|
||||
(
|
||||
"device_authorize",
|
||||
"AdminProviderOAuthUnavailableAdapter",
|
||||
{"operation": "device_authorize"},
|
||||
),
|
||||
("device_poll", "AdminProviderOAuthUnavailableAdapter", {"operation": "device_poll"}),
|
||||
],
|
||||
)
|
||||
async def test_admin_provider_oauth_routes_use_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
route_name: str,
|
||||
expected_adapter: str,
|
||||
expected_state: dict[str, Any],
|
||||
) -> None:
|
||||
from src.api.admin import provider_oauth as mod
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_run(*, adapter, http_request, db, mode, **_kwargs):
|
||||
captured.update(
|
||||
{
|
||||
"adapter": adapter,
|
||||
"request": http_request,
|
||||
"db": db,
|
||||
"mode": mode,
|
||||
}
|
||||
)
|
||||
if route_name == "supported_types":
|
||||
return []
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", fake_run)
|
||||
|
||||
request = SimpleNamespace(state=SimpleNamespace())
|
||||
db = object()
|
||||
|
||||
if route_name == "supported_types":
|
||||
result = await mod.supported_types(request=request, db=db, _=None)
|
||||
elif route_name == "start_oauth":
|
||||
result = await mod.start_oauth("key_1", request=request, db=db, _=None)
|
||||
elif route_name == "complete_oauth":
|
||||
result = await mod.complete_oauth(
|
||||
"key_1",
|
||||
mod.CompleteOAuthRequest(callback_url="http://localhost/?code=x&state=y"),
|
||||
request=request,
|
||||
db=db,
|
||||
_=None,
|
||||
)
|
||||
elif route_name == "refresh_oauth":
|
||||
result = await mod.refresh_oauth("key_1", request=request, db=db, _=None)
|
||||
elif route_name == "start_provider_oauth":
|
||||
result = await mod.start_provider_oauth("provider_1", request=request, db=db, _=None)
|
||||
elif route_name == "complete_provider_oauth":
|
||||
result = await mod.complete_provider_oauth(
|
||||
"provider_1",
|
||||
mod.ProviderCompleteOAuthRequest(callback_url="http://localhost/?code=x&state=y"),
|
||||
request=request,
|
||||
db=db,
|
||||
_=None,
|
||||
)
|
||||
elif route_name == "import_refresh_token":
|
||||
result = await mod.import_refresh_token(
|
||||
"provider_1",
|
||||
mod.ImportRefreshTokenRequest(refresh_token="refresh-token"),
|
||||
request=request,
|
||||
db=db,
|
||||
_=None,
|
||||
)
|
||||
elif route_name == "batch_import_oauth":
|
||||
result = await mod.batch_import_oauth(
|
||||
"provider_1",
|
||||
mod.BatchImportRequest(credentials="refresh-token"),
|
||||
request=request,
|
||||
db=db,
|
||||
_=None,
|
||||
)
|
||||
elif route_name == "start_batch_import_oauth_task":
|
||||
result = await mod.start_batch_import_oauth_task(
|
||||
"provider_1",
|
||||
mod.BatchImportRequest(credentials="refresh-token"),
|
||||
request=request,
|
||||
db=db,
|
||||
_=None,
|
||||
)
|
||||
elif route_name == "get_batch_import_oauth_task_status":
|
||||
result = await mod.get_batch_import_oauth_task_status(
|
||||
"provider_1",
|
||||
"task_1",
|
||||
request=request,
|
||||
db=db,
|
||||
_=None,
|
||||
)
|
||||
elif route_name == "device_authorize":
|
||||
result = await mod.device_authorize(
|
||||
"provider_1",
|
||||
mod.DeviceAuthorizeRequest(),
|
||||
request=request,
|
||||
db=db,
|
||||
_=None,
|
||||
)
|
||||
else:
|
||||
result = await mod.device_poll(
|
||||
"provider_1",
|
||||
mod.DevicePollRequest(session_id="session_1"),
|
||||
request=request,
|
||||
db=db,
|
||||
_=None,
|
||||
)
|
||||
|
||||
assert captured["request"] is request
|
||||
assert captured["db"] is db
|
||||
assert captured["mode"] == captured["adapter"].mode
|
||||
assert type(captured["adapter"]).__name__ == expected_adapter
|
||||
assert getattr(captured["adapter"], "__dict__", {}) == expected_state
|
||||
if route_name == "supported_types":
|
||||
assert result == []
|
||||
else:
|
||||
assert result == {"ok": True}
|
||||
410
tests/api/admin/test_provider_ops_shell.py
Normal file
410
tests/api/admin/test_provider_ops_shell.py
Normal file
@@ -0,0 +1,410 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _normalize_state(adapter: Any) -> dict[str, Any]:
|
||||
state: dict[str, Any] = {}
|
||||
for key, value in dict(getattr(adapter, "__dict__", {})).items():
|
||||
if hasattr(value, "model_dump"):
|
||||
state[key] = value.model_dump()
|
||||
else:
|
||||
state[key] = value
|
||||
return state
|
||||
|
||||
|
||||
def _build_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
pipeline_result: Any,
|
||||
) -> tuple[TestClient, list[dict[str, Any]]]:
|
||||
from src.api.admin.provider_ops import routes as mod
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mod.router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*,
|
||||
adapter: Any,
|
||||
http_request: object,
|
||||
db: object,
|
||||
mode: object,
|
||||
api_format_hint: str | None = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
del http_request, db, api_format_hint, path_params
|
||||
calls.append(
|
||||
{
|
||||
"adapter_type": type(adapter).__name__,
|
||||
"mode": getattr(mode, "value", mode),
|
||||
"adapter_state": _normalize_state(adapter),
|
||||
}
|
||||
)
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", _fake_pipeline_run)
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
def _architecture_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"architecture_id": "generic_api",
|
||||
"display_name": "Generic API",
|
||||
"description": "generic",
|
||||
"credentials_schema": {},
|
||||
"supported_auth_types": [],
|
||||
"supported_actions": [],
|
||||
"default_connector": None,
|
||||
}
|
||||
|
||||
|
||||
def _status_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"provider_id": "provider-1",
|
||||
"is_configured": True,
|
||||
"architecture_id": "generic_api",
|
||||
"connection_status": {
|
||||
"status": "connected",
|
||||
"auth_type": "api_key",
|
||||
"connected_at": None,
|
||||
"expires_at": None,
|
||||
"last_error": None,
|
||||
},
|
||||
"enabled_actions": ["balance"],
|
||||
}
|
||||
|
||||
|
||||
def _config_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"provider_id": "provider-1",
|
||||
"is_configured": True,
|
||||
"architecture_id": "generic_api",
|
||||
"base_url": "https://example.com",
|
||||
"connector": {
|
||||
"auth_type": "api_key",
|
||||
"config": {},
|
||||
"credentials": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _verify_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "ok",
|
||||
"data": {"verified": True},
|
||||
"updated_credentials": {"token": "masked"},
|
||||
}
|
||||
|
||||
|
||||
def _action_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "success",
|
||||
"action_type": "balance",
|
||||
"data": {"balance": "1.23"},
|
||||
"message": "ok",
|
||||
"executed_at": "2026-03-26T00:00:00+00:00",
|
||||
"response_time_ms": 12,
|
||||
"cache_ttl_seconds": 60,
|
||||
}
|
||||
|
||||
|
||||
def test_provider_ops_architectures_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=[_architecture_payload()])
|
||||
|
||||
response = client.get("/api/admin/provider-ops/architectures")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [_architecture_payload()]
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsListArchitecturesAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_architecture_detail_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=_architecture_payload())
|
||||
|
||||
response = client.get("/api/admin/provider-ops/architectures/generic_api")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _architecture_payload()
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsGetArchitectureAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"architecture_id": "generic_api"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_status_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=_status_payload())
|
||||
|
||||
response = client.get("/api/admin/provider-ops/providers/provider-1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _status_payload()
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsStatusAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"provider_id": "provider-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_config_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=_config_payload())
|
||||
|
||||
response = client.get("/api/admin/provider-ops/providers/provider-1/config")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _config_payload()
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsConfigAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"provider_id": "provider-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_save_config_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"success": True, "message": "ok"})
|
||||
payload = {
|
||||
"architecture_id": "generic_api",
|
||||
"base_url": "https://example.com",
|
||||
"connector": {
|
||||
"auth_type": "api_key",
|
||||
"config": {"region": "us"},
|
||||
"credentials": {"api_key": "secret"},
|
||||
},
|
||||
"actions": {"balance": {"enabled": True, "config": {"refresh": True}}},
|
||||
"schedule": {"balance": "0 * * * *"},
|
||||
}
|
||||
|
||||
response = client.put("/api/admin/provider-ops/providers/provider-1/config", json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"success": True, "message": "ok"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsSaveConfigAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {
|
||||
"provider_id": "provider-1",
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_verify_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=_verify_payload())
|
||||
payload = {
|
||||
"architecture_id": "generic_api",
|
||||
"base_url": "https://example.com",
|
||||
"connector": {
|
||||
"auth_type": "api_key",
|
||||
"config": {},
|
||||
"credentials": {"api_key": "secret"},
|
||||
},
|
||||
"actions": {},
|
||||
"schedule": {},
|
||||
}
|
||||
|
||||
response = client.post("/api/admin/provider-ops/providers/provider-1/verify", json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _verify_payload()
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsVerifyAuthAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {
|
||||
"provider_id": "provider-1",
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_delete_config_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"success": True, "message": "ok"})
|
||||
|
||||
response = client.delete("/api/admin/provider-ops/providers/provider-1/config")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"success": True, "message": "ok"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsDeleteConfigAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"provider_id": "provider-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_connect_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"success": True, "message": "ok"})
|
||||
|
||||
response = client.post(
|
||||
"/api/admin/provider-ops/providers/provider-1/connect",
|
||||
json={"credentials": {"api_key": "secret"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"success": True, "message": "ok"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsConnectAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {
|
||||
"provider_id": "provider-1",
|
||||
"payload": {"credentials": {"api_key": "secret"}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_disconnect_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"success": True, "message": "ok"})
|
||||
|
||||
response = client.post("/api/admin/provider-ops/providers/provider-1/disconnect")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"success": True, "message": "ok"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsDisconnectAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"provider_id": "provider-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_execute_action_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=_action_payload())
|
||||
|
||||
response = client.post(
|
||||
"/api/admin/provider-ops/providers/provider-1/actions/balance",
|
||||
json={"config": {"refresh": True}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _action_payload()
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsExecuteActionAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {
|
||||
"provider_id": "provider-1",
|
||||
"action_type": "balance",
|
||||
"payload": {"config": {"refresh": True}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_get_balance_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=_action_payload())
|
||||
|
||||
response = client.get("/api/admin/provider-ops/providers/provider-1/balance?refresh=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _action_payload()
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsGetBalanceAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {
|
||||
"provider_id": "provider-1",
|
||||
"refresh": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_refresh_balance_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=_action_payload())
|
||||
|
||||
response = client.post("/api/admin/provider-ops/providers/provider-1/balance")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _action_payload()
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsRefreshBalanceAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"provider_id": "provider-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_checkin_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result=_action_payload())
|
||||
|
||||
response = client.post("/api/admin/provider-ops/providers/provider-1/checkin")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _action_payload()
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsCheckinAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"provider_id": "provider-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_ops_batch_balance_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
pipeline_result={"provider-1": _action_payload(), "provider-2": _action_payload()},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/admin/provider-ops/batch/balance?provider_ids=provider-1&provider_ids=provider-2"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"provider-1": _action_payload(), "provider-2": _action_payload()}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "AdminProviderOpsBatchBalanceAdapter",
|
||||
"mode": "admin",
|
||||
"adapter_state": {"provider_ids": ["provider-1", "provider-2"]},
|
||||
}
|
||||
]
|
||||
259
tests/api/admin/test_rust_only_admin_surfaces.py
Normal file
259
tests/api/admin/test_rust_only_admin_surfaces.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, result: object) -> None:
|
||||
self._result = result
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object:
|
||||
return self._result
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, result: object) -> None:
|
||||
self._result = result
|
||||
|
||||
def query(self, *_args: object, **_kwargs: object) -> _FakeQuery:
|
||||
return _FakeQuery(self._result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_system_check_update_returns_unavailable_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin import system as mod
|
||||
|
||||
monkeypatch.setattr(mod, "_get_current_version", lambda: "1.2.3")
|
||||
|
||||
result = mod._build_check_update_unavailable_response()
|
||||
|
||||
assert result == {
|
||||
"current_version": "1.2.3",
|
||||
"latest_version": None,
|
||||
"has_update": False,
|
||||
"release_url": None,
|
||||
"release_notes": None,
|
||||
"published_at": None,
|
||||
"error": "检查更新需要 Rust 管理后端",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_system_aws_regions_uses_local_cache_when_present(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin import system as mod
|
||||
from src.core.cache_service import CacheService
|
||||
|
||||
monkeypatch.setattr(CacheService, "get", AsyncMock(return_value=["us-east-1", "us-west-2"]))
|
||||
mod._aws_regions_mem_cache = None
|
||||
|
||||
result = await mod._get_aws_regions_response()
|
||||
|
||||
assert result == {"regions": ["us-east-1", "us-west-2"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_system_aws_regions_raises_without_local_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin import system as mod
|
||||
from src.core.cache_service import CacheService
|
||||
|
||||
monkeypatch.setattr(CacheService, "get", AsyncMock(return_value=None))
|
||||
mod._aws_regions_mem_cache = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await mod._get_aws_regions_response()
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "AWS regions requires Rust admin backend"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_gemini_file_upload_requires_rust_uploader() -> None:
|
||||
from src.api.admin import gemini_files as mod
|
||||
|
||||
upload = UploadFile(filename="example.txt", file=BytesIO(b"hello"), headers=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await mod._upload_file_response(file=upload, key_ids="key_1")
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Admin Gemini file upload requires Rust uploader"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_external_models_returns_cached_data(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin.models import external as mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_get_cached_data",
|
||||
AsyncMock(return_value={"openai": {"official": True, "models": []}}),
|
||||
)
|
||||
|
||||
response = await mod._get_external_models_response()
|
||||
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 200
|
||||
assert b'"official":true' in response.body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_external_models_raise_without_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin.models import external as mod
|
||||
|
||||
monkeypatch.setattr(mod, "_get_cached_data", AsyncMock(return_value=None))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await mod._get_external_models_response()
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "External models catalog requires Rust admin backend"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_video_proxy_raises_when_google_proxy_is_required(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin.video_tasks import routes as mod
|
||||
from src.utils import auth_utils
|
||||
|
||||
task = SimpleNamespace(
|
||||
id="task_1",
|
||||
user_id="user_1",
|
||||
video_url="https://generativelanguage.googleapis.com/v1/media/video.mp4",
|
||||
)
|
||||
request = SimpleNamespace(cookies={}, headers={"Authorization": "Bearer token"})
|
||||
db = _FakeDB(task)
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_utils,
|
||||
"authenticate_user_from_bearer_token",
|
||||
AsyncMock(return_value=SimpleNamespace(id="admin_1", role=mod.UserRole.ADMIN)),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await mod._proxy_video_stream_response(task_id="task_1", request=request, token=None, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Admin video proxy requires Rust/public download path"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_usage_replay_requires_rust_maintenance_backend() -> None:
|
||||
from src.api.admin.usage import routes as mod
|
||||
|
||||
adapter = mod.AdminUsageReplayAdapter(usage_id="usage_1")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await adapter.handle(SimpleNamespace())
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Admin usage replay requires Rust maintenance backend"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("call_factory"),
|
||||
[
|
||||
lambda mod: mod.start_oauth("key_1", request=SimpleNamespace(), db=None, _=None),
|
||||
lambda mod: mod.complete_oauth(
|
||||
"key_1",
|
||||
mod.CompleteOAuthRequest(callback_url="http://localhost/?code=x&state=y"),
|
||||
request=SimpleNamespace(),
|
||||
db=None,
|
||||
_=None,
|
||||
),
|
||||
lambda mod: mod.refresh_oauth("key_1", request=SimpleNamespace(), db=None, _=None),
|
||||
lambda mod: mod.start_provider_oauth(
|
||||
"provider_1", request=SimpleNamespace(), db=None, _=None
|
||||
),
|
||||
lambda mod: mod.complete_provider_oauth(
|
||||
"provider_1",
|
||||
mod.ProviderCompleteOAuthRequest(
|
||||
callback_url="http://localhost/?code=x&state=y",
|
||||
),
|
||||
request=SimpleNamespace(),
|
||||
db=None,
|
||||
_=None,
|
||||
),
|
||||
lambda mod: mod.import_refresh_token(
|
||||
"provider_1",
|
||||
mod.ImportRefreshTokenRequest(refresh_token="refresh-token"),
|
||||
request=SimpleNamespace(),
|
||||
db=None,
|
||||
_=None,
|
||||
),
|
||||
lambda mod: mod.batch_import_oauth(
|
||||
"provider_1",
|
||||
mod.BatchImportRequest(credentials="refresh-token"),
|
||||
request=SimpleNamespace(),
|
||||
db=None,
|
||||
_=None,
|
||||
),
|
||||
lambda mod: mod.start_batch_import_oauth_task(
|
||||
"provider_1",
|
||||
mod.BatchImportRequest(credentials="refresh-token"),
|
||||
request=SimpleNamespace(),
|
||||
db=None,
|
||||
_=None,
|
||||
),
|
||||
lambda mod: mod.get_batch_import_oauth_task_status(
|
||||
"provider_1",
|
||||
"task_1",
|
||||
request=SimpleNamespace(),
|
||||
db=None,
|
||||
_=None,
|
||||
),
|
||||
lambda mod: mod.device_authorize(
|
||||
"provider_1",
|
||||
mod.DeviceAuthorizeRequest(),
|
||||
request=SimpleNamespace(),
|
||||
db=None,
|
||||
_=None,
|
||||
),
|
||||
lambda mod: mod.device_poll(
|
||||
"provider_1",
|
||||
mod.DevicePollRequest(session_id="session_1"),
|
||||
request=SimpleNamespace(),
|
||||
db=None,
|
||||
_=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_admin_provider_oauth_routes_require_rust_maintenance_backend(
|
||||
call_factory,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin import provider_oauth as mod
|
||||
|
||||
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: object, mode: object):
|
||||
_ = http_request, db, mode
|
||||
context = SimpleNamespace(add_audit_metadata=lambda **_kwargs: None)
|
||||
return await adapter.handle(context) # type: ignore[attr-defined]
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", _fake_pipeline_run)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await call_factory(mod)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Admin provider OAuth requires Rust maintenance backend"
|
||||
257
tests/api/admin/test_shell_routes.py
Normal file
257
tests/api/admin/test_shell_routes.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def _make_request(path: str, method: str = "POST") -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": method,
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_external_models_route_uses_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin.models import external as mod
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_run(*, adapter, http_request, db, mode, **_kwargs):
|
||||
captured.update(
|
||||
{"adapter": adapter, "request": http_request, "db": db, "mode": mode},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", fake_run)
|
||||
|
||||
db = SimpleNamespace(name="db")
|
||||
request = _make_request("/api/admin/models/external", method="GET")
|
||||
|
||||
result = await mod.get_external_models(request=request, db=db, _=SimpleNamespace())
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert isinstance(captured["adapter"], mod.AdminGetExternalModelsAdapter)
|
||||
assert captured["request"] is request
|
||||
assert captured["db"] is db
|
||||
assert captured["mode"] == captured["adapter"].mode
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("path", "adapter_type"),
|
||||
[
|
||||
("/api/admin/system/version", "AdminSystemVersionAdapter"),
|
||||
("/api/admin/system/check-update", "AdminSystemCheckUpdateAdapter"),
|
||||
("/api/admin/system/aws-regions", "AdminAwsRegionsAdapter"),
|
||||
],
|
||||
)
|
||||
async def test_admin_system_routes_use_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
path: str,
|
||||
adapter_type: str,
|
||||
) -> None:
|
||||
from src.api.admin import system as mod
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_run(*, adapter, http_request, db, mode, **_kwargs):
|
||||
captured.update(
|
||||
{"adapter": adapter, "request": http_request, "db": db, "mode": mode},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", fake_run)
|
||||
|
||||
db = SimpleNamespace(name="db")
|
||||
request = _make_request(path, method="GET")
|
||||
|
||||
if path.endswith("/version"):
|
||||
result = await mod.get_system_version(request=request, db=db)
|
||||
elif path.endswith("/check-update"):
|
||||
result = await mod.check_update(request=request, db=db)
|
||||
else:
|
||||
result = await mod.get_aws_regions(request=request, db=db)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert type(captured["adapter"]).__name__ == adapter_type
|
||||
assert captured["request"] is request
|
||||
assert captured["db"] is db
|
||||
assert captured["mode"] == captured["adapter"].mode
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_video_proxy_route_uses_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin.video_tasks import routes as mod
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_run(*, adapter, http_request, db, mode, **_kwargs):
|
||||
captured.update(
|
||||
{"adapter": adapter, "request": http_request, "db": db, "mode": mode},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", fake_run)
|
||||
|
||||
db = SimpleNamespace(name="db")
|
||||
request = _make_request("/api/admin/video-tasks/task_1/video", method="GET")
|
||||
|
||||
result = await mod.proxy_video_stream(task_id="task_1", request=request, token="query-token", db=db)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert isinstance(captured["adapter"], mod.VideoTaskProxyVideoAdapter)
|
||||
assert captured["adapter"].task_id == "task_1"
|
||||
assert captured["adapter"].token == "query-token"
|
||||
assert captured["request"] is request
|
||||
assert captured["db"] is db
|
||||
assert captured["mode"] == captured["adapter"].mode
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_external_models_cache_route_uses_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin.models import external as mod
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_run(*, adapter, http_request, db, mode, **_kwargs):
|
||||
captured.update(
|
||||
{"adapter": adapter, "request": http_request, "db": db, "mode": mode},
|
||||
)
|
||||
return {"cleared": True}
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", fake_run)
|
||||
|
||||
db = SimpleNamespace(name="db")
|
||||
request = _make_request("/api/admin/models/external/cache", method="DELETE")
|
||||
|
||||
result = await mod.clear_external_models_cache(request=request, db=db, _=SimpleNamespace())
|
||||
|
||||
assert result == {"cleared": True}
|
||||
assert isinstance(captured["adapter"], mod.AdminClearExternalModelsCacheAdapter)
|
||||
assert captured["request"] is request
|
||||
assert captured["db"] is db
|
||||
assert captured["mode"] == captured["adapter"].mode
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_external_models_adapters_delegate_to_helpers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin.models import external as mod
|
||||
|
||||
get_cached = AsyncMock(return_value={"ok": True})
|
||||
clear_cached = AsyncMock(return_value={"cleared": True})
|
||||
monkeypatch.setattr(mod, "_get_external_models_response", get_cached)
|
||||
monkeypatch.setattr(mod, "_clear_external_models_cache_response", clear_cached)
|
||||
|
||||
get_result = await mod.AdminGetExternalModelsAdapter().handle(SimpleNamespace())
|
||||
clear_result = await mod.AdminClearExternalModelsCacheAdapter().handle(SimpleNamespace())
|
||||
|
||||
assert get_result == {"ok": True}
|
||||
assert clear_result == {"cleared": True}
|
||||
get_cached.assert_awaited_once()
|
||||
clear_cached.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_query_routes_use_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin import provider_query as mod
|
||||
from fastapi import HTTPException
|
||||
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
async def fake_run(*, adapter, http_request, db, mode, **_kwargs):
|
||||
captured.append({"adapter": adapter, "request": http_request, "db": db, "mode": mode})
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", fake_run)
|
||||
|
||||
db = SimpleNamespace(name="db")
|
||||
|
||||
models_payload = mod.ModelsQueryRequest(provider_id="provider_1")
|
||||
models_request = _make_request("/api/admin/provider-query/models")
|
||||
with pytest.raises(HTTPException) as models_exc:
|
||||
await mod.query_available_models(models_payload, models_request, db=db)
|
||||
|
||||
test_payload = mod.TestModelRequest(provider_id="provider_1", model_name="gpt-4o")
|
||||
test_request = _make_request("/api/admin/provider-query/test-model")
|
||||
with pytest.raises(HTTPException) as test_exc:
|
||||
await mod.test_model(test_payload, test_request, db=db)
|
||||
|
||||
failover_payload = mod.TestModelFailoverRequest(
|
||||
provider_id="provider_1",
|
||||
mode="direct",
|
||||
model_name="gpt-4o",
|
||||
)
|
||||
failover_request = _make_request("/api/admin/provider-query/test-model-failover")
|
||||
with pytest.raises(HTTPException) as failover_exc:
|
||||
await mod.test_model_failover(failover_payload, failover_request, db=db)
|
||||
|
||||
assert models_exc.value.status_code == 503
|
||||
assert test_exc.value.status_code == 503
|
||||
assert failover_exc.value.status_code == 503
|
||||
assert "requires Rust maintenance backend" in str(models_exc.value.detail)
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_query_adapters_delegate_to_helpers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.admin import provider_query as mod
|
||||
|
||||
models_helper = AsyncMock(return_value={"kind": "models"})
|
||||
test_helper = AsyncMock(return_value={"kind": "test"})
|
||||
failover_helper = AsyncMock(return_value={"kind": "failover"})
|
||||
monkeypatch.setattr(mod, "_query_available_models_response", models_helper)
|
||||
monkeypatch.setattr(mod, "_test_model_response", test_helper)
|
||||
monkeypatch.setattr(mod, "_test_model_failover_response", failover_helper)
|
||||
|
||||
db = SimpleNamespace(name="db")
|
||||
user = SimpleNamespace(id="user_1")
|
||||
request = _make_request("/api/admin/provider-query/test-model-failover")
|
||||
context = SimpleNamespace(db=db, user=user, request=request)
|
||||
|
||||
models_payload = mod.ModelsQueryRequest(provider_id="provider_1")
|
||||
test_payload = mod.TestModelRequest(provider_id="provider_1", model_name="gpt-4o")
|
||||
failover_payload = mod.TestModelFailoverRequest(
|
||||
provider_id="provider_1",
|
||||
mode="direct",
|
||||
model_name="gpt-4o",
|
||||
)
|
||||
|
||||
models_result = await mod.ProviderQueryModelsAdapter(payload=models_payload).handle(context)
|
||||
test_result = await mod.ProviderQueryTestModelAdapter(payload=test_payload).handle(context)
|
||||
failover_result = await mod.ProviderQueryTestModelFailoverAdapter(
|
||||
payload=failover_payload
|
||||
).handle(context)
|
||||
|
||||
assert models_result == {"kind": "models"}
|
||||
assert test_result == {"kind": "test"}
|
||||
assert failover_result == {"kind": "failover"}
|
||||
|
||||
models_helper.assert_awaited_once_with(models_payload, db)
|
||||
test_helper.assert_awaited_once_with(test_payload, db, user)
|
||||
failover_helper.assert_awaited_once_with(failover_payload, request, db, user)
|
||||
@@ -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"
|
||||
|
||||
@@ -14,6 +14,7 @@ import src.services.proxy_node.resolver as resolver_mod
|
||||
import src.services.request.rust_executor_client as rust_client_mod
|
||||
from src.api.handlers.gemini.video_handler import GeminiVeoHandler
|
||||
from src.core.api_format.conversion.internal_video import VideoStatus
|
||||
from src.core.exceptions import ProviderNotAvailableException
|
||||
from src.services.request.rust_executor_client import RustExecutorStreamResult
|
||||
|
||||
|
||||
@@ -82,12 +83,6 @@ async def test_handle_create_task_uses_rust_sync_helper(
|
||||
"x-goog-api-key": upstream_key
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
video_mod.HTTPClientPool,
|
||||
"get_default_client_async",
|
||||
AsyncMock(side_effect=AssertionError("python fallback should not run")),
|
||||
)
|
||||
|
||||
async def _fake_rust_sync(**kwargs: object) -> httpx.Response:
|
||||
assert kwargs["method"] == "POST"
|
||||
assert kwargs["provider_id"] == "prov-1"
|
||||
@@ -207,12 +202,6 @@ async def test_handle_download_content_uses_rust_executor_with_proxy_snapshot(
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rust_client_mod.RustExecutorClient, "execute_stream", _fake_execute_stream)
|
||||
monkeypatch.setattr(
|
||||
video_mod.HTTPClientPool,
|
||||
"get_default_client_async",
|
||||
AsyncMock(side_effect=AssertionError("python fallback should not run")),
|
||||
)
|
||||
|
||||
response = await handler.handle_download_content(
|
||||
task_id="operations/ext-1",
|
||||
http_request=SimpleNamespace(),
|
||||
@@ -224,3 +213,39 @@ async def test_handle_download_content_uses_rust_executor_with_proxy_snapshot(
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
assert body == b"gemini-video"
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_download_content_raises_when_rust_backend_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(video_mod.config, "executor_backend", "python")
|
||||
handler = _make_handler()
|
||||
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_get_task_by_external_id",
|
||||
lambda task_id: SimpleNamespace(
|
||||
id=task_id,
|
||||
status=VideoStatus.COMPLETED.value,
|
||||
video_url="https://storage.example.com/video.mp4",
|
||||
video_expires_at=datetime.now(timezone.utc).replace(year=2099),
|
||||
model="veo-3",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_get_endpoint_and_key",
|
||||
lambda task: (
|
||||
SimpleNamespace(id="ep-1", provider_id="prov-1", proxy=None),
|
||||
SimpleNamespace(id="key-1", api_key=None, proxy=None),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException):
|
||||
await handler.handle_download_content(
|
||||
task_id="operations/ext-1",
|
||||
http_request=SimpleNamespace(),
|
||||
original_headers={},
|
||||
query_params=None,
|
||||
)
|
||||
|
||||
@@ -5,11 +5,12 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import src.api.handlers.openai.video_handler as video_mod
|
||||
from src.api.handlers.openai.video_handler import OpenAIVideoHandler
|
||||
from src.core.api_format.conversion.internal_video import VideoStatus
|
||||
from src.core.exceptions import ProviderNotAvailableException
|
||||
from src.services.request.rust_executor_client import (
|
||||
RustExecutorClientError,
|
||||
RustExecutorStreamResult,
|
||||
@@ -157,7 +158,7 @@ async def test_handle_download_content_uses_rust_executor_for_upstream_content_e
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_download_content_falls_back_to_python_proxy_when_rust_unavailable(
|
||||
async def test_handle_download_content_raises_when_rust_executor_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(video_mod.config, "executor_backend", "rust")
|
||||
@@ -178,21 +179,11 @@ async def test_handle_download_content_falls_back_to_python_proxy_when_rust_unav
|
||||
del self, plan
|
||||
raise RustExecutorClientError("executor down")
|
||||
|
||||
fallback_response = Response(content=b"python-fallback", media_type="video/mp4")
|
||||
|
||||
async def _fake_proxy_direct_url(url: str, task_id: str) -> Response:
|
||||
assert url == "https://cdn.example.com/video.mp4"
|
||||
assert task_id == "task-1"
|
||||
return fallback_response
|
||||
|
||||
monkeypatch.setattr(video_mod.RustExecutorClient, "execute_stream", _failing_execute_stream)
|
||||
monkeypatch.setattr(handler, "_proxy_direct_url", _fake_proxy_direct_url)
|
||||
|
||||
response = await handler.handle_download_content(
|
||||
task_id="task-1",
|
||||
http_request=SimpleNamespace(),
|
||||
original_headers={},
|
||||
query_params={"variant": "video"},
|
||||
)
|
||||
|
||||
assert response is fallback_response
|
||||
with pytest.raises(ProviderNotAvailableException):
|
||||
await handler.handle_download_content(
|
||||
task_id="task-1",
|
||||
http_request=SimpleNamespace(),
|
||||
original_headers={},
|
||||
query_params={"variant": "video"},
|
||||
)
|
||||
|
||||
@@ -2877,3 +2877,286 @@ async def test_build_openai_cli_stream_plan_rejects_force_rewrite_envelope(
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_openai_cli_stream_plan_rejects_codex_transport_for_direct_executor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.handlers.openai_cli import OpenAICliAdapter
|
||||
|
||||
adapter = OpenAICliAdapter()
|
||||
fake_context = SimpleNamespace(
|
||||
path_params={},
|
||||
request_id="req-cli-stream-codex-transport-123",
|
||||
client_ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
start_time=0.0,
|
||||
original_headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
||||
query_params={},
|
||||
client_content_encoding=None,
|
||||
extra={"perf": None},
|
||||
)
|
||||
fake_context.ensure_json_body_async = AsyncMock(
|
||||
return_value={"model": "gpt-5", "input": "hello", "stream": True}
|
||||
)
|
||||
fake_candidate = SimpleNamespace(
|
||||
provider=SimpleNamespace(
|
||||
id="provider-cli-stream-codex-transport-123",
|
||||
name="codex",
|
||||
proxy=None,
|
||||
),
|
||||
endpoint=SimpleNamespace(id="endpoint-cli-stream-codex-transport-123", api_format="openai:cli"),
|
||||
key=SimpleNamespace(id="key-cli-stream-codex-transport-123", proxy=None),
|
||||
mapping_matched_model="gpt-5",
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
request_candidate_id="cand-cli-stream-codex-transport-123",
|
||||
)
|
||||
fake_upstream_request = SimpleNamespace(
|
||||
url="https://chatgpt.com/backend-api/codex/responses",
|
||||
headers={"content-type": "application/json", "accept": "text/event-stream"},
|
||||
payload={"model": "gpt-5", "input": "hello", "stream": True},
|
||||
upstream_is_stream=True,
|
||||
envelope=None,
|
||||
tls_profile=None,
|
||||
)
|
||||
|
||||
class FakeCliHandler:
|
||||
primary_api_format = "openai:cli"
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def extract_model_from_request(
|
||||
self, body: dict[str, object], path_params: dict[str, str]
|
||||
) -> str:
|
||||
return "gpt-5"
|
||||
|
||||
def _resolve_capability_requirements(self, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
async def _resolve_preferred_key_ids(self, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
async def _get_mapped_model(self, **kwargs: object) -> str:
|
||||
return "gpt-5"
|
||||
|
||||
async def _build_upstream_request(self, **kwargs: object) -> SimpleNamespace:
|
||||
return fake_upstream_request
|
||||
|
||||
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
||||
monkeypatch.setattr(
|
||||
type(adapter),
|
||||
"HANDLER_CLASS",
|
||||
property(lambda self: FakeCliHandler),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"_merge_path_params",
|
||||
lambda body, path_params: body,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
||||
lambda decision, path: (adapter, {}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._load_gateway_auth_models",
|
||||
lambda db, auth_context: (
|
||||
SimpleNamespace(id="user-cli-stream-codex-transport-123"),
|
||||
SimpleNamespace(id="api-key-cli-stream-codex-transport-123"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway.get_pipeline",
|
||||
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._build_gateway_request_context",
|
||||
lambda **kwargs: fake_context,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._select_gateway_direct_candidate",
|
||||
AsyncMock(return_value=fake_candidate),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.build_proxy_url_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
decision = classify_gateway_route("POST", "/v1/responses")
|
||||
auth_context = GatewayAuthContext(
|
||||
user_id="user-cli-stream-codex-transport-123",
|
||||
api_key_id="api-key-cli-stream-codex-transport-123",
|
||||
access_allowed=True,
|
||||
)
|
||||
payload = GatewayExecuteRequest(
|
||||
method="POST",
|
||||
path="/v1/responses",
|
||||
headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
||||
body_json={"model": "gpt-5", "input": "hello", "stream": True},
|
||||
auth_context=auth_context,
|
||||
)
|
||||
|
||||
result = await _build_openai_cli_stream_plan(
|
||||
request=SimpleNamespace(),
|
||||
payload=payload,
|
||||
db=object(),
|
||||
auth_context=auth_context,
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_openai_cli_sync_plan_rejects_codex_transport_for_direct_executor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.handlers.openai_cli import OpenAICliAdapter
|
||||
|
||||
adapter = OpenAICliAdapter()
|
||||
fake_context = SimpleNamespace(
|
||||
path_params={},
|
||||
request_id="req-cli-sync-codex-transport-123",
|
||||
client_ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
start_time=0.0,
|
||||
original_headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
||||
query_params={},
|
||||
client_content_encoding=None,
|
||||
extra={"perf": None},
|
||||
)
|
||||
fake_context.ensure_json_body_async = AsyncMock(
|
||||
return_value={"model": "gpt-5", "input": "hello"}
|
||||
)
|
||||
fake_candidate = SimpleNamespace(
|
||||
provider=SimpleNamespace(
|
||||
id="provider-cli-sync-codex-transport-123",
|
||||
name="codex",
|
||||
proxy=None,
|
||||
request_timeout=None,
|
||||
),
|
||||
endpoint=SimpleNamespace(id="endpoint-cli-sync-codex-transport-123", api_format="openai:cli"),
|
||||
key=SimpleNamespace(id="key-cli-sync-codex-transport-123", proxy=None),
|
||||
mapping_matched_model="gpt-5",
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
request_candidate_id="cand-cli-sync-codex-transport-123",
|
||||
)
|
||||
fake_upstream_request = SimpleNamespace(
|
||||
url="https://chatgpt.com/backendapi/codex/responses",
|
||||
headers={"content-type": "application/json"},
|
||||
payload={"model": "gpt-5", "input": "hello"},
|
||||
upstream_is_stream=False,
|
||||
envelope=None,
|
||||
tls_profile=None,
|
||||
)
|
||||
|
||||
class FakeCliHandler:
|
||||
primary_api_format = "openai:cli"
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def extract_model_from_request(
|
||||
self, body: dict[str, object], path_params: dict[str, str]
|
||||
) -> str:
|
||||
return "gpt-5"
|
||||
|
||||
def _resolve_capability_requirements(self, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
async def _resolve_preferred_key_ids(self, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
async def _get_mapped_model(self, **kwargs: object) -> str:
|
||||
return "gpt-5"
|
||||
|
||||
async def _build_upstream_request(self, **kwargs: object) -> SimpleNamespace:
|
||||
return fake_upstream_request
|
||||
|
||||
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
||||
monkeypatch.setattr(
|
||||
type(adapter),
|
||||
"HANDLER_CLASS",
|
||||
property(lambda self: FakeCliHandler),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"_merge_path_params",
|
||||
lambda body, path_params: body,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
||||
lambda decision, path: (adapter, {}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._load_gateway_auth_models",
|
||||
lambda db, auth_context: (
|
||||
SimpleNamespace(id="user-cli-sync-codex-transport-123"),
|
||||
SimpleNamespace(id="api-key-cli-sync-codex-transport-123"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway.get_pipeline",
|
||||
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._build_gateway_request_context",
|
||||
lambda **kwargs: fake_context,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._select_gateway_direct_candidate",
|
||||
AsyncMock(return_value=fake_candidate),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.build_proxy_url_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.get_system_proxy_config_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
decision = classify_gateway_route("POST", "/v1/responses")
|
||||
auth_context = GatewayAuthContext(
|
||||
user_id="user-cli-sync-codex-transport-123",
|
||||
api_key_id="api-key-cli-sync-codex-transport-123",
|
||||
access_allowed=True,
|
||||
)
|
||||
payload = GatewayExecuteRequest(
|
||||
method="POST",
|
||||
path="/v1/responses",
|
||||
headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
||||
body_json={"model": "gpt-5", "input": "hello"},
|
||||
auth_context=auth_context,
|
||||
)
|
||||
|
||||
result = await _build_openai_cli_sync_plan(
|
||||
request=SimpleNamespace(),
|
||||
payload=payload,
|
||||
db=object(),
|
||||
auth_context=auth_context,
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@@ -75,6 +75,14 @@ def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def _make_legacy_test_client(app: FastAPI) -> TestClient:
|
||||
return TestClient(
|
||||
app,
|
||||
base_url="http://127.0.0.1",
|
||||
headers={"x-aether-legacy-internal-gateway": "true"},
|
||||
)
|
||||
|
||||
|
||||
def test_finalize_sync_route_finalizes_openai_video_create_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -96,7 +104,7 @@ def test_finalize_sync_route_finalizes_openai_video_create_response(
|
||||
finalize_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -144,7 +152,7 @@ def test_finalize_sync_route_finalizes_openai_chat_response(
|
||||
finalize_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -193,7 +201,7 @@ def test_finalize_sync_route_finalizes_openai_cli_response(
|
||||
finalize_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -753,7 +761,7 @@ def test_finalize_sync_route_uses_chat_fast_path_without_db_session(
|
||||
lambda: (_ for _ in ()).throw(AssertionError("create_session should not be called")),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -802,7 +810,7 @@ def test_finalize_sync_route_uses_cli_fast_path_without_db_session(
|
||||
lambda: (_ for _ in ()).throw(AssertionError("create_session should not be called")),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -847,7 +855,7 @@ def test_finalize_sync_route_finalizes_openai_video_remix_response(
|
||||
finalize_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -896,7 +904,7 @@ def test_finalize_sync_route_finalizes_gemini_video_create_response(
|
||||
finalize_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -945,7 +953,7 @@ def test_finalize_sync_route_finalizes_openai_video_delete_response(
|
||||
finalize_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -984,7 +992,7 @@ def test_finalize_sync_route_finalizes_openai_video_cancel_response(
|
||||
finalize_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -1019,7 +1027,7 @@ def test_finalize_sync_route_finalizes_gemini_video_cancel_response(
|
||||
finalize_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
@@ -1040,3 +1048,278 @@ def test_finalize_sync_route_finalizes_gemini_video_cancel_response(
|
||||
assert response.headers[CONTROL_EXECUTED_HEADER] == "true"
|
||||
assert response.json() == {}
|
||||
finalize_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_gateway_openai_video_create_sync_reuses_rust_owned_task(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.internal import gateway as gateway_module
|
||||
|
||||
background_mock = AsyncMock(return_value=None)
|
||||
begin_pending_usage = MagicMock()
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, value: Any) -> None:
|
||||
self._value = value
|
||||
|
||||
def filter(self, *args: Any, **kwargs: Any) -> "FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> Any:
|
||||
return self._value
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self) -> None:
|
||||
self._mapping = {
|
||||
"User": SimpleNamespace(id="user-123"),
|
||||
"ApiKey": SimpleNamespace(id="key-123"),
|
||||
"VideoTask": SimpleNamespace(id="task-local-123"),
|
||||
}
|
||||
|
||||
def query(self, model: Any) -> FakeQuery:
|
||||
return FakeQuery(self._mapping.get(getattr(model, "__name__", "")))
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
raise AssertionError("legacy task creation should be skipped")
|
||||
|
||||
class FakeOpenAIVideoHandler:
|
||||
FORMAT_ID = "openai:video"
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self._normalizer = SimpleNamespace(
|
||||
video_task_from_internal=lambda task: {
|
||||
"id": task["id"],
|
||||
"object": "video",
|
||||
"status": "submitted",
|
||||
}
|
||||
)
|
||||
|
||||
def _task_to_internal(self, task: Any) -> dict[str, Any]:
|
||||
return {"id": task.id}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.handlers.openai.video_handler.OpenAIVideoHandler",
|
||||
FakeOpenAIVideoHandler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._run_gateway_video_finalize_submitted_background",
|
||||
background_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.usage.service.UsageService.begin_pending_usage",
|
||||
begin_pending_usage,
|
||||
)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-openai-video-rust-owner-123",
|
||||
report_kind="openai_video_create_sync_success",
|
||||
report_context={
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"provider_id": "provider-123",
|
||||
"endpoint_id": "endpoint-123",
|
||||
"key_id": "provider-key-123",
|
||||
"request_id": "req-openai-video-rust-owner-123",
|
||||
"local_task_id": "task-local-123",
|
||||
"provider_name": "openai",
|
||||
"provider_api_format": "openai:video",
|
||||
"rust_video_task_persisted": True,
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"id": "ext-video-123", "status": "submitted"},
|
||||
telemetry={"elapsed_ms": 42},
|
||||
)
|
||||
|
||||
response = await gateway_module._finalize_gateway_openai_video_create_sync(
|
||||
payload,
|
||||
db=FakeDB(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {
|
||||
"id": "task-local-123",
|
||||
"object": "video",
|
||||
"status": "submitted",
|
||||
}
|
||||
begin_pending_usage.assert_not_called()
|
||||
background_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_gateway_openai_video_remix_sync_reuses_rust_owned_task(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.internal import gateway as gateway_module
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, value: Any) -> None:
|
||||
self._value = value
|
||||
|
||||
def filter(self, *args: Any, **kwargs: Any) -> "FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> Any:
|
||||
return self._value
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self) -> None:
|
||||
self._mapping = {
|
||||
"User": SimpleNamespace(id="user-123"),
|
||||
"ApiKey": SimpleNamespace(id="key-123"),
|
||||
"VideoTask": SimpleNamespace(id="task-local-remix-123"),
|
||||
}
|
||||
|
||||
def query(self, model: Any) -> FakeQuery:
|
||||
return FakeQuery(self._mapping.get(getattr(model, "__name__", "")))
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
raise AssertionError("legacy remix task creation should be skipped")
|
||||
|
||||
class FakeOpenAIVideoHandler:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self._normalizer = SimpleNamespace(
|
||||
video_task_from_internal=lambda task: {
|
||||
"id": task["id"],
|
||||
"object": "video",
|
||||
"status": "submitted",
|
||||
}
|
||||
)
|
||||
|
||||
def _task_to_internal(self, task: Any) -> dict[str, Any]:
|
||||
return {"id": task.id}
|
||||
|
||||
def _get_task(self, task_id: str) -> Any:
|
||||
raise AssertionError("legacy remix source lookup should be skipped")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.handlers.openai.video_handler.OpenAIVideoHandler",
|
||||
FakeOpenAIVideoHandler,
|
||||
)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-openai-video-remix-rust-owner-123",
|
||||
report_kind="openai_video_remix_sync_success",
|
||||
report_context={
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"task_id": "task-source-123",
|
||||
"request_id": "req-openai-video-remix-rust-owner-123",
|
||||
"local_task_id": "task-local-remix-123",
|
||||
"rust_video_task_persisted": True,
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"id": "ext-remix-task-123", "status": "submitted"},
|
||||
)
|
||||
|
||||
response = await gateway_module._finalize_gateway_openai_video_remix_sync(
|
||||
payload,
|
||||
db=FakeDB(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {
|
||||
"id": "task-local-remix-123",
|
||||
"object": "video",
|
||||
"status": "submitted",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_gateway_gemini_video_create_sync_reuses_rust_owned_task(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.internal import gateway as gateway_module
|
||||
|
||||
background_mock = AsyncMock(return_value=None)
|
||||
begin_pending_usage = MagicMock()
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, value: Any) -> None:
|
||||
self._value = value
|
||||
|
||||
def filter(self, *args: Any, **kwargs: Any) -> "FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> Any:
|
||||
return self._value
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self) -> None:
|
||||
self._mapping = {
|
||||
"User": SimpleNamespace(id="user-123"),
|
||||
"ApiKey": SimpleNamespace(id="key-123"),
|
||||
"VideoTask": SimpleNamespace(short_id="short12345678"),
|
||||
}
|
||||
|
||||
def query(self, model: Any) -> FakeQuery:
|
||||
return FakeQuery(self._mapping.get(getattr(model, "__name__", "")))
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
raise AssertionError("legacy gemini task creation should be skipped")
|
||||
|
||||
class FakeGeminiVeoHandler:
|
||||
FORMAT_ID = "gemini:video"
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self._normalizer = SimpleNamespace(
|
||||
video_task_from_internal=lambda task: {
|
||||
"name": f"models/veo-3/operations/{task['id']}",
|
||||
"done": False,
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
|
||||
def _task_to_internal(self, task: Any) -> dict[str, Any]:
|
||||
return {"id": task.short_id}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.handlers.gemini.video_handler.GeminiVeoHandler",
|
||||
FakeGeminiVeoHandler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._run_gateway_video_finalize_submitted_background",
|
||||
background_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.usage.service.UsageService.begin_pending_usage",
|
||||
begin_pending_usage,
|
||||
)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-gemini-video-rust-owner-123",
|
||||
report_kind="gemini_video_create_sync_success",
|
||||
report_context={
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"provider_id": "provider-123",
|
||||
"endpoint_id": "endpoint-123",
|
||||
"key_id": "provider-key-123",
|
||||
"request_id": "req-gemini-video-rust-owner-123",
|
||||
"model": "veo-3",
|
||||
"local_short_id": "short12345678",
|
||||
"provider_name": "gemini",
|
||||
"provider_api_format": "gemini:video",
|
||||
"rust_video_task_persisted": True,
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"name": "operations/ext-video-123"},
|
||||
telemetry={"elapsed_ms": 48},
|
||||
)
|
||||
|
||||
response = await gateway_module._finalize_gateway_gemini_video_create_sync(
|
||||
payload,
|
||||
db=FakeDB(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {
|
||||
"name": "models/veo-3/operations/short12345678",
|
||||
"done": False,
|
||||
"metadata": {},
|
||||
}
|
||||
begin_pending_usage.assert_not_called()
|
||||
background_mock.assert_awaited_once()
|
||||
|
||||
@@ -75,6 +75,14 @@ def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def _make_legacy_test_client(app: FastAPI) -> TestClient:
|
||||
return TestClient(
|
||||
app,
|
||||
base_url="http://127.0.0.1",
|
||||
headers={"x-aether-legacy-internal-gateway": "true"},
|
||||
)
|
||||
|
||||
|
||||
def test_report_sync_route_applies_gemini_files_mapping(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
@@ -91,7 +99,7 @@ def test_report_sync_route_applies_gemini_files_mapping(monkeypatch: pytest.Monk
|
||||
_fake_store_mapping,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -138,7 +146,7 @@ def test_report_sync_route_applies_gemini_files_delete_mapping(
|
||||
_fake_delete_mapping,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -172,7 +180,7 @@ def test_report_sync_route_uses_lazy_session(
|
||||
lambda: (_ for _ in ()).throw(AssertionError("create_session should not be called")),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -213,7 +221,7 @@ def test_report_sync_route_runs_video_create_success_inline(
|
||||
lambda app_obj: ("db-inline-123", cleanup_mock),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
|
||||
@@ -75,6 +75,14 @@ def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def _make_legacy_test_client(app: FastAPI) -> TestClient:
|
||||
return TestClient(
|
||||
app,
|
||||
base_url="http://127.0.0.1",
|
||||
headers={"x-aether-legacy-internal-gateway": "true"},
|
||||
)
|
||||
|
||||
|
||||
def test_report_stream_route_uses_lazy_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -91,7 +99,7 @@ def test_report_stream_route_uses_lazy_session(
|
||||
lambda: (_ for _ in ()).throw(AssertionError("create_session should not be called")),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
@@ -170,7 +178,7 @@ def test_report_stream_route_records_openai_chat_stream_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
@@ -203,7 +211,7 @@ def test_report_stream_route_records_claude_chat_stream_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
@@ -240,7 +248,7 @@ def test_report_stream_route_records_gemini_chat_stream_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
@@ -277,7 +285,7 @@ def test_report_stream_route_records_openai_cli_stream_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
@@ -316,7 +324,7 @@ def test_report_stream_route_records_claude_cli_stream_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
@@ -355,7 +363,7 @@ def test_report_stream_route_records_gemini_cli_stream_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
|
||||
@@ -75,6 +75,14 @@ def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def _make_legacy_test_client(app: FastAPI) -> TestClient:
|
||||
return TestClient(
|
||||
app,
|
||||
base_url="http://127.0.0.1",
|
||||
headers={"x-aether-legacy-internal-gateway": "true"},
|
||||
)
|
||||
|
||||
|
||||
def test_report_sync_route_records_openai_chat_sync_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -88,7 +96,7 @@ def test_report_sync_route_records_openai_chat_sync_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -125,7 +133,7 @@ def test_report_sync_route_records_openai_cli_sync_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -166,7 +174,7 @@ def test_report_sync_route_records_claude_cli_sync_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -206,7 +214,7 @@ def test_report_sync_route_records_gemini_cli_sync_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -254,7 +262,7 @@ def test_report_sync_route_records_video_sync_success_variants(
|
||||
record_mock = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(f"src.api.internal.gateway.{recorder_attr}", record_mock)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -335,7 +343,7 @@ def test_report_sync_route_records_claude_chat_sync_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
@@ -375,7 +383,7 @@ def test_report_sync_route_records_gemini_chat_sync_success(
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
|
||||
@@ -273,3 +273,81 @@ def test_record_gateway_direct_candidate_graph_uses_selected_pool_key_index() ->
|
||||
assert rows[0].status == "unused"
|
||||
assert rows[1].status == "pending"
|
||||
assert getattr(candidate, "request_candidate_id") == rows[1].id
|
||||
|
||||
|
||||
def test_record_gateway_direct_candidate_graph_is_idempotent_for_same_request_id() -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine, tables=[RequestCandidate.__table__])
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
|
||||
with SessionLocal() as db:
|
||||
resolver = CandidateResolver(db=db, cache_scheduler=SimpleNamespace())
|
||||
user_api_key = SimpleNamespace(
|
||||
id="api-key-direct-idempotent-123",
|
||||
name="client-key",
|
||||
user_id="user-direct-idempotent-123",
|
||||
user=SimpleNamespace(id="user-direct-idempotent-123", username="direct-user"),
|
||||
)
|
||||
request_id = "req-direct-candidate-idempotent-123"
|
||||
first_candidates = [
|
||||
ProviderCandidate(
|
||||
provider=SimpleNamespace(id="provider-selected-123", name="openai", max_retries=1),
|
||||
endpoint=SimpleNamespace(id="endpoint-selected-123"),
|
||||
key=SimpleNamespace(id="key-selected-123"),
|
||||
provider_api_format="openai:cli",
|
||||
),
|
||||
ProviderCandidate(
|
||||
provider=SimpleNamespace(id="provider-unused-123", name="openai", max_retries=1),
|
||||
endpoint=SimpleNamespace(id="endpoint-unused-123"),
|
||||
key=SimpleNamespace(id="key-unused-123"),
|
||||
provider_api_format="openai:cli",
|
||||
),
|
||||
]
|
||||
|
||||
_record_gateway_direct_candidate_graph(
|
||||
db=db,
|
||||
candidate_resolver=resolver,
|
||||
candidates=first_candidates,
|
||||
request_id=request_id,
|
||||
user_api_key=user_api_key,
|
||||
required_capabilities=None,
|
||||
selected_candidate_index=0,
|
||||
)
|
||||
first_selected_record_id = getattr(first_candidates[0], "request_candidate_id")
|
||||
|
||||
second_candidates = [
|
||||
ProviderCandidate(
|
||||
provider=SimpleNamespace(id="provider-selected-123", name="openai", max_retries=1),
|
||||
endpoint=SimpleNamespace(id="endpoint-selected-123"),
|
||||
key=SimpleNamespace(id="key-selected-123"),
|
||||
provider_api_format="openai:cli",
|
||||
),
|
||||
ProviderCandidate(
|
||||
provider=SimpleNamespace(id="provider-unused-123", name="openai", max_retries=1),
|
||||
endpoint=SimpleNamespace(id="endpoint-unused-123"),
|
||||
key=SimpleNamespace(id="key-unused-123"),
|
||||
provider_api_format="openai:cli",
|
||||
),
|
||||
]
|
||||
|
||||
_record_gateway_direct_candidate_graph(
|
||||
db=db,
|
||||
candidate_resolver=resolver,
|
||||
candidates=second_candidates,
|
||||
request_id=request_id,
|
||||
user_api_key=user_api_key,
|
||||
required_capabilities=None,
|
||||
selected_candidate_index=0,
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(RequestCandidate)
|
||||
.filter(RequestCandidate.request_id == request_id)
|
||||
.order_by(RequestCandidate.candidate_index, RequestCandidate.retry_index)
|
||||
.all()
|
||||
)
|
||||
|
||||
assert len(rows) == 2
|
||||
assert rows[0].status == "pending"
|
||||
assert rows[1].status == "unused"
|
||||
assert getattr(second_candidates[0], "request_candidate_id") == first_selected_record_id
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_no_python_modules_outside_internal_gateway_reference_internal_gateway_urls() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
scan_roots = [repo_root / "src" / "api", repo_root / "src" / "services"]
|
||||
needle = "/api/internal/gateway"
|
||||
offenders: list[str] = []
|
||||
|
||||
for root in scan_roots:
|
||||
for path in root.rglob("*.py"):
|
||||
rel = path.relative_to(repo_root).as_posix()
|
||||
if rel.startswith("src/api/internal/"):
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if needle in text:
|
||||
offenders.append(rel)
|
||||
|
||||
assert offenders == []
|
||||
@@ -75,6 +75,29 @@ def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def _make_legacy_test_client(app: FastAPI) -> TestClient:
|
||||
return TestClient(
|
||||
app,
|
||||
base_url="http://127.0.0.1",
|
||||
headers={"x-aether-legacy-internal-gateway": "true"},
|
||||
)
|
||||
|
||||
|
||||
def _make_internal_test_client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
return TestClient(app, base_url="http://127.0.0.1")
|
||||
|
||||
|
||||
def _assert_legacy_guard_response(response) -> None:
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
|
||||
|
||||
def test_build_gateway_sync_telemetry_writer_uses_queue_writer_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -314,7 +337,7 @@ def test_auth_context_route_returns_openai_bearer_auth_context(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/auth-context",
|
||||
json={
|
||||
@@ -371,14 +394,17 @@ def test_execute_sync_route_returns_controlled_response(monkeypatch: pytest.Monk
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
"trace_id": "trace-sync-123",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"user-agent": "pytest"},
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
@@ -438,7 +464,7 @@ def test_execute_sync_route_resolves_auth_context_when_missing(
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
@@ -448,6 +474,7 @@ def test_execute_sync_route_resolves_auth_context_when_missing(
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"authorization": "Bearer client-key",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
},
|
||||
@@ -465,14 +492,17 @@ def test_execute_sync_route_falls_back_for_stream_payload() -> None:
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
"trace_id": "trace-stream-123",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"user-agent": "pytest"},
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": [], "stream": True},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
@@ -522,14 +552,17 @@ def test_execute_stream_route_returns_controlled_stream(monkeypatch: pytest.Monk
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-stream",
|
||||
json={
|
||||
"trace_id": "trace-stream-123",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"user-agent": "pytest"},
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": [], "stream": True},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
@@ -567,7 +600,7 @@ def test_plan_sync_route_executes_control_when_direct_plan_missing(
|
||||
execute_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -608,7 +641,7 @@ def test_plan_stream_route_executes_control_when_direct_plan_missing(
|
||||
execute_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -626,13 +659,77 @@ def test_plan_stream_route_executes_control_when_direct_plan_missing(
|
||||
execute_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_plan_sync_route_requires_legacy_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_internal_test_client(monkeypatch)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
"trace_id": "trace-guard-plan-sync",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
},
|
||||
)
|
||||
|
||||
_assert_legacy_guard_response(response)
|
||||
|
||||
|
||||
def test_plan_stream_route_requires_legacy_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_internal_test_client(monkeypatch)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
"trace_id": "trace-guard-plan-stream",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {"model": "gpt-5", "messages": [], "stream": True},
|
||||
},
|
||||
)
|
||||
|
||||
_assert_legacy_guard_response(response)
|
||||
|
||||
|
||||
def test_report_sync_route_requires_legacy_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_internal_test_client(monkeypatch)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-guard-report-sync",
|
||||
"report_kind": "openai_chat_sync_finalize",
|
||||
"report_context": {"user_id": "user-123", "api_key_id": "key-123"},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
},
|
||||
)
|
||||
|
||||
_assert_legacy_guard_response(response)
|
||||
|
||||
|
||||
def test_finalize_sync_route_requires_legacy_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_internal_test_client(monkeypatch)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
"trace_id": "trace-guard-finalize-sync",
|
||||
"report_kind": "openai_chat_sync_finalize",
|
||||
"report_context": {"user_id": "user-123", "api_key_id": "key-123"},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
},
|
||||
)
|
||||
|
||||
_assert_legacy_guard_response(response)
|
||||
|
||||
|
||||
def test_execute_stream_route_falls_back_for_sync_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-stream",
|
||||
json={
|
||||
@@ -649,9 +746,10 @@ def test_execute_stream_route_falls_back_for_sync_payload(monkeypatch: pytest.Mo
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.headers[CONTROL_ACTION_HEADER] == CONTROL_ACTION_PROXY_PUBLIC
|
||||
assert response.json() == {"action": CONTROL_ACTION_PROXY_PUBLIC}
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
|
||||
|
||||
def test_execute_sync_route_handles_gemini_files_list(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -683,7 +781,7 @@ def test_execute_sync_route_handles_gemini_files_list(monkeypatch: pytest.Monkey
|
||||
|
||||
monkeypatch.setattr("src.api.public.gemini_files.list_files", _fake_list_files)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
@@ -732,7 +830,7 @@ def test_execute_sync_route_handles_gemini_files_upload_raw_body(
|
||||
|
||||
monkeypatch.setattr("src.api.public.gemini_files.upload_file", _fake_upload_file)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
@@ -796,7 +894,7 @@ def test_execute_sync_route_handles_openai_video_remix_with_original_request(
|
||||
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
@@ -860,7 +958,7 @@ def test_plan_stream_route_returns_executor_plan_for_gemini_files_download(
|
||||
AsyncMock(return_value=fake_plan),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -924,7 +1022,7 @@ def test_plan_stream_route_returns_executor_plan_for_openai_video_content(
|
||||
AsyncMock(return_value=fake_plan),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -988,7 +1086,7 @@ def test_plan_sync_route_returns_executor_plan_for_gemini_files_get(
|
||||
AsyncMock(return_value=(fake_plan, {"file_key_id": "file-key-123", "user_id": "user-123"})),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -1051,7 +1149,7 @@ def test_plan_sync_route_returns_executor_plan_for_openai_chat(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -1118,7 +1216,7 @@ def test_decision_sync_route_returns_executor_decision_for_openai_chat(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
json={
|
||||
@@ -1201,7 +1299,7 @@ def test_decision_stream_route_returns_executor_decision_for_openai_chat(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-stream",
|
||||
json={
|
||||
@@ -1247,6 +1345,38 @@ def test_decision_stream_route_returns_executor_decision_for_openai_chat(
|
||||
}
|
||||
|
||||
|
||||
def test_decision_sync_route_requires_legacy_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_internal_test_client(monkeypatch)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
json={
|
||||
"trace_id": "trace-guard-decision-sync",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
},
|
||||
)
|
||||
|
||||
_assert_legacy_guard_response(response)
|
||||
|
||||
|
||||
def test_decision_stream_route_requires_legacy_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_internal_test_client(monkeypatch)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-stream",
|
||||
json={
|
||||
"trace_id": "trace-guard-decision-stream",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {"model": "gpt-5", "messages": [], "stream": True},
|
||||
},
|
||||
)
|
||||
|
||||
_assert_legacy_guard_response(response)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "headers", "decision_kind", "client_api_format", "builder_path", "model_name", "mapped_model", "upstream_url"),
|
||||
[
|
||||
@@ -1333,7 +1463,7 @@ def test_decision_stream_route_returns_executor_decision_for_claude_and_gemini_c
|
||||
else {"contents": [{"role": "user", "parts": [{"text": "hello"}]}]}
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-stream",
|
||||
json={
|
||||
@@ -1494,7 +1624,7 @@ def test_decision_stream_route_returns_executor_decision_for_cli_variants(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-stream",
|
||||
json={
|
||||
@@ -1591,7 +1721,7 @@ def test_decision_sync_route_returns_executor_decision_for_openai_cli_variants(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
json={
|
||||
@@ -1708,7 +1838,7 @@ def test_decision_sync_route_returns_executor_decision_for_claude_variants(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
json={
|
||||
@@ -1839,7 +1969,7 @@ def test_decision_sync_route_returns_executor_decision_for_gemini_variants(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
json={
|
||||
@@ -1946,7 +2076,7 @@ def test_decision_sync_route_returns_executor_decision_for_gemini_files_get(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
json={
|
||||
@@ -2097,7 +2227,7 @@ def test_decision_sync_route_returns_executor_decision_for_video_variants(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
json={
|
||||
@@ -2198,7 +2328,7 @@ def test_decision_stream_route_returns_executor_decision_for_gemini_files_downlo
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-stream",
|
||||
json={
|
||||
@@ -2266,7 +2396,7 @@ def test_decision_stream_route_returns_executor_decision_for_openai_video_conten
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-stream",
|
||||
json={
|
||||
@@ -2322,7 +2452,7 @@ def test_plan_sync_route_resolves_auth_context_when_missing(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -2384,7 +2514,7 @@ def test_plan_sync_route_returns_executor_plan_for_openai_video_create(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -2450,7 +2580,7 @@ def test_plan_sync_route_returns_executor_plan_for_openai_video_remix(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -2516,7 +2646,7 @@ def test_plan_sync_route_returns_executor_plan_for_gemini_video_create(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -2582,7 +2712,7 @@ def test_plan_sync_route_returns_executor_plan_for_openai_video_cancel(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -2646,7 +2776,7 @@ def test_plan_sync_route_returns_executor_plan_for_openai_video_delete(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -2710,7 +2840,7 @@ def test_plan_sync_route_returns_executor_plan_for_gemini_video_cancel(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -2785,7 +2915,7 @@ def test_plan_stream_route_returns_executor_plan_for_openai_chat(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -2860,7 +2990,7 @@ def test_plan_stream_route_resolves_auth_context_when_missing(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -2927,7 +3057,7 @@ def test_plan_stream_route_returns_executor_plan_for_claude_chat(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -2997,7 +3127,7 @@ def test_plan_stream_route_returns_executor_plan_for_gemini_chat(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -3067,7 +3197,7 @@ def test_plan_stream_route_returns_executor_plan_for_openai_cli(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -3143,7 +3273,7 @@ def test_plan_stream_route_returns_executor_plan_for_claude_cli(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -3218,7 +3348,7 @@ def test_plan_stream_route_returns_executor_plan_for_gemini_cli(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
@@ -3285,7 +3415,7 @@ def test_plan_sync_route_returns_executor_plan_for_openai_cli(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -3351,7 +3481,7 @@ def test_plan_sync_route_returns_executor_plan_for_openai_compact(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -3417,7 +3547,7 @@ def test_plan_sync_route_returns_executor_plan_for_claude_chat(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -3483,7 +3613,7 @@ def test_plan_sync_route_returns_executor_plan_for_gemini_chat(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -3549,7 +3679,7 @@ def test_plan_sync_route_returns_executor_plan_for_claude_cli(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -3615,7 +3745,7 @@ def test_plan_sync_route_returns_executor_plan_for_gemini_cli(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -3685,7 +3815,7 @@ def test_plan_sync_route_returns_executor_plan_for_gemini_files_list(
|
||||
AsyncMock(return_value=(fake_plan, {"file_key_id": "file-key-123", "user_id": "user-123"})),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -3751,7 +3881,7 @@ def test_plan_sync_route_returns_executor_plan_for_gemini_files_upload(
|
||||
AsyncMock(return_value=(fake_plan, {"file_key_id": "file-key-123", "user_id": "user-123"})),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
@@ -3821,7 +3951,7 @@ def test_plan_sync_route_returns_executor_plan_for_gemini_files_delete(
|
||||
AsyncMock(return_value=(fake_plan, {})),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
json={
|
||||
|
||||
@@ -5,8 +5,8 @@ from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import src.api.public.gemini_files as gemini_files_mod
|
||||
@@ -127,12 +127,6 @@ async def test_proxy_request_passes_proxy_snapshot_to_rust_executor(
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod.HTTPClientPool,
|
||||
"get_upstream_client",
|
||||
AsyncMock(side_effect=AssertionError("python fallback should not run")),
|
||||
)
|
||||
|
||||
response = await gemini_files_mod._proxy_request(
|
||||
"GET",
|
||||
"https://generativelanguage.googleapis.com/v1beta/files",
|
||||
@@ -150,57 +144,30 @@ async def test_proxy_request_passes_proxy_snapshot_to_rust_executor(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_request_fallback_uses_upstream_client_proxy_context(
|
||||
async def test_proxy_request_returns_503_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FakeClient:
|
||||
async def get(self, url: str, headers: dict[str, str]) -> httpx.Response:
|
||||
assert url == "https://generativelanguage.googleapis.com/v1beta/files"
|
||||
assert headers == {"x-goog-api-key": "upstream-key"}
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("GET", url),
|
||||
json={"files": []},
|
||||
)
|
||||
|
||||
async def _fake_try_rust_sync_proxy_request(*args: object, **kwargs: object) -> None:
|
||||
async def _fake_try_rust_sync_proxy_request(*args: object, **kwargs: object) -> Response:
|
||||
del args, kwargs
|
||||
return None
|
||||
|
||||
async def _fake_get_upstream_client(
|
||||
delegate_cfg: dict[str, object] | None,
|
||||
*,
|
||||
proxy_config: dict[str, object] | None = None,
|
||||
tls_profile: str | None = None,
|
||||
) -> _FakeClient:
|
||||
assert delegate_cfg == {"tunnel": True, "node_id": "node-1"}
|
||||
assert proxy_config == {"enabled": True, "node_id": "node-1"}
|
||||
assert tls_profile is None
|
||||
return _FakeClient()
|
||||
return gemini_files_mod._build_rust_unavailable_response()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod,
|
||||
"_try_rust_sync_proxy_request",
|
||||
_fake_try_rust_sync_proxy_request,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod.HTTPClientPool,
|
||||
"get_upstream_client",
|
||||
_fake_get_upstream_client,
|
||||
)
|
||||
|
||||
response = await gemini_files_mod._proxy_request(
|
||||
"GET",
|
||||
"https://generativelanguage.googleapis.com/v1beta/files",
|
||||
{"x-goog-api-key": "upstream-key"},
|
||||
file_key_id="key-1",
|
||||
user_id="user-1",
|
||||
proxy_config={"enabled": True, "node_id": "node-1"},
|
||||
delegate_config={"tunnel": True, "node_id": "node-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {"files": []}
|
||||
body = json.loads(response.body)
|
||||
assert response.status_code == 503
|
||||
assert body["error"]["code"] == 503
|
||||
assert body["error"]["status"] == "UNAVAILABLE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -275,13 +242,7 @@ async def test_download_file_uses_enriched_proxy_snapshot_for_regular_files(
|
||||
"execute_stream",
|
||||
_fake_execute_stream,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod.HTTPClientPool,
|
||||
"get_upstream_client",
|
||||
AsyncMock(side_effect=AssertionError("python fallback should not run")),
|
||||
)
|
||||
|
||||
response = await gemini_files_mod.download_file(
|
||||
response = await gemini_files_mod._download_file_response(
|
||||
"file-1",
|
||||
SimpleNamespace(
|
||||
headers={},
|
||||
@@ -294,3 +255,71 @@ async def test_download_file_uses_enriched_proxy_snapshot_for_regular_files(
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
assert body == b"file-bytes"
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_returns_503_when_rust_stream_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "executor_backend", "rust")
|
||||
raw_ctx = UpstreamContext(
|
||||
upstream_key="upstream-key",
|
||||
base_url="https://generativelanguage.googleapis.com",
|
||||
file_key_id="key-1",
|
||||
user_id="user-1",
|
||||
provider_id="prov-1",
|
||||
endpoint_id="ep-1",
|
||||
)
|
||||
enriched_ctx = UpstreamContext(
|
||||
upstream_key="upstream-key",
|
||||
base_url="https://generativelanguage.googleapis.com",
|
||||
file_key_id="key-1",
|
||||
user_id="user-1",
|
||||
provider_id="prov-1",
|
||||
endpoint_id="ep-1",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gemini_files_mod, "_extract_gemini_api_key", lambda request: "client-key")
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod,
|
||||
"create_session",
|
||||
lambda: _FakeDBContext(SimpleNamespace()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod.AuthService,
|
||||
"authenticate_api_key",
|
||||
lambda db, key: (SimpleNamespace(id="user-1"), SimpleNamespace(id="user-api-key")),
|
||||
)
|
||||
monkeypatch.setattr(gemini_files_mod, "_ensure_balance_access", lambda db, user, api_key: None)
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod,
|
||||
"_resolve_upstream_context",
|
||||
AsyncMock(return_value=raw_ctx),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod,
|
||||
"_enrich_upstream_context_proxy",
|
||||
AsyncMock(return_value=enriched_ctx),
|
||||
)
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> RustExecutorStreamResult:
|
||||
del self, plan
|
||||
raise rust_client_mod.RustExecutorClientError("executor unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
rust_client_mod.RustExecutorClient,
|
||||
"execute_stream",
|
||||
_fake_execute_stream,
|
||||
)
|
||||
response = await gemini_files_mod._download_file_response(
|
||||
"file-1",
|
||||
SimpleNamespace(
|
||||
headers={},
|
||||
query_params={"alt": "media"},
|
||||
),
|
||||
)
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert response.status_code == 503
|
||||
assert body["error"]["code"] == 503
|
||||
assert body["error"]["status"] == "UNAVAILABLE"
|
||||
|
||||
133
tests/api/public/test_gemini_files_shell.py
Normal file
133
tests/api/public/test_gemini_files_shell.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
pipeline_result: Any,
|
||||
) -> tuple[TestClient, list[dict[str, Any]]]:
|
||||
from src.api.public import gemini_files as mod
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mod.router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*,
|
||||
adapter: Any,
|
||||
http_request: object,
|
||||
db: object,
|
||||
mode: object,
|
||||
api_format_hint: str | None = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
del http_request, db, api_format_hint, path_params
|
||||
calls.append(
|
||||
{
|
||||
"adapter_type": type(adapter).__name__,
|
||||
"mode": getattr(mode, "value", mode),
|
||||
"adapter_state": dict(getattr(adapter, "__dict__", {})),
|
||||
}
|
||||
)
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", _fake_pipeline_run)
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
def test_gemini_files_upload_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"file": {"name": "files/1"}})
|
||||
|
||||
response = client.post("/upload/v1beta/files")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"file": {"name": "files/1"}}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicGeminiFilesUploadAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_gemini_files_list_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"files": []})
|
||||
|
||||
response = client.get("/v1beta/files?pageSize=20&pageToken=next-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"files": []}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicGeminiFilesListAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {
|
||||
"page_size": 20,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_gemini_files_download_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"download": "ok"})
|
||||
|
||||
response = client.get("/v1beta/files/file-1:download?alt=media")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"download": "ok"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicGeminiFilesDownloadAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {"file_id": "file-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_gemini_files_get_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"name": "files/file-1"})
|
||||
|
||||
response = client.get("/v1beta/files/file-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"name": "files/file-1"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicGeminiFilesGetAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {"file_name": "file-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_gemini_files_delete_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"deleted": True})
|
||||
|
||||
response = client.delete("/v1beta/files/file-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"deleted": True}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicGeminiFilesDeleteAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {"file_name": "file-1"},
|
||||
}
|
||||
]
|
||||
117
tests/api/public/test_gemini_shell.py
Normal file
117
tests/api/public/test_gemini_shell.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def _make_request(path: str, method: str = "POST", headers: list[tuple[bytes, bytes]] | None = None) -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": method,
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": b"",
|
||||
"headers": headers or [],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("route", "model", "stream"),
|
||||
[
|
||||
("v1beta_generate", "gemini-2.5-flash", False),
|
||||
("v1beta_stream", "gemini-2.5-flash", True),
|
||||
("v1_generate", "gemini-2.5-flash", False),
|
||||
("v1_stream", "gemini-2.5-flash", True),
|
||||
],
|
||||
)
|
||||
async def test_public_gemini_routes_use_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
route: str,
|
||||
model: str,
|
||||
stream: bool,
|
||||
) -> None:
|
||||
from src.api.public import gemini as mod
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_run(*, adapter, http_request, db, mode, api_format_hint, path_params, **_kwargs):
|
||||
captured.update(
|
||||
{
|
||||
"adapter": adapter,
|
||||
"request": http_request,
|
||||
"db": db,
|
||||
"mode": mode,
|
||||
"api_format_hint": api_format_hint,
|
||||
"path_params": path_params,
|
||||
}
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", fake_run)
|
||||
|
||||
db = object()
|
||||
request = _make_request(f"/{route}")
|
||||
|
||||
if route == "v1beta_generate":
|
||||
result = await mod.generate_content(model=model, http_request=request, db=db)
|
||||
elif route == "v1beta_stream":
|
||||
result = await mod.stream_generate_content(model=model, http_request=request, db=db)
|
||||
elif route == "v1_generate":
|
||||
result = await mod.generate_content_v1(model=model, http_request=request, db=db)
|
||||
else:
|
||||
result = await mod.stream_generate_content_v1(model=model, http_request=request, db=db)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert isinstance(captured["adapter"], mod.PublicGeminiContentAdapter)
|
||||
assert captured["adapter"].model == model
|
||||
assert captured["adapter"].stream is stream
|
||||
assert captured["request"] is request
|
||||
assert captured["db"] is db
|
||||
assert captured["mode"] == captured["adapter"].mode
|
||||
assert captured["api_format_hint"] == "gemini:chat"
|
||||
assert captured["path_params"] == {"model": model, "stream": stream}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_gemini_shell_detects_cli_request_for_api_format_hint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.public import gemini as mod
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_run(*, adapter, http_request, db, mode, api_format_hint, path_params, **_kwargs):
|
||||
captured.update(
|
||||
{
|
||||
"adapter": adapter,
|
||||
"request": http_request,
|
||||
"db": db,
|
||||
"mode": mode,
|
||||
"api_format_hint": api_format_hint,
|
||||
"path_params": path_params,
|
||||
}
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", fake_run)
|
||||
|
||||
request = _make_request(
|
||||
"/v1beta/models/gemini-2.5-flash:generateContent",
|
||||
headers=[(b"x-app", b"gemini-cli")],
|
||||
)
|
||||
|
||||
result = await mod.generate_content(model="gemini-2.5-flash", http_request=request, db=object())
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert isinstance(captured["adapter"], mod.PublicGeminiContentAdapter)
|
||||
assert captured["api_format_hint"] == "gemini:cli"
|
||||
assert captured["path_params"] == {"model": "gemini-2.5-flash", "stream": False}
|
||||
153
tests/api/public/test_misc_shells.py
Normal file
153
tests/api/public/test_misc_shells.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
module_name: str,
|
||||
*,
|
||||
pipeline_result: Any,
|
||||
) -> tuple[TestClient, list[dict[str, Any]]]:
|
||||
module = __import__(module_name, fromlist=["router", "pipeline"])
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(module.router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*,
|
||||
adapter: Any,
|
||||
http_request: object,
|
||||
db: object,
|
||||
mode: object,
|
||||
api_format_hint: str | None = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
del http_request, db, api_format_hint, path_params
|
||||
calls.append(
|
||||
{
|
||||
"adapter_type": type(adapter).__name__,
|
||||
"mode": getattr(mode, "value", mode),
|
||||
"adapter_state": dict(getattr(adapter, "__dict__", {})),
|
||||
}
|
||||
)
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr(module.pipeline, "run", _fake_pipeline_run)
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
def test_public_site_info_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
"src.api.public.catalog",
|
||||
pipeline_result={"site_name": "Aether", "site_subtitle": "AI Gateway"},
|
||||
)
|
||||
|
||||
response = client.get("/api/public/site-info")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"site_name": "Aether", "site_subtitle": "AI Gateway"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicSiteInfoAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_public_modules_auth_status_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
"src.api.public.modules",
|
||||
pipeline_result=[{"name": "oauth", "display_name": "OAuth", "active": True}],
|
||||
)
|
||||
|
||||
response = client.get("/api/modules/auth-status")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"name": "oauth", "display_name": "OAuth", "active": True}]
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicAuthModulesStatusAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_public_capabilities_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
"src.api.public.capabilities",
|
||||
pipeline_result={"capabilities": []},
|
||||
)
|
||||
|
||||
response = client.get("/api/capabilities")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"capabilities": []}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicCapabilitiesListAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_public_user_configurable_capabilities_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
"src.api.public.capabilities",
|
||||
pipeline_result={"capabilities": [{"name": "vision"}]},
|
||||
)
|
||||
|
||||
response = client.get("/api/capabilities/user-configurable")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"capabilities": [{"name": "vision"}]}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicUserConfigurableCapabilitiesAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_public_model_capabilities_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(
|
||||
monkeypatch,
|
||||
"src.api.public.capabilities",
|
||||
pipeline_result={"model": "gpt-5", "supported_capabilities": []},
|
||||
)
|
||||
|
||||
response = client.get("/api/capabilities/model/gpt-5")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"model": "gpt-5", "supported_capabilities": []}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicModelCapabilitiesAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {"model_name": "gpt-5"},
|
||||
}
|
||||
]
|
||||
130
tests/api/public/test_models_shell.py
Normal file
130
tests/api/public/test_models_shell.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
pipeline_result: Any,
|
||||
) -> tuple[TestClient, list[dict[str, Any]]]:
|
||||
from src.api.public import models as mod
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mod.router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*,
|
||||
adapter: Any,
|
||||
http_request: object,
|
||||
db: object,
|
||||
mode: object,
|
||||
api_format_hint: str | None = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
del http_request, db, api_format_hint, path_params
|
||||
calls.append(
|
||||
{
|
||||
"adapter_type": type(adapter).__name__,
|
||||
"mode": getattr(mode, "value", mode),
|
||||
"adapter_state": dict(getattr(adapter, "__dict__", {})),
|
||||
}
|
||||
)
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", _fake_pipeline_run)
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
def test_public_openai_models_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"object": "list", "data": []})
|
||||
|
||||
response = client.get("/v1/models?after_id=model-a&limit=12")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"object": "list", "data": []}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicModelsListAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {
|
||||
"before_id": None,
|
||||
"after_id": "model-a",
|
||||
"limit": 12,
|
||||
"page_size": 50,
|
||||
"page_token": None,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_public_model_detail_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"id": "gpt-5"})
|
||||
|
||||
response = client.get("/v1/models/gpt-5")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": "gpt-5"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicModelDetailAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {
|
||||
"model_id": "gpt-5",
|
||||
"force_gemini_name": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_public_gemini_models_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"models": []})
|
||||
|
||||
response = client.get("/v1beta/models?pageSize=25&pageToken=next-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"models": []}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicModelsListAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {
|
||||
"before_id": None,
|
||||
"after_id": None,
|
||||
"limit": 20,
|
||||
"page_size": 25,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_public_gemini_model_detail_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"name": "models/gemini-2.5-pro"})
|
||||
|
||||
response = client.get("/v1beta/models/models/gemini-2.5-pro")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"name": "models/gemini-2.5-pro"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicModelDetailAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {
|
||||
"model_id": "models/gemini-2.5-pro",
|
||||
"force_gemini_name": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _build_provider_fixture() -> SimpleNamespace:
|
||||
@@ -65,10 +66,7 @@ async def test_test_connection_prefers_rust_executor(
|
||||
rust_call = AsyncMock(return_value=rust_response)
|
||||
monkeypatch.setattr(mod, "_try_rust_test_connection_response", rust_call)
|
||||
|
||||
get_upstream_client = AsyncMock(side_effect=AssertionError("python upstream client should not be used"))
|
||||
monkeypatch.setattr(mod.HTTPClientPool, "get_upstream_client", get_upstream_client)
|
||||
|
||||
result = await mod.test_connection(
|
||||
result = await mod._test_connection_response(
|
||||
request=SimpleNamespace(query_params={}),
|
||||
db=MagicMock(),
|
||||
provider=None,
|
||||
@@ -79,11 +77,10 @@ async def test_test_connection_prefers_rust_executor(
|
||||
assert result["status"] == "success"
|
||||
assert result["response_id"] == "resp_rust"
|
||||
rust_call.assert_awaited_once()
|
||||
get_upstream_client.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_fallback_uses_transport_aware_client(
|
||||
async def test_test_connection_returns_503_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.public import system_catalog as mod
|
||||
@@ -105,38 +102,29 @@ async def test_test_connection_fallback_uses_transport_aware_client(
|
||||
lambda *_args, **_kwargs: "https://upstream.test/v1/chat/completions",
|
||||
)
|
||||
|
||||
proxy_config = {"enabled": True, "url": "http://proxy.test:8080"}
|
||||
delegate_cfg = {"node_id": "node-1", "tunnel": True}
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_build_test_connection_transport_context",
|
||||
AsyncMock(return_value=(proxy_config, delegate_cfg, None)),
|
||||
AsyncMock(return_value=({"enabled": True}, {"node_id": "node-1", "tunnel": True}, None)),
|
||||
)
|
||||
monkeypatch.setattr(mod, "_try_rust_test_connection_response", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_try_rust_test_connection_response",
|
||||
AsyncMock(
|
||||
side_effect=HTTPException(
|
||||
status_code=503,
|
||||
detail="System catalog test-connection requires Rust executor",
|
||||
)
|
||||
),
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await mod._test_connection_response(
|
||||
request=SimpleNamespace(query_params={}),
|
||||
db=MagicMock(),
|
||||
provider=None,
|
||||
model="gpt-test",
|
||||
api_format=None,
|
||||
)
|
||||
|
||||
upstream_response = httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", "https://upstream.test/v1/chat/completions"),
|
||||
json={"id": "resp_python"},
|
||||
)
|
||||
upstream_client = MagicMock()
|
||||
upstream_client.post = AsyncMock(return_value=upstream_response)
|
||||
get_upstream_client = AsyncMock(return_value=upstream_client)
|
||||
monkeypatch.setattr(mod.HTTPClientPool, "get_upstream_client", get_upstream_client)
|
||||
|
||||
result = await mod.test_connection(
|
||||
request=SimpleNamespace(query_params={}),
|
||||
db=MagicMock(),
|
||||
provider=None,
|
||||
model="gpt-test",
|
||||
api_format=None,
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["response_id"] == "resp_python"
|
||||
get_upstream_client.assert_awaited_once_with(delegate_cfg, proxy_config=proxy_config)
|
||||
upstream_client.post.assert_awaited_once_with(
|
||||
"https://upstream.test/v1/chat/completions",
|
||||
json={"model": "gpt-test"},
|
||||
headers={"authorization": "Bearer test"},
|
||||
)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "System catalog test-connection requires Rust executor"
|
||||
|
||||
164
tests/api/public/test_system_catalog_shell.py
Normal file
164
tests/api/public/test_system_catalog_shell.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
pipeline_result: Any,
|
||||
) -> tuple[TestClient, list[dict[str, Any]]]:
|
||||
from src.api.public import system_catalog as mod
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mod.router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*,
|
||||
adapter: Any,
|
||||
http_request: object,
|
||||
db: object,
|
||||
mode: object,
|
||||
api_format_hint: str | None = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
del http_request, db, api_format_hint, path_params
|
||||
calls.append(
|
||||
{
|
||||
"adapter_type": type(adapter).__name__,
|
||||
"mode": getattr(mode, "value", mode),
|
||||
"adapter_state": dict(getattr(adapter, "__dict__", {})),
|
||||
}
|
||||
)
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", _fake_pipeline_run)
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
def test_system_catalog_health_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"status": "ok"})
|
||||
|
||||
response = client.get("/v1/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicServiceHealthAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_system_catalog_simple_health_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"status": "healthy"})
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "healthy"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicSimpleHealthCheckAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_system_catalog_root_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"status": "running"})
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "running"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicRootCatalogAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_system_catalog_provider_list_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"providers": []})
|
||||
|
||||
response = client.get("/v1/providers?include_models=true&include_endpoints=true&active_only=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"providers": []}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicProvidersListAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {
|
||||
"include_models": True,
|
||||
"include_endpoints": True,
|
||||
"active_only": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_system_catalog_provider_detail_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"id": "provider-1"})
|
||||
|
||||
response = client.get("/v1/providers/provider-1?include_models=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": "provider-1"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicProviderDetailAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {
|
||||
"provider_identifier": "provider-1",
|
||||
"include_models": True,
|
||||
"include_endpoints": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_system_catalog_test_connection_route_is_pipeline_shell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"status": "success"})
|
||||
|
||||
response = client.get("/v1/test-connection?provider=openai&model=gpt-5&api_format=openai:chat")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "PublicTestConnectionAdapter",
|
||||
"mode": "public",
|
||||
"adapter_state": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5",
|
||||
"api_format": "openai:chat",
|
||||
},
|
||||
}
|
||||
]
|
||||
123
tests/api/public/test_videos_rust.py
Normal file
123
tests/api/public/test_videos_rust.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_app(monkeypatch: pytest.MonkeyPatch, *, pipeline_result: Any) -> tuple[TestClient, list[dict[str, Any]]]:
|
||||
from src.api.public import videos as mod
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mod.router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*,
|
||||
adapter: Any,
|
||||
http_request: object,
|
||||
db: object,
|
||||
mode: object,
|
||||
api_format_hint: str,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
del http_request, db
|
||||
calls.append(
|
||||
{
|
||||
"adapter_type": type(adapter).__name__,
|
||||
"mode": getattr(mode, "value", mode),
|
||||
"api_format_hint": api_format_hint,
|
||||
"path_params": path_params,
|
||||
}
|
||||
)
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr(mod.pipeline, "run", _fake_pipeline_run)
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
def test_openai_video_create_route_is_pipeline_shell(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"ok": True})
|
||||
|
||||
response = client.post("/v1/videos", json={"model": "sora", "prompt": "hello"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "OpenAIVideoAdapter",
|
||||
"mode": "standard",
|
||||
"api_format_hint": "openai:video",
|
||||
"path_params": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_openai_video_download_route_passes_task_id_to_pipeline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"download": True})
|
||||
|
||||
response = client.get("/v1/videos/task-123/content")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"download": True}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "OpenAIVideoAdapter",
|
||||
"mode": "standard",
|
||||
"api_format_hint": "openai:video",
|
||||
"path_params": {"task_id": "task-123"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_gemini_video_create_route_passes_model_to_pipeline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"ok": True})
|
||||
|
||||
response = client.post(
|
||||
"/v1beta/models/veo-3:predictLongRunning",
|
||||
json={"prompt": "hello"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "GeminiVeoAdapter",
|
||||
"mode": "standard",
|
||||
"api_format_hint": "gemini:video",
|
||||
"path_params": {"model": "veo-3"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_gemini_video_cancel_route_reconstructs_operation_name(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, calls = _build_app(monkeypatch, pipeline_result={"ok": True})
|
||||
|
||||
response = client.post("/v1beta/models/veo-3/operations/op-1:cancel")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
assert calls == [
|
||||
{
|
||||
"adapter_type": "GeminiVeoAdapter",
|
||||
"mode": "standard",
|
||||
"api_format_hint": "gemini:video",
|
||||
"path_params": {
|
||||
"task_id": "models/veo-3/operations/op-1",
|
||||
"action": "cancel",
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
@@ -77,6 +77,14 @@ def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def _make_legacy_test_client(app: FastAPI) -> TestClient:
|
||||
return TestClient(
|
||||
app,
|
||||
base_url="http://127.0.0.1",
|
||||
headers={"x-aether-legacy-internal-gateway": "true"},
|
||||
)
|
||||
|
||||
|
||||
def test_build_gateway_sync_telemetry_writer_uses_queue_writer_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -316,7 +324,7 @@ def test_auth_context_route_returns_openai_bearer_auth_context(
|
||||
),
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/auth-context",
|
||||
json={
|
||||
@@ -373,14 +381,17 @@ def test_execute_sync_route_returns_controlled_response(monkeypatch: pytest.Monk
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
"trace_id": "trace-sync-123",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"user-agent": "pytest"},
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
@@ -440,7 +451,7 @@ def test_execute_sync_route_resolves_auth_context_when_missing(
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
@@ -450,6 +461,7 @@ def test_execute_sync_route_resolves_auth_context_when_missing(
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"authorization": "Bearer client-key",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
},
|
||||
@@ -467,7 +479,7 @@ def test_execute_sync_route_falls_back_for_stream_payload() -> None:
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
@@ -484,12 +496,235 @@ def test_execute_sync_route_falls_back_for_stream_payload() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.headers[CONTROL_ACTION_HEADER] == CONTROL_ACTION_PROXY_PUBLIC
|
||||
assert response.json() == {"action": CONTROL_ACTION_PROXY_PUBLIC}
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
def test_execute_sync_route_requires_explicit_chat_cli_opt_in(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
resolve_sync_adapter = Mock(return_value=(Mock(), {}))
|
||||
monkeypatch.setattr("src.api.internal.gateway._resolve_gateway_sync_adapter", resolve_sync_adapter)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
"trace_id": "trace-sync-no-opt-in",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"user-agent": "pytest"},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"access_allowed": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
resolve_sync_adapter.assert_not_called()
|
||||
|
||||
|
||||
def test_execute_sync_route_requires_legacy_internal_gateway_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
resolve_sync_adapter = Mock(return_value=(Mock(), {}))
|
||||
monkeypatch.setattr("src.api.internal.gateway._resolve_gateway_sync_adapter", resolve_sync_adapter)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
"trace_id": "trace-sync-no-legacy-header",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"access_allowed": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
resolve_sync_adapter.assert_not_called()
|
||||
|
||||
|
||||
def test_decision_sync_route_requires_legacy_internal_gateway_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
build_decision = AsyncMock(return_value=MagicMock())
|
||||
monkeypatch.setattr("src.api.internal.gateway._build_openai_chat_sync_decision", build_decision)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
response = client.post(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
json={
|
||||
"trace_id": "trace-decision-no-legacy-header",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"authorization": "Bearer client-key",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"access_allowed": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
build_decision.assert_not_awaited()
|
||||
|
||||
|
||||
def test_plan_stream_route_requires_legacy_internal_gateway_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
build_plan = AsyncMock(return_value=MagicMock())
|
||||
monkeypatch.setattr("src.api.internal.gateway._build_openai_chat_stream_plan", build_plan)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
response = client.post(
|
||||
"/api/internal/gateway/plan-stream",
|
||||
json={
|
||||
"trace_id": "trace-plan-no-legacy-header",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"authorization": "Bearer client-key",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": [], "stream": True},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"access_allowed": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
build_plan.assert_not_awaited()
|
||||
|
||||
|
||||
def test_finalize_sync_route_requires_legacy_internal_gateway_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
finalize_mock = AsyncMock(return_value=JSONResponse(content={"ok": True}))
|
||||
monkeypatch.setattr("src.api.internal.gateway._finalize_gateway_chat_sync", finalize_mock)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
response = client.post(
|
||||
"/api/internal/gateway/finalize-sync",
|
||||
json={
|
||||
"trace_id": "trace-finalize-no-legacy-header",
|
||||
"report_kind": "openai_chat_sync_finalize",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "openai:chat",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {"id": "upstream-123"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
finalize_mock.assert_not_awaited()
|
||||
|
||||
|
||||
def test_report_sync_route_requires_legacy_internal_gateway_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
record_mock = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._record_gateway_openai_chat_sync_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-report-no-legacy-header",
|
||||
"report_kind": "openai_chat_sync_success",
|
||||
"report_context": {"user_id": "user-123", "api_key_id": "key-123"},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"choices": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"detail": "legacy internal gateway route removed; use public proxy"
|
||||
}
|
||||
assert record_mock.await_count == 0
|
||||
|
||||
|
||||
def test_execute_stream_route_returns_controlled_stream(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
@@ -524,14 +759,17 @@ def test_execute_stream_route_returns_controlled_stream(monkeypatch: pytest.Monk
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
client = TestClient(app, base_url="http://127.0.0.1")
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-stream",
|
||||
json={
|
||||
"trace_id": "trace-stream-123",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {"user-agent": "pytest"},
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": [], "stream": True},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
|
||||
@@ -163,9 +163,16 @@ class TestPipelineAuditLogging:
|
||||
mock_context.request_id = "req-123"
|
||||
mock_context.client_ip = "127.0.0.1"
|
||||
mock_context.user_agent = "test-agent"
|
||||
mock_context.request_method = "POST"
|
||||
mock_context.request_path = "/v1/messages"
|
||||
mock_context.tx_committed_by_route = False
|
||||
mock_context.gateway_execution_path = None
|
||||
mock_context.rate_limit_scope = None
|
||||
mock_context.sync_runtime_state_from_request = MagicMock()
|
||||
mock_context.request = MagicMock()
|
||||
mock_context.request.method = "POST"
|
||||
mock_context.request.url.path = "/v1/messages"
|
||||
mock_context.original_headers = {}
|
||||
mock_context.start_time = 1000.0
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
@@ -199,9 +206,16 @@ class TestPipelineAuditLogging:
|
||||
mock_context.request_id = "req-123"
|
||||
mock_context.client_ip = "127.0.0.1"
|
||||
mock_context.user_agent = "test-agent"
|
||||
mock_context.request_method = "POST"
|
||||
mock_context.request_path = "/v1/messages"
|
||||
mock_context.tx_committed_by_route = False
|
||||
mock_context.gateway_execution_path = None
|
||||
mock_context.rate_limit_scope = None
|
||||
mock_context.sync_runtime_state_from_request = MagicMock()
|
||||
mock_context.request = MagicMock()
|
||||
mock_context.request.method = "POST"
|
||||
mock_context.request.url.path = "/v1/messages"
|
||||
mock_context.original_headers = {}
|
||||
mock_context.start_time = 1000.0
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
@@ -239,11 +253,20 @@ class TestPipelineAuditLogging:
|
||||
mock_context.request_id = "req-123"
|
||||
mock_context.client_ip = "127.0.0.1"
|
||||
mock_context.user_agent = "test-agent"
|
||||
mock_context.request_method = "POST"
|
||||
mock_context.request_path = "/api/auth/refresh"
|
||||
mock_context.tx_committed_by_route = False
|
||||
mock_context.gateway_execution_path = None
|
||||
mock_context.rate_limit_scope = None
|
||||
mock_context.request = MagicMock()
|
||||
mock_context.request.method = "POST"
|
||||
mock_context.request.url.path = "/api/auth/refresh"
|
||||
mock_context.request.state = SimpleNamespace(tx_committed_by_route=True)
|
||||
mock_context.original_headers = {}
|
||||
mock_context.start_time = 1000.0
|
||||
mock_context.sync_runtime_state_from_request = MagicMock(
|
||||
side_effect=lambda: setattr(mock_context, "tx_committed_by_route", True)
|
||||
)
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "test-adapter"
|
||||
@@ -261,6 +284,8 @@ class TestPipelineAuditLogging:
|
||||
"""测试没有数据库会话时跳过审计"""
|
||||
mock_context = MagicMock()
|
||||
mock_context.db = None
|
||||
mock_context.sync_runtime_state_from_request = MagicMock()
|
||||
mock_context.tx_committed_by_route = False
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.audit_log_enabled = True
|
||||
@@ -302,10 +327,17 @@ class TestPipelineAuditLogging:
|
||||
mock_context.request_id = "req-123"
|
||||
mock_context.client_ip = "127.0.0.1"
|
||||
mock_context.user_agent = "test-agent"
|
||||
mock_context.request_method = "POST"
|
||||
mock_context.request_path = "/v1/messages"
|
||||
mock_context.tx_committed_by_route = False
|
||||
mock_context.gateway_execution_path = None
|
||||
mock_context.rate_limit_scope = None
|
||||
mock_context.sync_runtime_state_from_request = MagicMock()
|
||||
mock_context.request = MagicMock()
|
||||
mock_context.request.method = "POST"
|
||||
mock_context.request.url.path = "/v1/messages"
|
||||
mock_context.start_time = 1000.0
|
||||
mock_context.original_headers = {}
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "test-adapter"
|
||||
@@ -321,6 +353,81 @@ class TestPipelineAuditLogging:
|
||||
# 不应该抛出异常
|
||||
pipeline._record_audit_event(mock_context, mock_adapter, success=True)
|
||||
|
||||
def test_build_audit_metadata_prefers_context_path_params(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
mock_context = MagicMock()
|
||||
mock_context.start_time = 1000.0
|
||||
mock_context.mode = "standard"
|
||||
mock_context.api_format_hint = "gemini"
|
||||
mock_context.query_params = {}
|
||||
mock_context.raw_body = b"{}"
|
||||
mock_context.balance_remaining = 12.5
|
||||
mock_context.audit_metadata = {}
|
||||
mock_context.quiet_logging = False
|
||||
mock_context.user = None
|
||||
mock_context.api_key = None
|
||||
mock_context.request_method = "POST"
|
||||
mock_context.request_path = "/v1beta/models/gemini-2.5-flash:streamGenerateContent"
|
||||
mock_context.tx_committed_by_route = False
|
||||
mock_context.gateway_execution_path = "executor_local"
|
||||
mock_context.rate_limit_scope = "user"
|
||||
mock_context.sync_runtime_state_from_request = MagicMock()
|
||||
mock_context.path_params = {"model": "gemini-2.5-flash", "stream": True}
|
||||
mock_context.request = MagicMock()
|
||||
mock_context.request.method = "POST"
|
||||
mock_context.request.url.path = "/v1beta/models/gemini-2.5-flash:streamGenerateContent"
|
||||
mock_context.original_headers = {"content-type": "application/json"}
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "public.gemini.content"
|
||||
mock_adapter.__class__.__name__ = "PublicGeminiContentAdapter"
|
||||
mock_adapter.mode.value = "standard"
|
||||
mock_adapter.get_audit_metadata.return_value = {}
|
||||
|
||||
with patch("time.time", return_value=1001.0):
|
||||
metadata = pipeline._build_audit_metadata(
|
||||
context=mock_context,
|
||||
adapter=mock_adapter,
|
||||
success=True,
|
||||
status_code=200,
|
||||
error=None,
|
||||
)
|
||||
|
||||
assert metadata["path_params"] == {"model": "gemini-2.5-flash", "stream": True}
|
||||
assert metadata["gateway_execution_path"] == "executor_local"
|
||||
assert metadata["rate_limit_scope"] == "user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_context_runtime_state_prefers_snapshot_balance(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
context = MagicMock()
|
||||
context.prefetched_balance_remaining = 9.5
|
||||
context.balance_remaining = None
|
||||
context.management_token = None
|
||||
context.quiet_logging = False
|
||||
|
||||
auth_state = SimpleNamespace(
|
||||
user=MagicMock(id="user-1"),
|
||||
api_key=MagicMock(id="key-1"),
|
||||
management_token=None,
|
||||
)
|
||||
|
||||
pipeline._calculate_balance_remaining_async = AsyncMock(
|
||||
side_effect=AssertionError("prefetched snapshot should skip balance lookup")
|
||||
)
|
||||
|
||||
await pipeline._apply_context_runtime_state_legacy(
|
||||
context,
|
||||
mode=ApiMode.STANDARD,
|
||||
auth_state=auth_state,
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
assert context.balance_remaining == 9.5
|
||||
assert context.quiet_logging is True
|
||||
|
||||
|
||||
class TestPipelineAuthentication:
|
||||
"""测试 Pipeline 认证相关逻辑"""
|
||||
@@ -630,12 +737,95 @@ class TestPipelineAuthentication:
|
||||
with pytest.raises(BalanceInsufficientException):
|
||||
await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_client_gateway_marker_without_trusted_ids_falls_back_to_legacy(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-123"
|
||||
mock_api_key = MagicMock()
|
||||
mock_api_key.id = "key-123"
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"x-aether-gateway": "rust-phase3b"}
|
||||
mock_request.client = SimpleNamespace(host="127.0.0.1")
|
||||
mock_request.url.path = "/v1/chat/completions"
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
db_user = MagicMock()
|
||||
db_user.id = "user-123"
|
||||
db_user.is_active = True
|
||||
db_user.is_deleted = False
|
||||
db_api_key = MagicMock()
|
||||
db_api_key.id = "key-123"
|
||||
db_api_key.user_id = "user-123"
|
||||
db_api_key.is_active = True
|
||||
db_api_key.is_locked = False
|
||||
db_api_key.is_standalone = False
|
||||
db_api_key.expires_at = None
|
||||
|
||||
mock_db = MagicMock()
|
||||
user_query = MagicMock()
|
||||
user_query.filter.return_value.first.return_value = db_user
|
||||
api_key_query = MagicMock()
|
||||
api_key_query.filter.return_value.first.return_value = db_api_key
|
||||
mock_db.query.side_effect = [user_query, api_key_query]
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.extract_api_key = MagicMock(return_value="sk-test")
|
||||
|
||||
with patch.object(
|
||||
pipeline.auth_service,
|
||||
"authenticate_api_key_threadsafe",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(
|
||||
user=mock_user,
|
||||
api_key=mock_api_key,
|
||||
access_allowed=True,
|
||||
balance_remaining=12.5,
|
||||
),
|
||||
) as mock_auth:
|
||||
user, api_key = await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
||||
|
||||
assert user == db_user
|
||||
assert api_key == db_api_key
|
||||
mock_adapter.extract_api_key.assert_called_once_with(mock_request)
|
||||
mock_auth.assert_awaited_once_with("sk-test")
|
||||
assert mock_request.state.prefetched_balance_remaining == 12.5
|
||||
|
||||
|
||||
class TestPipelineUserRateLimit:
|
||||
@pytest.fixture
|
||||
def pipeline(self) -> ApiRequestPipeline:
|
||||
return ApiRequestPipeline()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_legacy_request_guards_skips_when_rust_completed_preflight(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
request = MagicMock()
|
||||
request.headers = {
|
||||
"x-aether-gateway": "rust-phase3b",
|
||||
"x-aether-rate-limit-preflight": "true",
|
||||
}
|
||||
request.client = SimpleNamespace(host="127.0.0.1")
|
||||
request.state = MagicMock()
|
||||
db = MagicMock()
|
||||
user = MagicMock(id="user-1")
|
||||
api_key = MagicMock(id="key-1")
|
||||
auth_state = SimpleNamespace(user=user, api_key=api_key, management_token=None)
|
||||
|
||||
pipeline._check_user_rate_limit = AsyncMock(
|
||||
side_effect=AssertionError("trusted rust preflight should skip legacy limiter")
|
||||
)
|
||||
|
||||
await pipeline._apply_legacy_request_guards(
|
||||
request,
|
||||
db,
|
||||
mode=ApiMode.STANDARD,
|
||||
auth_state=auth_state,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_user_rate_limit_uses_system_default_for_user_scope(
|
||||
self, pipeline: ApiRequestPipeline, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -1126,6 +1316,65 @@ class TestPipelineAdminAuth:
|
||||
assert management_token is None
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_admin_uses_identity_helper_for_session_touch(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-123"
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "admin-123"
|
||||
mock_user.is_active = True
|
||||
mock_user.is_deleted = False
|
||||
mock_user.role = UserRole.ADMIN
|
||||
mock_user.email = "admin@example.com"
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {
|
||||
"authorization": "Bearer valid-token",
|
||||
"X-Client-Device-Id": "device-admin-123",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "admin-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-123",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.get_request_identity_metadata",
|
||||
return_value=MagicMock(client_ip="203.0.113.10", user_agent="admin-agent/1.0"),
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.touch_session",
|
||||
return_value=True,
|
||||
) as mock_touch,
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
|
||||
mock_touch.assert_called_once_with(
|
||||
mock_session,
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="admin-agent/1.0",
|
||||
)
|
||||
|
||||
|
||||
class TestPipelineUserAuth:
|
||||
"""测试普通用户 JWT 认证"""
|
||||
@@ -1318,3 +1567,61 @@ class TestPipelineUserAuth:
|
||||
assert user == mock_user
|
||||
assert management_token is None
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_uses_identity_helper_for_session_touch(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-456"
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-123"
|
||||
mock_user.is_active = True
|
||||
mock_user.is_deleted = False
|
||||
mock_user.email = "user@example.com"
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {
|
||||
"authorization": "Bearer valid-token",
|
||||
"X-Client-Device-Id": "device-user-456",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "user-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-456",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.get_request_identity_metadata",
|
||||
return_value=MagicMock(client_ip="198.51.100.25", user_agent="user-agent/2.0"),
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.touch_session",
|
||||
return_value=True,
|
||||
) as mock_touch,
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
await pipeline._authenticate_user(mock_request, mock_db)
|
||||
|
||||
mock_touch.assert_called_once_with(
|
||||
mock_session,
|
||||
client_ip="198.51.100.25",
|
||||
user_agent="user-agent/2.0",
|
||||
)
|
||||
|
||||
38
tests/api/test_python_host_import_graph.py
Normal file
38
tests/api/test_python_host_import_graph.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def test_python_host_import_does_not_load_public_compat_modules() -> None:
|
||||
targets = [
|
||||
"src.main",
|
||||
"src.api.public",
|
||||
"src.api.public.compat",
|
||||
"src.api.public.support",
|
||||
"src.api.public.models",
|
||||
"src.api.public.capabilities",
|
||||
"src.api.public.modules",
|
||||
"src.api.public.openai",
|
||||
"src.api.public.claude",
|
||||
"src.api.public.gemini",
|
||||
"src.api.public.videos",
|
||||
"src.api.public.gemini_files",
|
||||
"src.api.public.system_catalog",
|
||||
]
|
||||
for name in targets:
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
importlib.import_module("src.main")
|
||||
|
||||
assert "src.api.public.support" in sys.modules
|
||||
assert "src.api.public.compat" not in sys.modules
|
||||
assert "src.api.public.models" not in sys.modules
|
||||
assert "src.api.public.capabilities" not in sys.modules
|
||||
assert "src.api.public.modules" not in sys.modules
|
||||
assert "src.api.public.openai" not in sys.modules
|
||||
assert "src.api.public.claude" not in sys.modules
|
||||
assert "src.api.public.gemini" not in sys.modules
|
||||
assert "src.api.public.videos" not in sys.modules
|
||||
assert "src.api.public.gemini_files" not in sys.modules
|
||||
assert "src.api.public.system_catalog" not in sys.modules
|
||||
920
tests/api/test_python_host_shell.py
Normal file
920
tests/api/test_python_host_shell.py
Normal file
@@ -0,0 +1,920 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.routing import Match
|
||||
|
||||
from src.api.announcements import router as python_announcement_router
|
||||
from src.api.admin import python_admin_router
|
||||
from src.api.auth import router as python_auth_router
|
||||
from src.api.dashboard import router as python_dashboard_router
|
||||
import src.api.internal as internal_module
|
||||
from src.api.internal.gateway import router as legacy_gateway_bridge_router
|
||||
from src.api.monitoring import router as python_monitoring_router
|
||||
from src.api.payment import router as python_payment_router
|
||||
from src.api.public import frontdoor_compat_router, router as python_public_router
|
||||
from src.api.user_me import router as python_user_me_router
|
||||
from src.api.wallet import router as python_wallet_router
|
||||
import src.main as main_module
|
||||
|
||||
LEGACY_GATEWAY_BRIDGE_PATH_PREFIX = "/api/internal/gateway"
|
||||
RUST_OWNED_ADMIN_PATHS = {
|
||||
"/api/admin/modules/status",
|
||||
"/api/admin/modules/status/{module_name}",
|
||||
"/api/admin/modules/status/{module_name}/enabled",
|
||||
"/api/admin/system/version",
|
||||
"/api/admin/system/check-update",
|
||||
"/api/admin/system/aws-regions",
|
||||
"/api/admin/system/stats",
|
||||
"/api/admin/system/settings",
|
||||
"/api/admin/system/config/export",
|
||||
"/api/admin/system/users/export",
|
||||
"/api/admin/system/config/import",
|
||||
"/api/admin/system/users/import",
|
||||
"/api/admin/system/smtp/test",
|
||||
"/api/admin/system/cleanup",
|
||||
"/api/admin/system/purge/config",
|
||||
"/api/admin/system/purge/users",
|
||||
"/api/admin/system/purge/usage",
|
||||
"/api/admin/system/purge/audit-logs",
|
||||
"/api/admin/system/purge/request-bodies",
|
||||
"/api/admin/system/purge/stats",
|
||||
"/api/admin/system/configs",
|
||||
"/api/admin/system/configs/{key}",
|
||||
"/api/admin/system/api-formats",
|
||||
"/api/admin/system/email/templates",
|
||||
"/api/admin/system/email/templates/{template_type}",
|
||||
"/api/admin/providers/",
|
||||
"/api/admin/providers/summary",
|
||||
"/api/admin/providers/{provider_id}",
|
||||
"/api/admin/providers/{provider_id}/summary",
|
||||
"/api/admin/providers/{provider_id}/health-monitor",
|
||||
"/api/admin/providers/{provider_id}/mapping-preview",
|
||||
"/api/admin/providers/{provider_id}/delete-task/{task_id}",
|
||||
"/api/admin/providers/{provider_id}/pool-status",
|
||||
"/api/admin/providers/{provider_id}/pool/clear-cooldown/{key_id}",
|
||||
"/api/admin/providers/{provider_id}/pool/reset-cost/{key_id}",
|
||||
"/api/admin/providers/{provider_id}/models",
|
||||
"/api/admin/providers/{provider_id}/models/{model_id}",
|
||||
"/api/admin/providers/{provider_id}/models/batch",
|
||||
"/api/admin/providers/{provider_id}/available-source-models",
|
||||
"/api/admin/providers/{provider_id}/assign-global-models",
|
||||
"/api/admin/providers/{provider_id}/import-from-upstream",
|
||||
"/api/admin/endpoints/providers/{provider_id}/endpoints",
|
||||
"/api/admin/endpoints/defaults/{api_format}/body-rules",
|
||||
"/api/admin/endpoints/{endpoint_id}",
|
||||
"/api/admin/endpoints/keys/{key_id}",
|
||||
"/api/admin/endpoints/keys/grouped-by-format",
|
||||
"/api/admin/endpoints/keys/{key_id}/reveal",
|
||||
"/api/admin/endpoints/keys/{key_id}/export",
|
||||
"/api/admin/endpoints/keys/batch-delete",
|
||||
"/api/admin/endpoints/keys/{key_id}/clear-oauth-invalid",
|
||||
"/api/admin/endpoints/providers/{provider_id}/keys",
|
||||
"/api/admin/endpoints/providers/{provider_id}/refresh-quota",
|
||||
"/api/admin/endpoints/rpm/key/{key_id}",
|
||||
"/api/admin/endpoints/health/summary",
|
||||
"/api/admin/endpoints/health/status",
|
||||
"/api/admin/endpoints/health/api-formats",
|
||||
"/api/admin/endpoints/health/key/{key_id}",
|
||||
"/api/admin/endpoints/health/keys/{key_id}",
|
||||
"/api/admin/endpoints/health/keys",
|
||||
"/api/admin/provider-oauth/supported-types",
|
||||
"/api/admin/provider-oauth/keys/{key_id}/start",
|
||||
"/api/admin/provider-oauth/keys/{key_id}/complete",
|
||||
"/api/admin/provider-oauth/keys/{key_id}/refresh",
|
||||
"/api/admin/provider-oauth/providers/{provider_id}/start",
|
||||
"/api/admin/provider-oauth/providers/{provider_id}/complete",
|
||||
"/api/admin/provider-oauth/providers/{provider_id}/import-refresh-token",
|
||||
"/api/admin/provider-oauth/providers/{provider_id}/device-authorize",
|
||||
"/api/admin/provider-oauth/providers/{provider_id}/device-poll",
|
||||
"/api/admin/provider-oauth/providers/{provider_id}/batch-import",
|
||||
"/api/admin/provider-oauth/providers/{provider_id}/batch-import/tasks",
|
||||
"/api/admin/provider-oauth/providers/{provider_id}/batch-import/tasks/{task_id}",
|
||||
"/api/admin/adaptive/keys",
|
||||
"/api/admin/adaptive/keys/{key_id}/mode",
|
||||
"/api/admin/adaptive/keys/{key_id}/stats",
|
||||
"/api/admin/adaptive/keys/{key_id}/learning",
|
||||
"/api/admin/adaptive/keys/{key_id}/limit",
|
||||
"/api/admin/adaptive/summary",
|
||||
"/api/admin/provider-ops/architectures",
|
||||
"/api/admin/provider-ops/architectures/{architecture_id}",
|
||||
"/api/admin/provider-ops/providers/{provider_id}/status",
|
||||
"/api/admin/provider-ops/providers/{provider_id}/config",
|
||||
"/api/admin/provider-ops/providers/{provider_id}/connect",
|
||||
"/api/admin/provider-ops/providers/{provider_id}/disconnect",
|
||||
"/api/admin/provider-ops/providers/{provider_id}/verify",
|
||||
"/api/admin/provider-ops/providers/{provider_id}/actions/{action_type}",
|
||||
"/api/admin/provider-ops/providers/{provider_id}/balance",
|
||||
"/api/admin/provider-ops/providers/{provider_id}/checkin",
|
||||
"/api/admin/provider-ops/batch/balance",
|
||||
"/api/admin/billing/presets",
|
||||
"/api/admin/billing/presets/apply",
|
||||
"/api/admin/billing/rules",
|
||||
"/api/admin/billing/rules/{rule_id}",
|
||||
"/api/admin/billing/collectors",
|
||||
"/api/admin/billing/collectors/{collector_id}",
|
||||
"/api/admin/provider-strategy/providers/{provider_id}/billing",
|
||||
"/api/admin/provider-strategy/providers/{provider_id}/stats",
|
||||
"/api/admin/provider-strategy/strategies",
|
||||
"/api/admin/provider-strategy/providers/{provider_id}/quota",
|
||||
"/api/admin/provider-query/models",
|
||||
"/api/admin/provider-query/test-model",
|
||||
"/api/admin/provider-query/test-model-failover",
|
||||
"/api/admin/payments/orders",
|
||||
"/api/admin/payments/orders/{order_id}",
|
||||
"/api/admin/payments/orders/{order_id}/expire",
|
||||
"/api/admin/payments/orders/{order_id}/credit",
|
||||
"/api/admin/payments/orders/{order_id}/fail",
|
||||
"/api/admin/payments/callbacks",
|
||||
"/api/admin/security/ip/blacklist",
|
||||
"/api/admin/security/ip/blacklist/{ip_address}",
|
||||
"/api/admin/security/ip/blacklist/stats",
|
||||
"/api/admin/security/ip/whitelist",
|
||||
"/api/admin/security/ip/whitelist/{ip_address}",
|
||||
"/api/admin/security/ip/whitelist",
|
||||
"/api/admin/stats/providers/quota-usage",
|
||||
"/api/admin/stats/comparison",
|
||||
"/api/admin/stats/errors/distribution",
|
||||
"/api/admin/stats/performance/percentiles",
|
||||
"/api/admin/stats/cost/forecast",
|
||||
"/api/admin/stats/cost/savings",
|
||||
"/api/admin/stats/leaderboard/api-keys",
|
||||
"/api/admin/stats/leaderboard/models",
|
||||
"/api/admin/stats/leaderboard/users",
|
||||
"/api/admin/stats/time-series",
|
||||
"/api/admin/monitoring/audit-logs",
|
||||
"/api/admin/monitoring/system-status",
|
||||
"/api/admin/monitoring/suspicious-activities",
|
||||
"/api/admin/monitoring/user-behavior/{user_id}",
|
||||
"/api/admin/monitoring/resilience-status",
|
||||
"/api/admin/monitoring/resilience/circuit-history",
|
||||
"/api/admin/monitoring/resilience/error-stats",
|
||||
"/api/admin/monitoring/trace/{request_id}",
|
||||
"/api/admin/monitoring/trace/stats/provider/{provider_id}",
|
||||
"/api/admin/monitoring/cache/stats",
|
||||
"/api/admin/monitoring/cache/affinity/{user_identifier}",
|
||||
"/api/admin/monitoring/cache/affinities",
|
||||
"/api/admin/monitoring/cache/users/{user_identifier}",
|
||||
"/api/admin/monitoring/cache/affinity/{affinity_key}/{endpoint_id}/{model_id}/{api_format}",
|
||||
"/api/admin/monitoring/cache",
|
||||
"/api/admin/monitoring/cache/providers/{provider_id}",
|
||||
"/api/admin/monitoring/cache/config",
|
||||
"/api/admin/monitoring/cache/metrics",
|
||||
"/api/admin/monitoring/cache/model-mapping/stats",
|
||||
"/api/admin/monitoring/cache/model-mapping",
|
||||
"/api/admin/monitoring/cache/model-mapping/{model_name}",
|
||||
"/api/admin/monitoring/cache/model-mapping/provider/{provider_id}/{global_model_id}",
|
||||
"/api/admin/monitoring/cache/redis-keys",
|
||||
"/api/admin/monitoring/cache/redis-keys/{category}",
|
||||
"/api/admin/usage/aggregation/stats",
|
||||
"/api/admin/usage/stats",
|
||||
"/api/admin/usage/heatmap",
|
||||
"/api/admin/usage/records",
|
||||
"/api/admin/usage/active",
|
||||
"/api/admin/usage/cache-affinity/hit-analysis",
|
||||
"/api/admin/usage/cache-affinity/interval-timeline",
|
||||
"/api/admin/usage/cache-affinity/ttl-analysis",
|
||||
"/api/admin/usage/{usage_id}/curl",
|
||||
"/api/admin/usage/{usage_id}",
|
||||
"/api/admin/usage/{usage_id}/replay",
|
||||
"/api/admin/video-tasks",
|
||||
"/api/admin/video-tasks/stats",
|
||||
"/api/admin/video-tasks/{task_id}",
|
||||
"/api/admin/video-tasks/{task_id}/cancel",
|
||||
"/api/admin/video-tasks/{task_id}/video",
|
||||
"/api/admin/wallets",
|
||||
"/api/admin/wallets/ledger",
|
||||
"/api/admin/wallets/refund-requests",
|
||||
"/api/admin/wallets/{wallet_id}",
|
||||
"/api/admin/wallets/{wallet_id}/transactions",
|
||||
"/api/admin/wallets/{wallet_id}/refunds",
|
||||
"/api/admin/wallets/{wallet_id}/adjust",
|
||||
"/api/admin/wallets/{wallet_id}/recharge",
|
||||
"/api/admin/wallets/{wallet_id}/refunds/{refund_id}/process",
|
||||
"/api/admin/wallets/{wallet_id}/refunds/{refund_id}/complete",
|
||||
"/api/admin/wallets/{wallet_id}/refunds/{refund_id}/fail",
|
||||
"/api/admin/api-keys",
|
||||
"/api/admin/api-keys/{key_id}",
|
||||
"/api/admin/users",
|
||||
"/api/admin/users/{user_id}",
|
||||
"/api/admin/users/{user_id}/sessions",
|
||||
"/api/admin/users/{user_id}/sessions/{session_id}",
|
||||
"/api/admin/users/{user_id}/api-keys",
|
||||
"/api/admin/users/{user_id}/api-keys/{key_id}",
|
||||
"/api/admin/users/{user_id}/api-keys/{key_id}/lock",
|
||||
"/api/admin/users/{user_id}/api-keys/{key_id}/full-key",
|
||||
"/api/admin/pool/overview",
|
||||
"/api/admin/pool/scheduling-presets",
|
||||
"/api/admin/pool/{provider_id}/keys",
|
||||
"/api/admin/pool/{provider_id}/keys/batch-delete-task/{task_id}",
|
||||
"/api/admin/pool/{provider_id}/keys/batch-action",
|
||||
"/api/admin/pool/{provider_id}/keys/batch-import",
|
||||
"/api/admin/pool/{provider_id}/keys/cleanup-banned",
|
||||
"/api/admin/pool/{provider_id}/keys/resolve-selection",
|
||||
"/api/admin/proxy-nodes",
|
||||
"/api/admin/proxy-nodes/register",
|
||||
"/api/admin/proxy-nodes/heartbeat",
|
||||
"/api/admin/proxy-nodes/unregister",
|
||||
"/api/admin/proxy-nodes/manual",
|
||||
"/api/admin/proxy-nodes/upgrade",
|
||||
"/api/admin/proxy-nodes/test-url",
|
||||
"/api/admin/proxy-nodes/{node_id}",
|
||||
"/api/admin/proxy-nodes/{node_id}/test",
|
||||
"/api/admin/proxy-nodes/{node_id}/config",
|
||||
"/api/admin/proxy-nodes/{node_id}/events",
|
||||
"/api/admin/models/catalog",
|
||||
"/api/admin/models/external",
|
||||
"/api/admin/models/external/cache",
|
||||
"/api/admin/models/global",
|
||||
"/api/admin/models/global/{global_model_id}",
|
||||
"/api/admin/models/global/batch-delete",
|
||||
"/api/admin/models/global/{global_model_id}/assign-to-providers",
|
||||
"/api/admin/models/global/{global_model_id}/providers",
|
||||
"/api/admin/models/global/{global_model_id}/routing",
|
||||
}
|
||||
|
||||
|
||||
def _route_paths(router: object) -> set[str]:
|
||||
return {route.path for route in getattr(router, "routes", [])}
|
||||
|
||||
|
||||
def _app_matches_http_route(path: str, method: str) -> bool:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"path": path,
|
||||
"method": method,
|
||||
"root_path": "",
|
||||
}
|
||||
return any(route.matches(scope)[0] is Match.FULL for route in main_module.app.routes)
|
||||
|
||||
|
||||
def _router_matches_http_route(router: object, path: str, method: str) -> bool:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"path": path,
|
||||
"method": method,
|
||||
"root_path": "",
|
||||
}
|
||||
return any(route.matches(scope)[0] is Match.FULL for route in getattr(router, "routes", []))
|
||||
|
||||
|
||||
def test_python_host_app_exposes_loopback_internal_gateway_bridge_routes() -> None:
|
||||
host_route_paths = _route_paths(main_module.app)
|
||||
legacy_bridge_paths = _route_paths(legacy_gateway_bridge_router)
|
||||
|
||||
assert "/api/internal/gateway/resolve" in legacy_bridge_paths
|
||||
assert "/api/internal/gateway/auth-context" in legacy_bridge_paths
|
||||
assert "/api/internal/gateway/decision-sync" in legacy_bridge_paths
|
||||
assert "/api/internal/gateway/decision-stream" in legacy_bridge_paths
|
||||
|
||||
assert hasattr(internal_module, "legacy_gateway_bridge_router") is False
|
||||
assert hasattr(internal_module, "LEGACY_GATEWAY_BRIDGE_PATH_PREFIXES") is False
|
||||
assert not legacy_bridge_paths.issubset(host_route_paths)
|
||||
assert not any(path.startswith(LEGACY_GATEWAY_BRIDGE_PATH_PREFIX) for path in host_route_paths)
|
||||
|
||||
|
||||
def test_python_host_app_exposes_no_api_routes() -> None:
|
||||
host_route_paths = _route_paths(main_module.app)
|
||||
api_route_paths = sorted(path for path in host_route_paths if path.startswith("/api/"))
|
||||
|
||||
assert api_route_paths == []
|
||||
|
||||
|
||||
def test_python_internal_router_excludes_gateway_bridge() -> None:
|
||||
internal_route_paths = _route_paths(internal_module.python_internal_router)
|
||||
|
||||
assert not any(
|
||||
path.startswith(LEGACY_GATEWAY_BRIDGE_PATH_PREFIX)
|
||||
for path in internal_route_paths
|
||||
)
|
||||
assert not any(path.startswith("/api/internal/hub") for path in internal_route_paths)
|
||||
|
||||
|
||||
def test_python_host_app_surface_keeps_shell_routes_and_rejects_removed_edges() -> None:
|
||||
host_route_paths = _route_paths(main_module.app)
|
||||
compat_route_paths = _route_paths(frontdoor_compat_router)
|
||||
python_public_route_paths = _route_paths(python_public_router)
|
||||
python_auth_route_paths = _route_paths(python_auth_router)
|
||||
python_dashboard_route_paths = _route_paths(python_dashboard_router)
|
||||
python_monitoring_route_paths = _route_paths(python_monitoring_router)
|
||||
python_payment_route_paths = _route_paths(python_payment_router)
|
||||
python_user_me_route_paths = _route_paths(python_user_me_router)
|
||||
python_wallet_route_paths = _route_paths(python_wallet_router)
|
||||
python_admin_route_paths = _route_paths(python_admin_router)
|
||||
python_announcement_route_paths = _route_paths(python_announcement_router)
|
||||
|
||||
assert "/v1/chat/completions" in compat_route_paths
|
||||
assert "/v1/messages" in compat_route_paths
|
||||
assert "/v1beta/models/{model}:generateContent" in compat_route_paths
|
||||
assert "/v1/videos" in compat_route_paths
|
||||
assert "/v1beta/files" in compat_route_paths
|
||||
|
||||
assert "/v1/chat/completions" not in python_public_route_paths
|
||||
assert "/v1/messages" not in python_public_route_paths
|
||||
assert "/v1beta/models/{model}:generateContent" not in python_public_route_paths
|
||||
assert "/v1/videos" not in python_public_route_paths
|
||||
assert "/v1beta/files" not in python_public_route_paths
|
||||
assert "/v1/models" not in python_public_route_paths
|
||||
assert "/api/public/site-info" not in python_public_route_paths
|
||||
assert "/api/public/providers" not in python_public_route_paths
|
||||
assert "/api/public/models" not in python_public_route_paths
|
||||
assert "/api/public/search/models" not in python_public_route_paths
|
||||
assert "/api/public/stats" not in python_public_route_paths
|
||||
assert "/api/public/global-models" not in python_public_route_paths
|
||||
assert "/api/public/health/api-formats" not in python_public_route_paths
|
||||
assert "/api/modules/auth-status" not in python_public_route_paths
|
||||
assert "/api/capabilities" not in python_public_route_paths
|
||||
assert "/api/capabilities/user-configurable" not in python_public_route_paths
|
||||
assert "/api/capabilities/model/{model_name}" not in python_public_route_paths
|
||||
assert "/api/auth/registration-settings" not in python_auth_route_paths
|
||||
assert "/api/auth/settings" not in python_auth_route_paths
|
||||
assert "/api/auth/login" not in python_auth_route_paths
|
||||
assert "/api/auth/refresh" not in python_auth_route_paths
|
||||
assert "/api/auth/register" not in python_auth_route_paths
|
||||
assert "/api/auth/me" not in python_auth_route_paths
|
||||
assert "/api/auth/logout" not in python_auth_route_paths
|
||||
assert "/api/auth/send-verification-code" not in python_auth_route_paths
|
||||
assert "/api/auth/verify-email" not in python_auth_route_paths
|
||||
assert "/api/auth/verification-status" not in python_auth_route_paths
|
||||
assert "/api/dashboard/stats" not in python_dashboard_route_paths
|
||||
assert "/api/dashboard/recent-requests" not in python_dashboard_route_paths
|
||||
assert "/api/dashboard/provider-status" not in python_dashboard_route_paths
|
||||
assert "/api/dashboard/daily-stats" not in python_dashboard_route_paths
|
||||
assert "/api/monitoring/my-audit-logs" not in python_monitoring_route_paths
|
||||
assert "/api/monitoring/rate-limit-status" not in python_monitoring_route_paths
|
||||
assert "/api/payment/callback/{payment_method}" not in python_payment_route_paths
|
||||
assert "/api/wallet/balance" not in python_wallet_route_paths
|
||||
assert "/api/wallet/transactions" not in python_wallet_route_paths
|
||||
assert "/api/wallet/flow" not in python_wallet_route_paths
|
||||
assert "/api/wallet/today-cost" not in python_wallet_route_paths
|
||||
assert "/api/wallet/recharge" not in python_wallet_route_paths
|
||||
assert "/api/wallet/recharge/{order_id}" not in python_wallet_route_paths
|
||||
assert "/api/wallet/refunds" not in python_wallet_route_paths
|
||||
assert "/api/wallet/refunds/{refund_id}" not in python_wallet_route_paths
|
||||
assert "/api/users/me" not in python_user_me_route_paths
|
||||
assert "/api/users/me/password" not in python_user_me_route_paths
|
||||
assert "/api/users/me/sessions" not in python_user_me_route_paths
|
||||
assert "/api/users/me/sessions/others" not in python_user_me_route_paths
|
||||
assert "/api/users/me/sessions/{session_id}" not in python_user_me_route_paths
|
||||
assert "/api/users/me/api-keys" not in python_user_me_route_paths
|
||||
assert "/api/users/me/api-keys/{key_id}" not in python_user_me_route_paths
|
||||
assert "/api/users/me/usage" not in python_user_me_route_paths
|
||||
assert "/api/users/me/usage/active" not in python_user_me_route_paths
|
||||
assert "/api/users/me/usage/interval-timeline" not in python_user_me_route_paths
|
||||
assert "/api/users/me/usage/heatmap" not in python_user_me_route_paths
|
||||
assert "/api/users/me/providers" not in python_user_me_route_paths
|
||||
assert "/api/users/me/available-models" not in python_user_me_route_paths
|
||||
assert "/api/users/me/endpoint-status" not in python_user_me_route_paths
|
||||
assert "/api/users/me/api-keys/{api_key_id}/providers" not in python_user_me_route_paths
|
||||
assert "/api/users/me/api-keys/{api_key_id}/capabilities" not in python_user_me_route_paths
|
||||
assert "/api/users/me/preferences" not in python_user_me_route_paths
|
||||
assert "/api/users/me/model-capabilities" not in python_user_me_route_paths
|
||||
assert not (RUST_OWNED_ADMIN_PATHS & python_admin_route_paths)
|
||||
assert not _router_matches_http_route(
|
||||
python_announcement_router, "/api/announcements", "GET"
|
||||
)
|
||||
assert not _router_matches_http_route(
|
||||
python_announcement_router, "/api/announcements/active", "GET"
|
||||
)
|
||||
assert not _router_matches_http_route(
|
||||
python_announcement_router, "/api/announcements", "POST"
|
||||
)
|
||||
assert not _router_matches_http_route(
|
||||
python_announcement_router, "/api/announcements/announcement-1", "PUT"
|
||||
)
|
||||
assert not _router_matches_http_route(
|
||||
python_announcement_router, "/api/announcements/announcement-1", "DELETE"
|
||||
)
|
||||
assert not _router_matches_http_route(
|
||||
python_announcement_router,
|
||||
"/api/announcements/users/me/unread-count",
|
||||
"GET",
|
||||
)
|
||||
assert not _router_matches_http_route(
|
||||
python_announcement_router,
|
||||
"/api/announcements/announcement-1/read-status",
|
||||
"PATCH",
|
||||
)
|
||||
|
||||
assert "/v1/chat/completions" not in host_route_paths
|
||||
assert "/v1/messages" not in host_route_paths
|
||||
assert "/v1beta/models/{model}:generateContent" not in host_route_paths
|
||||
assert "/v1/videos" not in host_route_paths
|
||||
assert "/v1beta/files" not in host_route_paths
|
||||
assert "/v1/models" not in host_route_paths
|
||||
assert "/v1/providers" not in host_route_paths
|
||||
assert "/v1/test-connection" not in host_route_paths
|
||||
assert "/api/public/site-info" not in host_route_paths
|
||||
assert "/api/public/providers" not in host_route_paths
|
||||
assert "/api/public/models" not in host_route_paths
|
||||
assert "/api/public/search/models" not in host_route_paths
|
||||
assert "/api/public/stats" not in host_route_paths
|
||||
assert not _app_matches_http_route("/api/announcements", "GET")
|
||||
assert not _app_matches_http_route("/api/announcements/active", "GET")
|
||||
assert not _app_matches_http_route("/api/announcements", "POST")
|
||||
assert not _app_matches_http_route("/api/announcements/announcement-1", "PUT")
|
||||
assert not _app_matches_http_route("/api/announcements/announcement-1", "DELETE")
|
||||
assert "/api/public/global-models" not in host_route_paths
|
||||
assert "/api/public/health/api-formats" not in host_route_paths
|
||||
assert "/api/modules/auth-status" not in host_route_paths
|
||||
assert "/api/capabilities" not in host_route_paths
|
||||
assert "/api/capabilities/user-configurable" not in host_route_paths
|
||||
assert "/api/capabilities/model/{model_name}" not in host_route_paths
|
||||
assert "/api/auth/registration-settings" not in host_route_paths
|
||||
assert "/api/auth/settings" not in host_route_paths
|
||||
assert "/api/auth/login" not in host_route_paths
|
||||
assert "/api/auth/refresh" not in host_route_paths
|
||||
assert "/api/auth/register" not in host_route_paths
|
||||
assert "/api/auth/me" not in host_route_paths
|
||||
assert "/api/auth/logout" not in host_route_paths
|
||||
assert "/api/auth/send-verification-code" not in host_route_paths
|
||||
assert "/api/auth/verify-email" not in host_route_paths
|
||||
assert "/api/auth/verification-status" not in host_route_paths
|
||||
assert "/api/dashboard/stats" not in host_route_paths
|
||||
assert "/api/dashboard/recent-requests" not in host_route_paths
|
||||
assert "/api/dashboard/provider-status" not in host_route_paths
|
||||
assert "/api/dashboard/daily-stats" not in host_route_paths
|
||||
assert "/api/monitoring/my-audit-logs" not in host_route_paths
|
||||
assert "/api/monitoring/rate-limit-status" not in host_route_paths
|
||||
assert "/api/payment/callback/{payment_method}" not in host_route_paths
|
||||
assert "/api/wallet/balance" not in host_route_paths
|
||||
assert "/api/wallet/transactions" not in host_route_paths
|
||||
assert "/api/wallet/flow" not in host_route_paths
|
||||
assert "/api/wallet/today-cost" not in host_route_paths
|
||||
assert "/api/wallet/recharge" not in host_route_paths
|
||||
assert "/api/wallet/recharge/{order_id}" not in host_route_paths
|
||||
assert "/api/wallet/refunds" not in host_route_paths
|
||||
assert "/api/wallet/refunds/{refund_id}" not in host_route_paths
|
||||
assert not (RUST_OWNED_ADMIN_PATHS & host_route_paths)
|
||||
assert "/health" not in host_route_paths
|
||||
assert "/v1/health" not in host_route_paths
|
||||
assert "/" not in host_route_paths
|
||||
assert "/test-connection" not in host_route_paths
|
||||
assert "/readyz" not in host_route_paths
|
||||
|
||||
assert _app_matches_http_route("/v1/chat/completions", "POST") is False
|
||||
assert _app_matches_http_route("/v1/messages", "POST") is False
|
||||
assert _app_matches_http_route("/v1beta/models/gemini-2.5-pro:generateContent", "POST") is False
|
||||
assert _app_matches_http_route("/v1/videos", "POST") is False
|
||||
assert _app_matches_http_route("/v1beta/files", "GET") is False
|
||||
assert _app_matches_http_route("/v1/models", "GET") is False
|
||||
assert _app_matches_http_route("/v1/providers", "GET") is False
|
||||
assert _app_matches_http_route("/v1/test-connection", "GET") is False
|
||||
assert _app_matches_http_route("/api/public/site-info", "GET") is False
|
||||
assert _app_matches_http_route("/api/public/providers", "GET") is False
|
||||
assert _app_matches_http_route("/api/public/models", "GET") is False
|
||||
assert _app_matches_http_route("/api/public/search/models", "GET") is False
|
||||
assert _app_matches_http_route("/api/public/stats", "GET") is False
|
||||
assert _app_matches_http_route("/api/public/global-models", "GET") is False
|
||||
assert _app_matches_http_route("/api/public/health/api-formats", "GET") is False
|
||||
assert _app_matches_http_route("/api/modules/auth-status", "GET") is False
|
||||
assert _app_matches_http_route("/api/capabilities", "GET") is False
|
||||
assert _app_matches_http_route("/api/capabilities/user-configurable", "GET") is False
|
||||
assert _app_matches_http_route("/api/capabilities/model/gpt-5", "GET") is False
|
||||
assert _app_matches_http_route("/api/auth/registration-settings", "GET") is False
|
||||
assert _app_matches_http_route("/api/auth/login", "POST") is False
|
||||
assert _app_matches_http_route("/api/auth/refresh", "POST") is False
|
||||
assert _app_matches_http_route("/api/auth/register", "POST") is False
|
||||
assert _app_matches_http_route("/api/auth/me", "GET") is False
|
||||
assert _app_matches_http_route("/api/auth/logout", "POST") is False
|
||||
assert _app_matches_http_route("/api/auth/send-verification-code", "POST") is False
|
||||
assert _app_matches_http_route("/api/auth/verify-email", "POST") is False
|
||||
assert _app_matches_http_route("/api/auth/verification-status", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/stats/comparison", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/stats/errors/distribution", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/stats/performance/percentiles", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/stats/cost/forecast", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/stats/time-series", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/stats/leaderboard/users", "GET") is False
|
||||
assert _app_matches_http_route("/api/auth/settings", "GET") is False
|
||||
assert _app_matches_http_route("/api/dashboard/stats", "GET") is False
|
||||
assert _app_matches_http_route("/api/dashboard/recent-requests", "GET") is False
|
||||
assert _app_matches_http_route("/api/dashboard/provider-status", "GET") is False
|
||||
assert _app_matches_http_route("/api/dashboard/daily-stats", "GET") is False
|
||||
assert _app_matches_http_route("/api/monitoring/my-audit-logs", "GET") is False
|
||||
assert _app_matches_http_route("/api/monitoring/rate-limit-status", "GET") is False
|
||||
assert _app_matches_http_route("/api/payment/callback/alipay", "POST") is False
|
||||
assert _app_matches_http_route("/api/wallet/balance", "GET") is False
|
||||
assert _app_matches_http_route("/api/wallet/transactions", "GET") is False
|
||||
assert _app_matches_http_route("/api/wallet/flow", "GET") is False
|
||||
assert _app_matches_http_route("/api/wallet/today-cost", "GET") is False
|
||||
assert _app_matches_http_route("/api/wallet/recharge", "GET") is False
|
||||
assert _app_matches_http_route("/api/wallet/recharge", "POST") is False
|
||||
assert _app_matches_http_route("/api/wallet/recharge/order-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/wallet/refunds", "GET") is False
|
||||
assert _app_matches_http_route("/api/wallet/refunds", "POST") is False
|
||||
assert _app_matches_http_route("/api/wallet/refunds/refund-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me", "PUT") is False
|
||||
assert _app_matches_http_route("/api/users/me/password", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/users/me/sessions", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/sessions/others", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/users/me/sessions/session-1", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/users/me/sessions/session-1", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/users/me/api-keys", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/api-keys", "POST") is False
|
||||
assert _app_matches_http_route("/api/users/me/api-keys/key-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/api-keys/key-1", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/users/me/api-keys/key-1", "PUT") is False
|
||||
assert _app_matches_http_route("/api/users/me/api-keys/key-1", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/users/me/usage", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/usage/active", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/usage/interval-timeline", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/usage/heatmap", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/providers", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/available-models", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/endpoint-status", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/api-keys/key-1/providers", "PUT") is False
|
||||
assert _app_matches_http_route("/api/users/me/api-keys/key-1/capabilities", "PUT") is False
|
||||
assert _app_matches_http_route("/api/users/me/preferences", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/preferences", "PUT") is False
|
||||
assert _app_matches_http_route("/api/users/me/model-capabilities", "GET") is False
|
||||
assert _app_matches_http_route("/api/users/me/model-capabilities", "PUT") is False
|
||||
assert _app_matches_http_route("/api/announcements/announcement-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/announcements/users/me/unread-count", "GET") is False
|
||||
assert (
|
||||
_app_matches_http_route("/api/announcements/announcement-1/read-status", "PATCH")
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/system/version", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/system/settings", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/system/config/export", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/system/configs", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/system/configs/smtp_password", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/modules/status", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/modules/status/auth", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/modules/status/auth/enabled", "PUT") is False
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/connect",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/disconnect",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/actions/query_balance",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/provider-strategy/strategies", "GET") is False
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-strategy/providers/provider-openai/billing",
|
||||
"PUT",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-strategy/providers/provider-openai/stats",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-strategy/providers/provider-openai/quota",
|
||||
"DELETE",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/stats/providers/quota-usage", "GET") is False
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/verify",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/balance",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/balance",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/checkin",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/provider-ops/batch/balance", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/presets", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/presets/apply", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/rules", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/rules/rule-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/rules", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/rules/rule-1", "PUT") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/collectors", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/collectors/collector-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/collectors", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/billing/collectors/collector-1", "PUT") is False
|
||||
assert _app_matches_http_route("/api/admin/provider-query/models", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/provider-query/test-model", "POST") is False
|
||||
assert (
|
||||
_app_matches_http_route("/api/admin/provider-query/test-model-failover", "POST")
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/payments/orders", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/payments/orders/order-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/payments/orders/order-1/expire", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/payments/orders/order-1/credit", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/payments/orders/order-1/fail", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/payments/callbacks", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/usage/aggregation/stats", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/usage/stats", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/usage/heatmap", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/usage/records", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/usage/active", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/usage/usage-1/curl", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/usage/usage-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/usage/usage-1/replay", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/register", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/heartbeat", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/unregister", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/manual", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/upgrade", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/test-url", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/node-1", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/node-1", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/node-1/test", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/node-1/config", "PUT") is False
|
||||
assert _app_matches_http_route("/api/admin/proxy-nodes/node-1/events", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/wallets", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/wallets/ledger", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/wallets/refund-requests", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/wallets/wallet-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/wallets/wallet-1/transactions", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/wallets/wallet-1/refunds", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/api-keys", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/api-keys", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/api-keys/key-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/api-keys/key-1", "PUT") is False
|
||||
assert _app_matches_http_route("/api/admin/api-keys/key-1", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/admin/api-keys/key-1", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/users", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/users", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1", "PUT") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/sessions", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/sessions", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/sessions/session-1", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/api-keys", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/api-keys", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/api-keys/key-1", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/api-keys/key-1", "PUT") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/api-keys/key-1/lock", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/admin/users/user-1/api-keys/key-1/full-key", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/system/email/templates", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/system/email/templates/verification", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/providers/", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/providers/", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/providers/provider-openai", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/admin/providers/provider-openai/summary", "GET") is False
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/providers/provider-openai/health-monitor",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/providers/provider-openai/mapping-preview",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/providers/provider-openai/delete-task/task-1",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/providers/provider-openai/pool-status",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/providers/provider-openai/models/model-1",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/providers/provider-openai/models/batch",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/providers/provider-openai/assign-global-models",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/providers/provider-openai/import-from-upstream",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/endpoints/providers/provider-openai/endpoints",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/endpoints/defaults/openai:responses/body-rules",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/endpoints/endpoint-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/endpoints/keys/key-1/export", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/endpoints/keys/key-1/reveal", "GET") is False
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/endpoints/providers/provider-openai/keys",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/endpoints/providers/provider-openai/refresh-quota",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/endpoints/keys/batch-delete", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/endpoints/rpm/key/key-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/endpoints/health/status", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/endpoints/health/key/key-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/endpoints/health/keys/key-1", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/admin/provider-oauth/supported-types", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/provider-oauth/keys/key-1/start", "POST") is False
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-oauth/providers/provider-kiro/device-authorize",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-oauth/providers/provider-kiro/device-poll",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-oauth/providers/provider-codex/import-refresh-token",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-oauth/providers/provider-codex/batch-import",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks/task-1",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/provider-ops/architectures", "GET") is False
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/architectures/generic_api",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/status",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/config",
|
||||
"GET",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/config",
|
||||
"PUT",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/provider-ops/providers/provider-openai/config",
|
||||
"DELETE",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/video-tasks", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/video-tasks/stats", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/video-tasks/task-1", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/video-tasks/task-1/cancel", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/video-tasks/task-1/video", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/adaptive/keys", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/adaptive/keys/key-1/mode", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/admin/adaptive/keys/key-1/stats", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/adaptive/keys/key-1/learning", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/adaptive/keys/key-1/limit", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/admin/adaptive/summary", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/models/catalog", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/models/external", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/models/external/cache", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/models/global", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/models/global", "POST") is False
|
||||
assert _app_matches_http_route("/api/admin/models/global/test-id", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/models/global/test-id", "PATCH") is False
|
||||
assert _app_matches_http_route("/api/admin/models/global/test-id", "DELETE") is False
|
||||
assert _app_matches_http_route("/api/admin/models/global/batch-delete", "POST") is False
|
||||
assert (
|
||||
_app_matches_http_route(
|
||||
"/api/admin/models/global/test-id/assign-to-providers",
|
||||
"POST",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert _app_matches_http_route("/api/admin/models/global/test-id/providers", "GET") is False
|
||||
assert _app_matches_http_route("/api/admin/models/global/test-id/routing", "GET") is False
|
||||
assert _app_matches_http_route("/health", "GET") is False
|
||||
assert _app_matches_http_route("/v1/health", "GET") is False
|
||||
assert _app_matches_http_route("/", "GET") is False
|
||||
assert _app_matches_http_route("/test-connection", "GET") is False
|
||||
assert _app_matches_http_route("/api/internal/gateway/auth-context", "POST") is False
|
||||
assert _app_matches_http_route("/api/internal/gateway/resolve", "POST") is False
|
||||
assert _app_matches_http_route("/api/internal/gateway/decision-sync", "POST") is False
|
||||
assert _app_matches_http_route("/api/internal/hub/heartbeat", "POST") is False
|
||||
assert _app_matches_http_route("/api/internal/hub/node-status", "POST") is False
|
||||
assert _app_matches_http_route("/readyz", "GET") is False
|
||||
|
||||
tags = {tag.get("name") for tag in main_module.app.openapi().get("tags", [])}
|
||||
assert "OpenAI API" not in tags
|
||||
assert "Claude API" not in tags
|
||||
assert "Gemini API" not in tags
|
||||
assert "Gemini Files API" not in tags
|
||||
assert "System Catalog" not in tags
|
||||
|
||||
|
||||
def test_python_payment_host_surface_is_single_dynamic_callback_route() -> None:
|
||||
host_route_paths = _route_paths(main_module.app)
|
||||
|
||||
assert "/api/payment/callback/{payment_method}" not in host_route_paths
|
||||
assert "/api/payment/callback/alipay" not in host_route_paths
|
||||
assert "/api/payment/callback/wechat" not in host_route_paths
|
||||
|
||||
assert _app_matches_http_route("/api/payment/callback/alipay", "POST") is False
|
||||
assert _app_matches_http_route("/api/payment/callback/wechat", "POST") is False
|
||||
@@ -6,6 +6,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.services.auth.oauth.models import OAuthFlowError
|
||||
from src.services.auth.oauth.providers.linuxdo import LinuxDoOAuthProvider
|
||||
|
||||
|
||||
@@ -33,6 +34,28 @@ def test_linuxdo_authorization_url_omits_empty_scope() -> None:
|
||||
assert params["state"] == ["state-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linuxdo_exchange_code_requires_rust_executor() -> None:
|
||||
provider = LinuxDoOAuthProvider()
|
||||
|
||||
with pytest.raises(OAuthFlowError) as exc_info:
|
||||
await provider.exchange_code(_make_config(), "code-0")
|
||||
|
||||
assert exc_info.value.error_code == "provider_unavailable"
|
||||
assert exc_info.value.detail == "OAuth 仅支持 Rust executor"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linuxdo_get_user_info_requires_rust_executor() -> None:
|
||||
provider = LinuxDoOAuthProvider()
|
||||
|
||||
with pytest.raises(OAuthFlowError) as exc_info:
|
||||
await provider.get_user_info(_make_config(), "access-token")
|
||||
|
||||
assert exc_info.value.error_code == "provider_unavailable"
|
||||
assert exc_info.value.detail == "OAuth 仅支持 Rust executor"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linuxdo_exchange_code_uses_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = LinuxDoOAuthProvider()
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.core.enums import AuthSource, UserRole
|
||||
from src.models.database import Base, OAuthProvider, User, UserOAuthLink
|
||||
from src.services.auth.oauth.models import OAuthFlowError
|
||||
from src.services.auth.oauth.service import OAuthService
|
||||
from src.services.auth.oauth.state import OAuthStateData
|
||||
|
||||
@@ -133,6 +134,127 @@ async def test_handle_callback_allows_bind_state_without_device_id(
|
||||
assert parse_qs(parsed.query)["oauth_bound"] == ["GitHub"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_callback_redirects_when_provider_http_is_rust_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
provider = SimpleNamespace(
|
||||
exchange_code=AsyncMock(
|
||||
side_effect=OAuthFlowError("provider_unavailable", "OAuth 仅支持 Rust executor")
|
||||
),
|
||||
get_user_info=AsyncMock(),
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
frontend_callback_url="https://app.example.com/auth/callback",
|
||||
display_name="GitHub",
|
||||
is_enabled=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._require_module_active",
|
||||
lambda _db: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
|
||||
lambda _provider_type: provider,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_config",
|
||||
lambda _db, _provider_type: config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.get_redis_client",
|
||||
AsyncMock(return_value=object()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.consume_oauth_state",
|
||||
AsyncMock(
|
||||
return_value=OAuthStateData(
|
||||
nonce="state-1",
|
||||
provider_type="github",
|
||||
action="login",
|
||||
user_id=None,
|
||||
client_device_id="device-1",
|
||||
created_at=123,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = await OAuthService.handle_callback(
|
||||
db=db,
|
||||
provider_type="github",
|
||||
state="state-1",
|
||||
code="code-1",
|
||||
error=None,
|
||||
error_description=None,
|
||||
client_ip=None,
|
||||
user_agent="pytest-agent",
|
||||
headers={},
|
||||
)
|
||||
|
||||
parsed = urlparse(result.redirect_url)
|
||||
params = parse_qs(parsed.query)
|
||||
assert params["error_code"] == ["provider_unavailable"]
|
||||
assert params["error_detail"] == ["OAuth 仅支持 Rust executor"]
|
||||
provider.get_user_info.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_provider_config_returns_rust_only_failure_without_network(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
|
||||
lambda _provider_type: SimpleNamespace(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_config",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("Python OAuth config probe should not read DB config")
|
||||
),
|
||||
)
|
||||
|
||||
result = await OAuthService.test_provider_config(db, "github")
|
||||
assert result == {
|
||||
"authorization_url_reachable": False,
|
||||
"token_url_reachable": False,
|
||||
"secret_status": "unsupported",
|
||||
"details": "OAuth 配置测试仅支持 Rust executor",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_provider_config_with_data_returns_rust_only_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
provider = SimpleNamespace(
|
||||
authorization_url="https://provider.example/authorize",
|
||||
token_url="https://provider.example/token",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
|
||||
lambda _provider_type: provider,
|
||||
)
|
||||
|
||||
result = await OAuthService.test_provider_config_with_data(
|
||||
provider_type="github",
|
||||
client_id="client-id",
|
||||
client_secret="secret",
|
||||
authorization_url_override=None,
|
||||
token_url_override=None,
|
||||
redirect_uri="https://api.example.com/api/oauth/github/callback",
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"authorization_url_reachable": False,
|
||||
"token_url_reachable": False,
|
||||
"secret_status": "unsupported",
|
||||
"details": "OAuth 配置测试仅支持 Rust executor",
|
||||
}
|
||||
|
||||
|
||||
def test_handle_login_sync_returns_user_snapshot_outside_db_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -173,6 +173,49 @@ def test_persist_refreshed_token_preserves_true_account_block(
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] Google requires verification"
|
||||
|
||||
|
||||
def test_mark_oauth_token_expired_preserves_existing_account_block() -> None:
|
||||
key = SimpleNamespace(
|
||||
id="key-1",
|
||||
oauth_invalid_at="old-invalid-at",
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] Google requires verification",
|
||||
)
|
||||
|
||||
module._mark_oauth_token_expired(key, expires_at=1_900_000_000)
|
||||
|
||||
assert key.oauth_invalid_at == "old-invalid-at"
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] Google requires verification"
|
||||
|
||||
|
||||
def test_mark_oauth_token_expired_marks_detached_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key = SimpleNamespace(id="key-1", oauth_invalid_at=None, oauth_invalid_reason=None)
|
||||
row = SimpleNamespace(id="key-1", oauth_invalid_at=None, oauth_invalid_reason=None)
|
||||
fake_db = _FakeDB(row)
|
||||
|
||||
monkeypatch.setattr(
|
||||
module, "object_session", lambda _key: (_ for _ in ()).throw(RuntimeError())
|
||||
)
|
||||
_install_module(
|
||||
monkeypatch,
|
||||
"src.database",
|
||||
{"create_session": lambda: _FakeSessionCtx(fake_db)},
|
||||
)
|
||||
_install_module(
|
||||
monkeypatch,
|
||||
"src.models.database",
|
||||
{"ProviderAPIKey": type("ProviderAPIKey", (), {"id": "id"})},
|
||||
)
|
||||
|
||||
module._mark_oauth_token_expired(key, expires_at=1_900_000_000)
|
||||
|
||||
assert fake_db.committed is True
|
||||
assert key.oauth_invalid_at is not None
|
||||
assert row.oauth_invalid_at is not None
|
||||
assert str(key.oauth_invalid_reason).startswith("[OAUTH_EXPIRED] Token 已过期且续期失败")
|
||||
assert "expired_at=1900000000" in str(row.oauth_invalid_reason)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_generic_oauth_token_persists_enriched_account_name(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -252,6 +252,54 @@ def test_clear_oauth_invalid_response_invalidates_caches(
|
||||
assert cache_calls == [("key", "key-1"), ("models", None)]
|
||||
|
||||
|
||||
def test_clear_oauth_invalid_response_noops_without_invalid_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cache_calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def _fake_invalidate_key_cache(key_id: str) -> None:
|
||||
cache_calls.append(("key", key_id))
|
||||
|
||||
async def _fake_invalidate_models_cache() -> None:
|
||||
cache_calls.append(("models", None))
|
||||
|
||||
fake_provider_cache_module = types.ModuleType("src.services.cache.provider_cache")
|
||||
|
||||
class _FakeProviderCacheService:
|
||||
@staticmethod
|
||||
async def invalidate_provider_api_key_cache(key_id: str) -> None:
|
||||
await _fake_invalidate_key_cache(key_id)
|
||||
|
||||
setattr(fake_provider_cache_module, "ProviderCacheService", _FakeProviderCacheService)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "src.services.cache.provider_cache", fake_provider_cache_module
|
||||
)
|
||||
|
||||
fake_models_service_module = types.ModuleType("src.services.cache.model_list_cache")
|
||||
setattr(
|
||||
fake_models_service_module, "invalidate_models_list_cache", _fake_invalidate_models_cache
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "src.services.cache.model_list_cache", fake_models_service_module
|
||||
)
|
||||
|
||||
key = SimpleNamespace(
|
||||
oauth_invalid_at=None,
|
||||
oauth_invalid_reason=None,
|
||||
is_active=False,
|
||||
)
|
||||
db = _FakeClearOAuthDB(key=key)
|
||||
|
||||
result = command_module.clear_oauth_invalid_response(cast(Any, db), key_id="key-1")
|
||||
|
||||
assert result["message"] == "该 Key 当前无失效标记,无需清除"
|
||||
assert key.oauth_invalid_at is None
|
||||
assert key.oauth_invalid_reason is None
|
||||
assert key.is_active is False
|
||||
assert db.commit_count == 0
|
||||
assert cache_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_delete_key_side_effects_skip_disassociate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -89,6 +89,18 @@ def _install_module(monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str,
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
|
||||
|
||||
def _patch_rust_codex_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
module: Any,
|
||||
response: Any | None,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_try_rust_codex_quota_response",
|
||||
AsyncMock(return_value=response),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_refresher_endpoint_missing_returns_error() -> None:
|
||||
key = SimpleNamespace(id="k1", name="K1")
|
||||
@@ -128,15 +140,12 @@ async def test_codex_refresher_http_non_200_returns_error(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
|
||||
response = _FakeResponse(status_code=503, payload={"x": 1})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -241,15 +250,12 @@ async def test_codex_refresher_http_401_marks_auth_invalid_without_disabling_key
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
|
||||
response = _FakeResponse(status_code=401, payload={"error": {"message": "token expired"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -295,7 +301,6 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -309,9 +314,7 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -362,7 +365,6 @@ async def test_codex_refresher_success_preserves_refresh_failed_marker(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -379,9 +381,7 @@ async def test_codex_refresher_success_preserves_refresh_failed_marker(
|
||||
module, "parse_codex_wham_usage_response", lambda _data: {"used_percent": 10.0}
|
||||
)
|
||||
response = _FakeResponse(status_code=200, payload={"ok": True})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -430,7 +430,6 @@ async def test_codex_refresher_quota_exhausted_preserves_refresh_failed_marker(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -444,9 +443,7 @@ async def test_codex_refresher_quota_exhausted_preserves_refresh_failed_marker(
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -490,7 +487,6 @@ async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -499,9 +495,7 @@ async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
|
||||
status_code=403,
|
||||
payload={"error": {"message": "Authentication token has been invalidated."}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -541,7 +535,6 @@ async def test_codex_refresher_http_403_generic_marks_soft_request_failed(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -550,9 +543,7 @@ async def test_codex_refresher_http_403_generic_marks_soft_request_failed(
|
||||
status_code=403,
|
||||
payload={"error": {"message": "Access forbidden for this account."}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -599,7 +590,6 @@ async def test_codex_refresher_success_updates_metadata(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -608,9 +598,7 @@ async def test_codex_refresher_success_updates_metadata(
|
||||
module, "parse_codex_wham_usage_response", lambda _data: {"used_percent": 10.0}
|
||||
)
|
||||
response = _FakeResponse(status_code=200, payload={"ok": True})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -647,7 +635,6 @@ async def test_codex_refresher_parse_error_is_diagnostic(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -660,9 +647,7 @@ async def test_codex_refresher_parse_error_is_diagnostic(
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=200, payload={"ok": True})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -706,7 +691,6 @@ async def test_codex_refresher_oauth_missing_plan_type_adds_account_header(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -717,12 +701,11 @@ async def test_codex_refresher_oauth_missing_plan_type_adds_account_header(
|
||||
module.crypto_service, "decrypt", lambda _v: json.dumps({"account_id": "acc-1"})
|
||||
)
|
||||
|
||||
def _client_factory(**kwargs: Any) -> _FakeAsyncClient:
|
||||
client = _FakeAsyncClient(response, **kwargs)
|
||||
client_ref["client"] = client
|
||||
return client
|
||||
async def _fake_rust_response(**kwargs: Any) -> Any:
|
||||
client_ref["client"] = SimpleNamespace(last_headers=dict(kwargs["headers"]))
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(module.httpx, "AsyncClient", _client_factory)
|
||||
monkeypatch.setattr(module, "_try_rust_codex_quota_response", _fake_rust_response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -766,7 +749,6 @@ async def test_codex_refresher_oauth_uppercase_free_does_not_add_account_header(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -779,12 +761,11 @@ async def test_codex_refresher_oauth_uppercase_free_does_not_add_account_header(
|
||||
lambda _v: json.dumps({"account_id": "acc-1", "plan_type": "FREE"}),
|
||||
)
|
||||
|
||||
def _client_factory(**kwargs: Any) -> _FakeAsyncClient:
|
||||
client = _FakeAsyncClient(response, **kwargs)
|
||||
client_ref["client"] = client
|
||||
return client
|
||||
async def _fake_rust_response(**kwargs: Any) -> Any:
|
||||
client_ref["client"] = SimpleNamespace(last_headers=dict(kwargs["headers"]))
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(module.httpx, "AsyncClient", _client_factory)
|
||||
monkeypatch.setattr(module, "_try_rust_codex_quota_response", _fake_rust_response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -1216,7 +1197,6 @@ async def test_codex_refresher_http_402_workspace_deactivated_marks_account_bloc
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -1230,9 +1210,7 @@ async def test_codex_refresher_http_402_workspace_deactivated_marks_account_bloc
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=402, payload={"detail": {"code": "deactivated_workspace"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import config as runtime_config
|
||||
from src.services.provider_ops.service import ProviderOpsService
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.services.provider_ops.types import (
|
||||
ActionStatus,
|
||||
ConnectorAuthType,
|
||||
ProviderActionType,
|
||||
ProviderOpsConfig,
|
||||
)
|
||||
from src.services.request.rust_executor_client import RustExecutorSyncResult
|
||||
|
||||
|
||||
@@ -53,6 +60,14 @@ class _FakeRegistry:
|
||||
return self._architecture
|
||||
|
||||
|
||||
class _ExplodingConnector:
|
||||
async def is_authenticated(self) -> bool:
|
||||
raise AssertionError("Python connector auth check should not run")
|
||||
|
||||
def get_client(self) -> Any:
|
||||
raise AssertionError("Python connector client should not be created")
|
||||
|
||||
|
||||
class _SuccessResult:
|
||||
def __init__(self) -> None:
|
||||
self.success = True
|
||||
@@ -165,3 +180,106 @@ async def test_verify_auth_prefers_rust_executor(
|
||||
assert captured["plan"].proxy.mode == "tunnel"
|
||||
assert captured["plan"].proxy.node_id == "node-1"
|
||||
cache_balance.assert_awaited_once_with("provider-1", 2.0, {"window": "day"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_auth_returns_explicit_failure_when_rust_verifier_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = ProviderOpsService(_FakeDB())
|
||||
architecture = _SuccessArchitecture()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_ops.service.get_registry",
|
||||
lambda: _FakeRegistry(architecture),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_try_rust_verify_response",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.resolve_ops_proxy_config_async",
|
||||
AsyncMock(return_value=(None, None)),
|
||||
)
|
||||
|
||||
result = await service.verify_auth(
|
||||
base_url="https://example.com",
|
||||
architecture_id="sub2api",
|
||||
auth_type=ConnectorAuthType.SESSION_LOGIN,
|
||||
config={},
|
||||
credentials={"access_token": "token"},
|
||||
)
|
||||
|
||||
assert result == {"success": False, "message": "认证验证仅支持 Rust executor"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("backend", ["python", "rust"])
|
||||
async def test_connect_returns_explicit_failure_without_python_connector(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
backend: str,
|
||||
) -> None:
|
||||
service = ProviderOpsService(_FakeDB())
|
||||
service._connectors["provider-1"] = object() # type: ignore[assignment]
|
||||
monkeypatch.setattr(runtime_config, "executor_backend", backend)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_provider",
|
||||
lambda _provider_id: SimpleNamespace(base_url="https://example.com"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_config",
|
||||
lambda _provider_id: ProviderOpsConfig(
|
||||
architecture_id="sub2api",
|
||||
base_url="https://example.com",
|
||||
connector_auth_type=ConnectorAuthType.API_KEY,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_ops.service.get_registry",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("Python provider connector registry should not be used")
|
||||
),
|
||||
)
|
||||
|
||||
success, message = await service.connect("provider-1", {"api_key": "token"})
|
||||
|
||||
assert success is False
|
||||
assert message == "Provider 连接仅支持 Rust executor"
|
||||
assert "provider-1" not in service._connectors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("backend", ["python", "rust"])
|
||||
async def test_execute_action_returns_not_supported_without_python_connector(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
backend: str,
|
||||
) -> None:
|
||||
service = ProviderOpsService(_FakeDB())
|
||||
service._connectors["provider-1"] = _ExplodingConnector() # type: ignore[assignment]
|
||||
monkeypatch.setattr(runtime_config, "executor_backend", backend)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_config",
|
||||
lambda _provider_id: ProviderOpsConfig(
|
||||
architecture_id="sub2api",
|
||||
base_url="https://example.com",
|
||||
connector_auth_type=ConnectorAuthType.API_KEY,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_ops.service.get_registry",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("Python provider action architecture should not be used")
|
||||
),
|
||||
)
|
||||
|
||||
result = await service.execute_action("provider-1", ProviderActionType.QUERY_BALANCE)
|
||||
|
||||
assert result.status == ActionStatus.NOT_SUPPORTED
|
||||
assert result.action_type == ProviderActionType.QUERY_BALANCE
|
||||
assert result.message == "Provider 操作仅支持 Rust executor"
|
||||
|
||||
@@ -79,6 +79,22 @@ def test_codex_openai_cli_does_not_duplicate_responses_suffix() -> None:
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses"
|
||||
|
||||
|
||||
def test_codex_openai_cli_supports_backendapi_variant() -> None:
|
||||
endpoint = _DummyEndpoint(
|
||||
base_url="https://chatgpt.com/backendapi/codex",
|
||||
api_format="openai:cli",
|
||||
provider=SimpleNamespace(provider_type="codex"),
|
||||
)
|
||||
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=True,
|
||||
)
|
||||
|
||||
assert url == "https://chatgpt.com/backendapi/codex/responses"
|
||||
|
||||
|
||||
def test_codex_openai_cli_uses_compact_suffix_when_context_marked_compact() -> None:
|
||||
endpoint = _DummyEndpoint(
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -49,6 +49,34 @@ async def test_maintenance_scheduler_start_skips_startup_task_when_disabled(
|
||||
assert created is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_start_skips_startup_task_when_no_python_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", True)
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
|
||||
|
||||
scheduler = MaintenanceScheduler()
|
||||
safe_create_task = MagicMock()
|
||||
|
||||
monkeypatch.setattr("src.utils.async_utils.safe_create_task", safe_create_task)
|
||||
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
|
||||
monkeypatch.setattr(
|
||||
maintenance_scheduler_module,
|
||||
"get_scheduler",
|
||||
lambda: SimpleNamespace(
|
||||
add_cron_job=lambda *args, **kwargs: None,
|
||||
add_interval_job=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
await scheduler.start()
|
||||
|
||||
safe_create_task.assert_not_called()
|
||||
assert scheduler._startup_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_stop_cancels_startup_task_and_removes_jobs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -89,6 +117,518 @@ async def test_maintenance_scheduler_stop_cancels_startup_task_and_removes_jobs(
|
||||
assert scheduler._registered_job_ids == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_start_skips_rust_owned_maintenance_jobs_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", False)
|
||||
monkeypatch.setattr(config, "audit_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "db_maintenance_python_enabled", False)
|
||||
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "pool_monitor_python_enabled", False)
|
||||
monkeypatch.setattr(config, "provider_checkin_python_enabled", False)
|
||||
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "stats_aggregation_python_enabled", False)
|
||||
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", False)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
|
||||
monkeypatch.setattr(config, "usage_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", False)
|
||||
|
||||
scheduler = MaintenanceScheduler()
|
||||
cron_job_ids: list[str] = []
|
||||
interval_job_ids: list[str] = []
|
||||
|
||||
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
|
||||
monkeypatch.setattr(
|
||||
maintenance_scheduler_module,
|
||||
"get_scheduler",
|
||||
lambda: SimpleNamespace(
|
||||
add_cron_job=lambda *, job_id, **kwargs: cron_job_ids.append(job_id),
|
||||
add_interval_job=lambda *, job_id, **kwargs: interval_job_ids.append(job_id),
|
||||
),
|
||||
)
|
||||
|
||||
await scheduler.start()
|
||||
|
||||
assert "stats_aggregation" not in cron_job_ids
|
||||
assert "audit_cleanup" not in cron_job_ids
|
||||
assert "candidate_cleanup" not in cron_job_ids
|
||||
assert "db_maintenance" not in cron_job_ids
|
||||
assert "stats_hourly_aggregation" not in cron_job_ids
|
||||
assert "pool_monitor" not in interval_job_ids
|
||||
assert "http_client_idle_cleanup" not in interval_job_ids
|
||||
assert "usage_cleanup" not in cron_job_ids
|
||||
assert "wallet_daily_usage_aggregation" not in cron_job_ids
|
||||
assert scheduler.CHECKIN_JOB_ID not in cron_job_ids
|
||||
assert "antigravity_ua_refresh" not in interval_job_ids
|
||||
assert "gemini_file_mapping_cleanup" not in interval_job_ids
|
||||
assert "pending_cleanup" not in interval_job_ids
|
||||
assert "stats_aggregation" not in scheduler._registered_job_ids
|
||||
assert "audit_cleanup" not in scheduler._registered_job_ids
|
||||
assert "candidate_cleanup" not in scheduler._registered_job_ids
|
||||
assert "db_maintenance" not in scheduler._registered_job_ids
|
||||
assert "stats_hourly_aggregation" not in scheduler._registered_job_ids
|
||||
assert "pool_monitor" not in scheduler._registered_job_ids
|
||||
assert "http_client_idle_cleanup" not in scheduler._registered_job_ids
|
||||
assert "usage_cleanup" not in scheduler._registered_job_ids
|
||||
assert "wallet_daily_usage_aggregation" not in scheduler._registered_job_ids
|
||||
assert scheduler.CHECKIN_JOB_ID not in scheduler._registered_job_ids
|
||||
assert "antigravity_ua_refresh" not in scheduler._registered_job_ids
|
||||
assert "gemini_file_mapping_cleanup" not in scheduler._registered_job_ids
|
||||
assert "pending_cleanup" not in scheduler._registered_job_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_start_registers_python_owned_maintenance_jobs_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", False)
|
||||
monkeypatch.setattr(config, "audit_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "db_maintenance_python_enabled", True)
|
||||
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "pool_monitor_python_enabled", True)
|
||||
monkeypatch.setattr(config, "provider_checkin_python_enabled", True)
|
||||
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "stats_aggregation_python_enabled", True)
|
||||
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", True)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", True)
|
||||
|
||||
scheduler = MaintenanceScheduler()
|
||||
cron_job_ids: list[str] = []
|
||||
interval_job_ids: list[str] = []
|
||||
|
||||
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
|
||||
monkeypatch.setattr(
|
||||
maintenance_scheduler_module,
|
||||
"get_scheduler",
|
||||
lambda: SimpleNamespace(
|
||||
add_cron_job=lambda *, job_id, **kwargs: cron_job_ids.append(job_id),
|
||||
add_interval_job=lambda *, job_id, **kwargs: interval_job_ids.append(job_id),
|
||||
),
|
||||
)
|
||||
|
||||
await scheduler.start()
|
||||
|
||||
assert "stats_aggregation" in cron_job_ids
|
||||
assert "audit_cleanup" in cron_job_ids
|
||||
assert "candidate_cleanup" in cron_job_ids
|
||||
assert "db_maintenance" in cron_job_ids
|
||||
assert "stats_hourly_aggregation" in cron_job_ids
|
||||
assert "pool_monitor" in interval_job_ids
|
||||
assert "http_client_idle_cleanup" in interval_job_ids
|
||||
assert "usage_cleanup" in cron_job_ids
|
||||
assert "wallet_daily_usage_aggregation" in cron_job_ids
|
||||
assert scheduler.CHECKIN_JOB_ID in cron_job_ids
|
||||
assert "antigravity_ua_refresh" in interval_job_ids
|
||||
assert "gemini_file_mapping_cleanup" in interval_job_ids
|
||||
assert "pending_cleanup" in interval_job_ids
|
||||
assert "stats_aggregation" in scheduler._registered_job_ids
|
||||
assert "audit_cleanup" in scheduler._registered_job_ids
|
||||
assert "candidate_cleanup" in scheduler._registered_job_ids
|
||||
assert "db_maintenance" in scheduler._registered_job_ids
|
||||
assert "stats_hourly_aggregation" in scheduler._registered_job_ids
|
||||
assert "pool_monitor" in scheduler._registered_job_ids
|
||||
assert "http_client_idle_cleanup" in scheduler._registered_job_ids
|
||||
assert "usage_cleanup" in scheduler._registered_job_ids
|
||||
assert "wallet_daily_usage_aggregation" in scheduler._registered_job_ids
|
||||
assert scheduler.CHECKIN_JOB_ID in scheduler._registered_job_ids
|
||||
assert "antigravity_ua_refresh" in scheduler._registered_job_ids
|
||||
assert "gemini_file_mapping_cleanup" in scheduler._registered_job_ids
|
||||
assert "pending_cleanup" in scheduler._registered_job_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_startup_tasks_skip_python_pending_cleanup_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scheduler = MaintenanceScheduler()
|
||||
refresh_user_agent = AsyncMock()
|
||||
perform_pending_cleanup = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
|
||||
monkeypatch.setattr(maintenance_scheduler_module.asyncio, "sleep", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.adapters.antigravity.client.refresh_user_agent",
|
||||
refresh_user_agent,
|
||||
)
|
||||
monkeypatch.setattr(scheduler, "_perform_pending_cleanup", perform_pending_cleanup)
|
||||
|
||||
await scheduler._run_startup_tasks()
|
||||
|
||||
refresh_user_agent.assert_not_awaited()
|
||||
perform_pending_cleanup.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_startup_tasks_run_python_pending_cleanup_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scheduler = MaintenanceScheduler()
|
||||
refresh_user_agent = AsyncMock()
|
||||
perform_pending_cleanup = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", True)
|
||||
monkeypatch.setattr(maintenance_scheduler_module.asyncio, "sleep", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.adapters.antigravity.client.refresh_user_agent",
|
||||
refresh_user_agent,
|
||||
)
|
||||
monkeypatch.setattr(scheduler, "_perform_pending_cleanup", perform_pending_cleanup)
|
||||
|
||||
await scheduler._run_startup_tasks()
|
||||
|
||||
refresh_user_agent.assert_awaited_once()
|
||||
perform_pending_cleanup.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_skips_python_maintenance_scheduler_when_unused(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, _task_poller, _model_fetch_scheduler = (
|
||||
_patch_background_services_dependencies(monkeypatch)
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "audit_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
|
||||
monkeypatch.setattr(config, "db_maintenance_python_enabled", False)
|
||||
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "pool_monitor_python_enabled", False)
|
||||
monkeypatch.setattr(config, "provider_checkin_python_enabled", False)
|
||||
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "stats_aggregation_python_enabled", False)
|
||||
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", False)
|
||||
monkeypatch.setattr(config, "usage_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "maintenance_scheduler" not in coordinator.acquire_calls
|
||||
assert state.maintenance_scheduler is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_starts_python_maintenance_scheduler_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, _task_poller, _model_fetch_scheduler = (
|
||||
_patch_background_services_dependencies(
|
||||
monkeypatch,
|
||||
acquire_results={"maintenance_scheduler": True},
|
||||
)
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", True)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "maintenance_scheduler" in coordinator.acquire_calls
|
||||
assert "maintenance_scheduler" in coordinator.registered_callbacks
|
||||
assert state.maintenance_scheduler is not None
|
||||
|
||||
|
||||
def _patch_core_infrastructure_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(config, "log_startup_warnings", lambda: None)
|
||||
monkeypatch.setattr(main_module, "init_db", lambda: None)
|
||||
monkeypatch.setattr(main_module, "initialize_providers", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"src.clients.redis_client.get_redis_client",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.rate_limit.concurrency_manager.get_concurrency_manager",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.rate_limit.user_rpm_limiter.get_user_rpm_limiter",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr("src.core.batch_committer.init_batch_committer", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_keys.codex_quota_sync_dispatcher.init_codex_quota_sync_dispatcher",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
def _patch_python_host_shutdown_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_keys.codex_quota_sync_dispatcher.shutdown_codex_quota_sync_dispatcher",
|
||||
AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr("src.core.batch_committer.shutdown_batch_committer", AsyncMock())
|
||||
monkeypatch.setattr("src.clients.redis_client.close_redis_client", AsyncMock())
|
||||
monkeypatch.setattr(main_module, "close_http_clients", AsyncMock())
|
||||
|
||||
|
||||
class _FakeStartupTaskCoordinator:
|
||||
def __init__(self, acquire_results: dict[str, bool] | None = None) -> None:
|
||||
self.acquire_results = acquire_results or {}
|
||||
self.acquire_calls: list[str] = []
|
||||
self.registered_callbacks: list[str] = []
|
||||
self.released: list[str] = []
|
||||
|
||||
async def acquire(self, name: str) -> bool:
|
||||
self.acquire_calls.append(name)
|
||||
return self.acquire_results.get(name, False)
|
||||
|
||||
async def release(self, name: str) -> None:
|
||||
self.released.append(name)
|
||||
|
||||
def register_lock_lost_callback(self, name: str, _callback: Any) -> None:
|
||||
self.registered_callbacks.append(name)
|
||||
|
||||
|
||||
def _patch_background_services_dependencies(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
acquire_results: dict[str, bool] | None = None,
|
||||
) -> tuple[_FakeStartupTaskCoordinator, Any, Any, Any]:
|
||||
coordinator = _FakeStartupTaskCoordinator(acquire_results)
|
||||
quota_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
maintenance_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
model_fetch_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
pool_quota_probe_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
task_poller = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
task_scheduler = SimpleNamespace(start=MagicMock())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.utils.task_coordinator.StartupTaskCoordinator",
|
||||
lambda _redis: coordinator,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.usage.quota_scheduler.get_quota_scheduler",
|
||||
lambda: quota_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.system.maintenance_scheduler.get_maintenance_scheduler",
|
||||
lambda: maintenance_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.model.fetch_scheduler.get_model_fetch_scheduler",
|
||||
lambda: model_fetch_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_keys.pool_quota_probe_scheduler.get_pool_quota_probe_scheduler",
|
||||
lambda: pool_quota_probe_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.task.polling.task_poller.get_task_poller",
|
||||
lambda: task_poller,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.system.scheduler.get_scheduler",
|
||||
lambda: task_scheduler,
|
||||
)
|
||||
|
||||
return coordinator, quota_scheduler, task_poller, model_fetch_scheduler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_core_infrastructure_skips_python_usage_consumer_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_core_infrastructure_dependencies(monkeypatch)
|
||||
started = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "require_redis", False)
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", False)
|
||||
monkeypatch.setattr("src.services.usage.consumer_streams.start_usage_queue_consumer", started)
|
||||
|
||||
await main_module._initialize_core_infrastructure(main_module.LifecycleState())
|
||||
|
||||
started.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_core_infrastructure_starts_python_usage_consumer_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_core_infrastructure_dependencies(monkeypatch)
|
||||
started = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "require_redis", False)
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", True)
|
||||
monkeypatch.setattr("src.services.usage.consumer_streams.start_usage_queue_consumer", started)
|
||||
|
||||
await main_module._initialize_core_infrastructure(main_module.LifecycleState())
|
||||
|
||||
started.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_python_host_shutdown_skips_python_usage_consumer_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_python_host_shutdown_dependencies(monkeypatch)
|
||||
stopped = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", False)
|
||||
monkeypatch.setattr("src.services.usage.consumer_streams.stop_usage_queue_consumer", stopped)
|
||||
|
||||
await main_module._run_python_host_shutdown(main_module.LifecycleState())
|
||||
|
||||
stopped.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_python_host_shutdown_stops_python_usage_consumer_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_python_host_shutdown_dependencies(monkeypatch)
|
||||
stopped = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", True)
|
||||
monkeypatch.setattr("src.services.usage.consumer_streams.stop_usage_queue_consumer", stopped)
|
||||
|
||||
await main_module._run_python_host_shutdown(main_module.LifecycleState())
|
||||
|
||||
stopped.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_skips_python_video_poller_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
|
||||
monkeypatch
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "task_poller:video" not in coordinator.acquire_calls
|
||||
task_poller.start.assert_not_awaited()
|
||||
assert state.task_poller is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_starts_python_video_poller_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
|
||||
monkeypatch,
|
||||
acquire_results={"task_poller:video": True},
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", True)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "task_poller:video" in coordinator.acquire_calls
|
||||
assert "task_poller:video" in coordinator.registered_callbacks
|
||||
task_poller.start.assert_awaited_once()
|
||||
assert state.task_poller is task_poller
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_skips_python_quota_scheduler_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, quota_scheduler, _task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
|
||||
monkeypatch
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "quota_scheduler" not in coordinator.acquire_calls
|
||||
quota_scheduler.start.assert_not_awaited()
|
||||
assert state.quota_scheduler is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_starts_python_quota_scheduler_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, quota_scheduler, _task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
|
||||
monkeypatch,
|
||||
acquire_results={"quota_scheduler": True},
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", True)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "quota_scheduler" in coordinator.acquire_calls
|
||||
assert "quota_scheduler" in coordinator.registered_callbacks
|
||||
quota_scheduler.start.assert_awaited_once()
|
||||
assert state.quota_scheduler is quota_scheduler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_skips_python_model_fetch_scheduler_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, _task_poller, model_fetch_scheduler = (
|
||||
_patch_background_services_dependencies(monkeypatch)
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "model_fetch_scheduler" not in coordinator.acquire_calls
|
||||
model_fetch_scheduler.start.assert_not_awaited()
|
||||
assert state.model_fetch_scheduler is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_starts_python_model_fetch_scheduler_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, _task_poller, model_fetch_scheduler = (
|
||||
_patch_background_services_dependencies(
|
||||
monkeypatch,
|
||||
acquire_results={"model_fetch_scheduler": True},
|
||||
)
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", True)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "model_fetch_scheduler" in coordinator.acquire_calls
|
||||
assert "model_fetch_scheduler" in coordinator.registered_callbacks
|
||||
model_fetch_scheduler.start.assert_awaited_once()
|
||||
assert state.model_fetch_scheduler is model_fetch_scheduler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_service_on_lock_lost_keeps_state_when_stop_fails() -> None:
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
@@ -191,7 +191,7 @@ async def test_fetch_models_from_endpoints_uses_rust_for_gemini(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_from_endpoints_falls_back_to_python_when_rust_unavailable(
|
||||
async def test_fetch_models_from_endpoints_returns_rust_only_error_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(fetcher_mod.config, "executor_backend", "rust")
|
||||
@@ -224,7 +224,7 @@ async def test_fetch_models_from_endpoints_falls_back_to_python_when_rust_unavai
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert errors == []
|
||||
assert models == [{"id": "fallback-model", "api_format": "openai:chat"}]
|
||||
python_fetch.assert_awaited_once()
|
||||
assert ok is False
|
||||
assert models == []
|
||||
assert errors == ["openai:chat: rust executor unavailable"]
|
||||
python_fetch.assert_not_awaited()
|
||||
|
||||
@@ -23,7 +23,7 @@ class _TimeoutAsyncClient:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vertex_auth_timeout_error_includes_readable_message(
|
||||
async def test_vertex_auth_is_rust_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = VertexAuthService(
|
||||
@@ -39,5 +39,5 @@ async def test_vertex_auth_timeout_error_includes_readable_message(
|
||||
monkeypatch.setattr(service, "_create_jwt", lambda: "signed-jwt")
|
||||
monkeypatch.setattr("src.core.vertex_auth.httpx.AsyncClient", _TimeoutAsyncClient)
|
||||
|
||||
with pytest.raises(VertexAuthError, match=r"request timed out after 30s"):
|
||||
with pytest.raises(VertexAuthError, match=r"仅支持 Rust executor"):
|
||||
await service.get_access_token(httpx_client_kwargs={"timeout": 30})
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import src.services.task.video.cancel as cancel_mod
|
||||
@@ -171,3 +172,122 @@ async def test_video_cancel_service_uses_rust_for_gemini_cancel(
|
||||
plan = execute_sync.await_args.args[0]
|
||||
assert plan.method == "POST"
|
||||
assert plan.body.json_body == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_cancel_service_returns_503_when_rust_backend_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep-1",
|
||||
provider_id="prov-1",
|
||||
api_family="openai",
|
||||
endpoint_kind="video",
|
||||
base_url="https://api.openai.com",
|
||||
)
|
||||
key = SimpleNamespace(id="key-1", api_key="encrypted")
|
||||
db = _FakeDB(endpoint, key)
|
||||
service = VideoTaskCancelService(db)
|
||||
task = SimpleNamespace(
|
||||
id="task-1",
|
||||
status=VideoStatus.PROCESSING.value,
|
||||
external_task_id="ext-1",
|
||||
endpoint_id="ep-1",
|
||||
key_id="key-1",
|
||||
request_id="req-1",
|
||||
model="sora-2",
|
||||
completed_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cancel_mod.config, "executor_backend", "python")
|
||||
monkeypatch.setattr(
|
||||
"src.core.crypto.crypto_service.decrypt",
|
||||
lambda value: "upstream-key",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.core.api_format.build_upstream_headers_for_endpoint",
|
||||
lambda *args, **kwargs: {"authorization": "Bearer upstream-key"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.transport.build_provider_url",
|
||||
lambda endpoint, is_stream=False, key=None: "https://api.openai.com/v1/videos",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_default_client_async",
|
||||
AsyncMock(side_effect=AssertionError("python fallback should not run")),
|
||||
)
|
||||
|
||||
response = await service.cancel_task(
|
||||
task=task,
|
||||
task_id="task-1",
|
||||
original_headers={"x-test": "1"},
|
||||
)
|
||||
|
||||
assert isinstance(response, httpx.Response)
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": {"message": "Video 取消仅支持 Rust executor"}}
|
||||
assert db.committed is False
|
||||
assert task.status == VideoStatus.PROCESSING.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_cancel_service_returns_503_when_rust_executor_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep-1",
|
||||
provider_id="prov-1",
|
||||
api_family="gemini",
|
||||
endpoint_kind="video",
|
||||
base_url="https://generativelanguage.googleapis.com",
|
||||
)
|
||||
key = SimpleNamespace(id="key-1", api_key="encrypted")
|
||||
db = _FakeDB(endpoint, key)
|
||||
service = VideoTaskCancelService(db)
|
||||
task = SimpleNamespace(
|
||||
id="task-1",
|
||||
status=VideoStatus.PROCESSING.value,
|
||||
external_task_id="operations/ext-1",
|
||||
endpoint_id="ep-1",
|
||||
key_id="key-1",
|
||||
request_id="req-1",
|
||||
model="veo-3",
|
||||
completed_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cancel_mod.config, "executor_backend", "rust")
|
||||
monkeypatch.setattr(
|
||||
rust_client_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
AsyncMock(side_effect=rust_client_mod.RustExecutorClientError("executor down")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.core.crypto.crypto_service.decrypt",
|
||||
lambda value: "upstream-key",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.core.api_format.build_upstream_headers_for_endpoint",
|
||||
lambda *args, **kwargs: {"x-goog-api-key": "upstream-key"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.auth.get_provider_auth",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_default_client_async",
|
||||
AsyncMock(side_effect=AssertionError("python fallback should not run")),
|
||||
)
|
||||
|
||||
response = await service.cancel_task(
|
||||
task=task,
|
||||
task_id="task-1",
|
||||
original_headers={"x-test": "1"},
|
||||
)
|
||||
|
||||
assert isinstance(response, httpx.Response)
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": {"message": "执行器暂时不可用,请稍后重试"}}
|
||||
assert db.committed is False
|
||||
assert task.status == VideoStatus.PROCESSING.value
|
||||
|
||||
@@ -25,30 +25,26 @@ async def test_poll_task_status_routes_gemini_video_to_gemini(
|
||||
external_task_id="operations/123",
|
||||
),
|
||||
)
|
||||
endpoint = SimpleNamespace(id="e1", base_url="https://example.com", api_format="gemini:video")
|
||||
key = SimpleNamespace(id="k1", api_key="enc")
|
||||
|
||||
monkeypatch.setattr(adapter, "_get_endpoint", lambda _db, _id: endpoint)
|
||||
monkeypatch.setattr(adapter, "_get_key", lambda _db, _id: key)
|
||||
monkeypatch.setattr(
|
||||
"src.services.task.video.poller_adapter.crypto_service.decrypt", lambda _v: "decrypted"
|
||||
prepared_ctx = VideoPollContext(
|
||||
task_id="task-1",
|
||||
external_task_id="operations/123",
|
||||
provider_api_format="gemini:video",
|
||||
base_url="https://example.com",
|
||||
upstream_key="decrypted",
|
||||
headers={"authorization": "Bearer x"},
|
||||
poll_count=0,
|
||||
retry_count=0,
|
||||
poll_interval_seconds=15,
|
||||
max_poll_count=10,
|
||||
current_status=VideoStatus.PROCESSING.value,
|
||||
)
|
||||
|
||||
auth_info = SimpleNamespace(auth_header="authorization", auth_value="Bearer x")
|
||||
monkeypatch.setattr(
|
||||
"src.services.task.video.poller_adapter.get_provider_auth",
|
||||
AsyncMock(return_value=auth_info),
|
||||
)
|
||||
|
||||
poll_gemini = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
|
||||
poll_openai = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
|
||||
monkeypatch.setattr(adapter, "_poll_gemini", poll_gemini)
|
||||
monkeypatch.setattr(adapter, "_poll_openai", poll_openai)
|
||||
poll_http = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
|
||||
monkeypatch.setattr(adapter, "prepare_poll_context", AsyncMock(return_value=prepared_ctx))
|
||||
monkeypatch.setattr(adapter, "poll_task_http", poll_http)
|
||||
|
||||
result = await adapter._poll_task_status(MagicMock(), task)
|
||||
assert result.status == VideoStatus.PROCESSING
|
||||
assert poll_gemini.await_count == 1
|
||||
assert poll_openai.await_count == 0
|
||||
poll_http.assert_awaited_once_with(prepared_ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -129,8 +125,6 @@ async def test_video_poller_try_rust_payload_passes_proxy_snapshot(
|
||||
async def test_video_poller_openai_poll_prefers_rust_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.task.video import poller_adapter as mod
|
||||
|
||||
adapter = VideoTaskPollerAdapter()
|
||||
|
||||
rust_poll = AsyncMock(return_value={"id": "vid_1", "status": "processing"})
|
||||
@@ -140,9 +134,6 @@ async def test_video_poller_openai_poll_prefers_rust_payload(
|
||||
normalizer = MagicMock(return_value=normalized)
|
||||
monkeypatch.setattr(adapter._openai_normalizer, "video_poll_to_internal", normalizer)
|
||||
|
||||
get_upstream_client = AsyncMock(side_effect=AssertionError("python upstream client should not be used"))
|
||||
monkeypatch.setattr(mod.HTTPClientPool, "get_upstream_client", get_upstream_client)
|
||||
|
||||
ctx = VideoPollContext(
|
||||
task_id="task-1",
|
||||
external_task_id="vid_1",
|
||||
@@ -161,34 +152,17 @@ async def test_video_poller_openai_poll_prefers_rust_payload(
|
||||
|
||||
assert result is normalized
|
||||
normalizer.assert_called_once_with({"id": "vid_1", "status": "processing"})
|
||||
get_upstream_client.assert_not_awaited()
|
||||
rust_poll.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_poller_openai_poll_fallback_uses_transport_aware_client(
|
||||
async def test_video_poller_openai_poll_requires_rust_executor_when_payload_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.task.video import poller_adapter as mod
|
||||
|
||||
adapter = VideoTaskPollerAdapter()
|
||||
|
||||
monkeypatch.setattr(adapter, "_try_rust_poll_payload", AsyncMock(return_value=None))
|
||||
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = {"id": "vid_2", "status": "processing"}
|
||||
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(return_value=response)
|
||||
|
||||
get_upstream_client = AsyncMock(return_value=client)
|
||||
monkeypatch.setattr(mod.HTTPClientPool, "get_upstream_client", get_upstream_client)
|
||||
|
||||
normalized = InternalVideoPollResult(status=VideoStatus.PROCESSING, progress_percent=7)
|
||||
normalizer = MagicMock(return_value=normalized)
|
||||
monkeypatch.setattr(adapter._openai_normalizer, "video_poll_to_internal", normalizer)
|
||||
|
||||
ctx = VideoPollContext(
|
||||
task_id="task-2",
|
||||
external_task_id="vid_2",
|
||||
@@ -205,14 +179,7 @@ async def test_video_poller_openai_poll_fallback_uses_transport_aware_client(
|
||||
delegate_config={"node_id": "node-1", "tunnel": True},
|
||||
)
|
||||
|
||||
result = await adapter._poll_openai_with_context(ctx)
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await adapter._poll_openai_with_context(ctx)
|
||||
|
||||
assert result is normalized
|
||||
get_upstream_client.assert_awaited_once_with(
|
||||
{"node_id": "node-1", "tunnel": True},
|
||||
proxy_config={"enabled": True, "url": "http://proxy.test:8080"},
|
||||
)
|
||||
client.get.assert_awaited_once_with(
|
||||
"https://api.openai.com/v1/videos/vid_2",
|
||||
headers={"authorization": "Bearer test"},
|
||||
)
|
||||
assert "Rust executor" in str(exc_info.value)
|
||||
|
||||
@@ -79,3 +79,14 @@ def test_import_new_standalone_key_null_rate_limit_keeps_inherit_semantics() ->
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_import_access_list_accepts_stringified_json_array() -> None:
|
||||
assert AdminImportUsersAdapter._normalize_imported_access_list('["openai", " gemini "]') == [
|
||||
"openai",
|
||||
"gemini",
|
||||
]
|
||||
|
||||
|
||||
def test_import_access_list_wraps_single_string() -> None:
|
||||
assert AdminImportUsersAdapter._normalize_imported_access_list("openai") == ["openai"]
|
||||
|
||||
@@ -58,6 +58,8 @@ def _build_context(raw_body: bytes, headers: dict[str, str] | None = None) -> Ap
|
||||
api_key=None,
|
||||
request_id="req_test",
|
||||
start_time=0.0,
|
||||
request_method="POST",
|
||||
request_path="/v1/messages",
|
||||
client_ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
original_headers=headers or {},
|
||||
@@ -67,6 +69,74 @@ def _build_context(raw_body: bytes, headers: dict[str, str] | None = None) -> Ap
|
||||
|
||||
|
||||
class TestApiRequestContextEnsureJsonBody:
|
||||
def test_build_prefers_request_state_request_id_over_trace_header(self) -> None:
|
||||
request = _build_request(headers={"x-trace-id": "trace-frontdoor-123"})
|
||||
request.state.request_id = "state-rid-001"
|
||||
|
||||
context = ApiRequestContext.build(
|
||||
request=request,
|
||||
db=None, # type: ignore[arg-type]
|
||||
user=None,
|
||||
api_key=None,
|
||||
raw_body=b"{}",
|
||||
)
|
||||
|
||||
assert context.request_id == "state-rid-001"
|
||||
assert request.state.request_id == "state-rid-001"
|
||||
|
||||
def test_build_prefers_trace_header_for_request_id(self) -> None:
|
||||
request = _build_request(headers={"x-trace-id": "trace-frontdoor-123"})
|
||||
|
||||
context = ApiRequestContext.build(
|
||||
request=request,
|
||||
db=None, # type: ignore[arg-type]
|
||||
user=None,
|
||||
api_key=None,
|
||||
raw_body=b"{}",
|
||||
)
|
||||
|
||||
assert context.request_id == "trace-frontdoor-123"
|
||||
assert request.state.request_id == "trace-frontdoor-123"
|
||||
|
||||
def test_build_snapshots_request_method_path_and_path_params(self) -> None:
|
||||
request = _build_request(headers={"x-trace-id": "trace-frontdoor-123"})
|
||||
request.scope["method"] = "GET"
|
||||
request.scope["path"] = "/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
request.scope["raw_path"] = b"/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
request.scope["path_params"] = {"model": "gemini-2.5-pro"}
|
||||
|
||||
context = ApiRequestContext.build(
|
||||
request=request,
|
||||
db=None, # type: ignore[arg-type]
|
||||
user=None,
|
||||
api_key=None,
|
||||
raw_body=b"{}",
|
||||
)
|
||||
|
||||
assert context.request_method == "GET"
|
||||
assert context.request_path == "/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
assert context.path_params == {"model": "gemini-2.5-pro"}
|
||||
|
||||
def test_build_snapshots_request_runtime_state(self) -> None:
|
||||
request = _build_request(headers={"x-trace-id": "trace-frontdoor-123"})
|
||||
request.state.prefetched_balance_remaining = "12.5"
|
||||
request.state.gateway_execution_path = "public_proxy_after_executor_miss"
|
||||
request.state.rate_limit_scope = "user"
|
||||
request.state.tx_committed_by_route = True
|
||||
|
||||
context = ApiRequestContext.build(
|
||||
request=request,
|
||||
db=None, # type: ignore[arg-type]
|
||||
user=None,
|
||||
api_key=None,
|
||||
raw_body=b"{}",
|
||||
)
|
||||
|
||||
assert context.prefetched_balance_remaining == 12.5
|
||||
assert context.gateway_execution_path == "public_proxy_after_executor_miss"
|
||||
assert context.rate_limit_scope == "user"
|
||||
assert context.tx_committed_by_route is True
|
||||
|
||||
def test_decompresses_gzip_body(self) -> None:
|
||||
payload = {"message": "hello", "count": 2}
|
||||
raw_body = gzip.compress(json.dumps(payload).encode("utf-8"))
|
||||
@@ -88,6 +158,7 @@ class TestApiRequestContextEnsureJsonBody:
|
||||
def test_build_records_client_encoding_preferences(self) -> None:
|
||||
request = _build_request(
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"content-encoding": "gzip",
|
||||
"accept-encoding": "gzip, deflate",
|
||||
}
|
||||
@@ -102,6 +173,31 @@ class TestApiRequestContextEnsureJsonBody:
|
||||
|
||||
assert context.client_content_encoding == "gzip"
|
||||
assert context.client_accept_encoding == "gzip, deflate"
|
||||
assert context.request_content_type == "application/json"
|
||||
|
||||
def test_build_records_perf_only_when_payload_not_empty(self) -> None:
|
||||
request = _build_request(headers={"x-trace-id": "trace-frontdoor-123"})
|
||||
request.state.perf_metrics = {}
|
||||
context = ApiRequestContext.build(
|
||||
request=request,
|
||||
db=None, # type: ignore[arg-type]
|
||||
user=None,
|
||||
api_key=None,
|
||||
raw_body=b"{}",
|
||||
)
|
||||
assert "perf" not in context.extra
|
||||
|
||||
request_with_perf = _build_request(headers={"x-trace-id": "trace-frontdoor-456"})
|
||||
request_with_perf.state.perf_metrics = {"pipeline": {"auth_ms": 3}}
|
||||
context_with_perf = ApiRequestContext.build(
|
||||
request=request_with_perf,
|
||||
db=None, # type: ignore[arg-type]
|
||||
user=None,
|
||||
api_key=None,
|
||||
raw_body=b"{}",
|
||||
)
|
||||
assert context_with_perf.extra["perf"] == {"pipeline": {"auth_ms": 3}}
|
||||
assert context_with_perf.perf_metrics == {"pipeline": {"auth_ms": 3}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_json_body_async_loads_body_lazily(self) -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.services.request.executor_plan import (
|
||||
ExecutionPlanTimeouts,
|
||||
ExecutionProxySnapshot,
|
||||
build_execution_plan_body,
|
||||
should_bypass_remote_executor_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -342,3 +343,43 @@ def test_prepared_execution_plan_remote_eligible_allows_empty_get_body() -> None
|
||||
)
|
||||
|
||||
assert prepared.remote_eligible is True
|
||||
|
||||
|
||||
def test_prepared_execution_plan_remote_eligible_rejects_codex_cli_transport() -> None:
|
||||
prepared = PreparedExecutionPlan(
|
||||
contract=ExecutionPlan(
|
||||
request_id="req-1",
|
||||
candidate_id=None,
|
||||
provider_name="codex",
|
||||
provider_id="prov-1",
|
||||
endpoint_id="ep-1",
|
||||
key_id="key-1",
|
||||
method="POST",
|
||||
url="https://chatgpt.com/backend-api/codex/responses",
|
||||
headers={"content-type": "application/json"},
|
||||
body=ExecutionPlanBody(json_body={"model": "gpt-5.4", "input": []}),
|
||||
stream=True,
|
||||
provider_api_format="openai:cli",
|
||||
client_api_format="openai:cli",
|
||||
model_name="gpt-5.4",
|
||||
),
|
||||
payload={"model": "gpt-5.4", "input": []},
|
||||
headers={"content-type": "application/json"},
|
||||
upstream_is_stream=True,
|
||||
needs_conversion=False,
|
||||
provider_type="codex",
|
||||
request_timeout=30.0,
|
||||
)
|
||||
|
||||
assert prepared.remote_eligible is False
|
||||
|
||||
|
||||
def test_should_bypass_remote_executor_url_accepts_backendapi_codex_variant() -> None:
|
||||
assert (
|
||||
should_bypass_remote_executor_url(
|
||||
"https://chatgpt.com/backendapi/codex/responses",
|
||||
provider_api_format="openai:cli",
|
||||
client_api_format="openai:cli",
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.api.admin import provider_oauth as module
|
||||
|
||||
|
||||
class _SingleKeyQuery:
|
||||
def __init__(self, key: object) -> None:
|
||||
self._key = key
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_SingleKeyQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object:
|
||||
return self._key
|
||||
|
||||
|
||||
class _SingleKeyDB:
|
||||
def __init__(self, key: object) -> None:
|
||||
self._key = key
|
||||
|
||||
def query(self, _model: object) -> _SingleKeyQuery:
|
||||
return _SingleKeyQuery(self._key)
|
||||
|
||||
|
||||
class _FakeDBContext:
|
||||
def __init__(self, db: _SingleKeyDB) -> None:
|
||||
self._db = db
|
||||
|
||||
def __enter__(self) -> _SingleKeyDB:
|
||||
return self._db
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
_ = exc_type, exc, tb
|
||||
return False
|
||||
|
||||
|
||||
def test_extract_oauth_refresh_error_reason_for_reused_refresh_token() -> None:
|
||||
response = httpx.Response(
|
||||
400,
|
||||
@@ -54,3 +87,50 @@ def test_merge_refresh_failure_reason_keeps_account_block_and_appends_refresh_fa
|
||||
"[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n"
|
||||
"[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||
)
|
||||
|
||||
|
||||
def test_merge_refresh_failure_reason_keeps_oauth_expired_sticky() -> None:
|
||||
current_reason = "[OAUTH_EXPIRED] Token 已过期且续期失败"
|
||||
refresh_reason = "[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||
|
||||
assert module._merge_refresh_failure_reason(current_reason, refresh_reason) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial_reason", "should_clear"),
|
||||
[
|
||||
("[REFRESH_FAILED] Token 续期失败 (401): refresh_token_reused", True),
|
||||
("[ACCOUNT_BLOCK] Google requires verification", False),
|
||||
],
|
||||
)
|
||||
def test_store_refreshed_oauth_sync_only_clears_recoverable_invalid_markers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
initial_reason: str,
|
||||
should_clear: bool,
|
||||
) -> None:
|
||||
key = SimpleNamespace(
|
||||
id="key-1",
|
||||
api_key="old-api",
|
||||
auth_config="old-config",
|
||||
oauth_invalid_at="old-invalid-at",
|
||||
oauth_invalid_reason=initial_reason,
|
||||
)
|
||||
db = _SingleKeyDB(key)
|
||||
|
||||
monkeypatch.setattr(module, "get_db_context", lambda: _FakeDBContext(db))
|
||||
monkeypatch.setattr(module.crypto_service, "encrypt", lambda value: f"enc:{value}")
|
||||
|
||||
module._store_refreshed_oauth_sync(
|
||||
"key-1",
|
||||
"new-token",
|
||||
{"refresh_token": "rt-2"},
|
||||
)
|
||||
|
||||
assert key.api_key == "enc:new-token"
|
||||
assert key.auth_config == 'enc:{"refresh_token": "rt-2"}'
|
||||
if should_clear:
|
||||
assert key.oauth_invalid_at is None
|
||||
assert key.oauth_invalid_reason is None
|
||||
else:
|
||||
assert key.oauth_invalid_at == "old-invalid-at"
|
||||
assert key.oauth_invalid_reason == initial_reason
|
||||
|
||||
110
tests/unit/test_request_utils.py
Normal file
110
tests/unit/test_request_utils.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from src.utils.request_utils import (
|
||||
get_request_id,
|
||||
get_request_identity_metadata,
|
||||
get_request_metadata,
|
||||
update_request_state,
|
||||
)
|
||||
|
||||
|
||||
def _build_request(headers: dict[str, str] | None = None) -> Request:
|
||||
header_items = [
|
||||
(str(key).encode("latin-1"), str(value).encode("latin-1"))
|
||||
for key, value in (headers or {}).items()
|
||||
]
|
||||
scope = {
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": "/health",
|
||||
"raw_path": b"/health",
|
||||
"query_string": b"",
|
||||
"headers": header_items,
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
|
||||
async def receive() -> dict[str, object]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
return Request(scope, receive)
|
||||
|
||||
|
||||
def test_get_request_id_prefers_request_state() -> None:
|
||||
request = _build_request(headers={"x-trace-id": "trace-header-123"})
|
||||
request.state.request_id = "req-state-123"
|
||||
|
||||
assert get_request_id(request) == "req-state-123"
|
||||
|
||||
|
||||
def test_get_request_id_falls_back_to_trace_header() -> None:
|
||||
request = _build_request(headers={"x-trace-id": "trace-header-123"})
|
||||
|
||||
assert get_request_id(request) == "trace-header-123"
|
||||
|
||||
|
||||
def test_get_request_id_returns_none_without_state_or_trace_header() -> None:
|
||||
request = _build_request()
|
||||
|
||||
assert get_request_id(request) is None
|
||||
|
||||
|
||||
def test_update_request_state_sets_selected_fields() -> None:
|
||||
request = _build_request()
|
||||
|
||||
update_request_state(
|
||||
request,
|
||||
request_id="req-123",
|
||||
user_id="user-123",
|
||||
api_key_id="key-123",
|
||||
gateway_execution_path="executor_sync",
|
||||
rate_limit_scope="user",
|
||||
)
|
||||
|
||||
assert request.state.request_id == "req-123"
|
||||
assert request.state.user_id == "user-123"
|
||||
assert request.state.api_key_id == "key-123"
|
||||
assert request.state.gateway_execution_path == "executor_sync"
|
||||
assert request.state.rate_limit_scope == "user"
|
||||
|
||||
|
||||
def test_get_request_identity_metadata_reads_request_id_client_ip_and_user_agent() -> None:
|
||||
request = _build_request(
|
||||
headers={
|
||||
"x-trace-id": "trace-header-abc",
|
||||
"x-real-ip": "203.0.113.7",
|
||||
"user-agent": "pytest-agent",
|
||||
}
|
||||
)
|
||||
|
||||
meta = get_request_identity_metadata(request)
|
||||
|
||||
assert meta.request_id == "trace-header-abc"
|
||||
assert meta.client_ip == "203.0.113.7"
|
||||
assert meta.user_agent == "pytest-agent"
|
||||
|
||||
|
||||
def test_get_request_metadata_reuses_identity_fields() -> None:
|
||||
request = _build_request(
|
||||
headers={
|
||||
"x-trace-id": "trace-xyz",
|
||||
"x-real-ip": "198.51.100.23",
|
||||
"user-agent": "pytest-meta-agent",
|
||||
"content-type": "application/json",
|
||||
"content-length": "42",
|
||||
}
|
||||
)
|
||||
|
||||
metadata = get_request_metadata(request)
|
||||
|
||||
assert metadata["request_id"] == "trace-xyz"
|
||||
assert metadata["client_ip"] == "198.51.100.23"
|
||||
assert metadata["user_agent"] == "pytest-meta-agent"
|
||||
assert metadata["method"] == "GET"
|
||||
assert metadata["path"] == "/health"
|
||||
assert metadata["content_type"] == "application/json"
|
||||
assert metadata["content_length"] == "42"
|
||||
Reference in New Issue
Block a user