mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
Remove legacy Python tests
This commit is contained in:
@@ -1 +0,0 @@
|
||||
"""测试模块"""
|
||||
@@ -1,235 +0,0 @@
|
||||
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",
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -1,164 +0,0 @@
|
||||
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}
|
||||
@@ -1,410 +0,0 @@
|
||||
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"]},
|
||||
}
|
||||
]
|
||||
@@ -1,259 +0,0 @@
|
||||
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"
|
||||
@@ -1,257 +0,0 @@
|
||||
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)
|
||||
@@ -1,271 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.cli_protocol import CliHandlerProtocol
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.gemini_cli.handler import GeminiCliMessageHandler
|
||||
from src.services.provider.adapters.antigravity.envelope import (
|
||||
unwrap_v1internal_response,
|
||||
wrap_v1internal_request,
|
||||
)
|
||||
|
||||
|
||||
def _make_handler() -> GeminiCliMessageHandler:
|
||||
return GeminiCliMessageHandler(
|
||||
db=MagicMock(), # type: ignore[arg-type]
|
||||
user=SimpleNamespace(id=1), # type: ignore[arg-type]
|
||||
api_key=SimpleNamespace(id=1), # type: ignore[arg-type]
|
||||
request_id="req_1",
|
||||
client_ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
start_time=0.0,
|
||||
)
|
||||
|
||||
|
||||
def test_wrap_v1internal_request_removes_inner_model() -> None:
|
||||
gemini_request = {
|
||||
"model": "gemini-2.0-flash",
|
||||
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
}
|
||||
|
||||
wrapped = wrap_v1internal_request(
|
||||
gemini_request,
|
||||
project_id="project-123",
|
||||
model="gemini-2.0-flash",
|
||||
)
|
||||
|
||||
assert wrapped["project"] == "project-123"
|
||||
assert wrapped["model"] == "gemini-2.0-flash"
|
||||
assert "request" in wrapped
|
||||
assert "model" not in wrapped["request"]
|
||||
|
||||
|
||||
def test_unwrap_v1internal_response() -> None:
|
||||
v1_resp = {
|
||||
"response": {"candidates": [{"content": {"parts": [{"text": "Hello"}]}}]},
|
||||
"responseId": "resp-123",
|
||||
}
|
||||
|
||||
unwrapped = unwrap_v1internal_response(v1_resp)
|
||||
|
||||
assert "response" not in unwrapped
|
||||
assert "candidates" in unwrapped
|
||||
assert unwrapped["_v1internal_response_id"] == "resp-123"
|
||||
|
||||
|
||||
def test_convert_sse_line_unwraps_before_convert_stream_chunk() -> None:
|
||||
handler = _make_handler()
|
||||
|
||||
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
|
||||
ctx.provider_type = "antigravity"
|
||||
ctx.provider_api_format = "gemini:chat"
|
||||
ctx.client_api_format = "gemini:cli"
|
||||
|
||||
v1_line = (
|
||||
'data: {"response": {"candidates": [{"content": {"parts": [{"text": "Hi"}]}}]},'
|
||||
' "responseId": "123"}'
|
||||
)
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
class _DummyRegistry:
|
||||
def convert_stream_chunk(
|
||||
self, data_obj: object, *_args: object, **_kwargs: object
|
||||
) -> list[str]:
|
||||
seen["data_obj"] = data_obj
|
||||
return []
|
||||
|
||||
with patch(
|
||||
"src.api.handlers.base.cli_event_mixin.get_format_converter_registry",
|
||||
return_value=_DummyRegistry(),
|
||||
):
|
||||
_lines, _events = handler._convert_sse_line(ctx, v1_line, [])
|
||||
|
||||
assert isinstance(seen.get("data_obj"), dict)
|
||||
assert "response" not in seen["data_obj"] # type: ignore[operator]
|
||||
assert "_v1internal_response_id" in seen["data_obj"] # type: ignore[operator]
|
||||
|
||||
|
||||
def test_handle_sse_event_unwraps_for_antigravity() -> None:
|
||||
handler = _make_handler()
|
||||
|
||||
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
|
||||
ctx.provider_type = "antigravity"
|
||||
|
||||
v1_data = {
|
||||
"response": {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Hello"}], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"modelVersion": "gemini-2.0-flash-001",
|
||||
},
|
||||
"responseId": "123",
|
||||
}
|
||||
|
||||
with patch.object(handler, "_process_event_data") as mock_process:
|
||||
cast(CliHandlerProtocol, handler)._handle_sse_event(
|
||||
ctx, None, json.dumps(v1_data), record_chunk=False
|
||||
)
|
||||
|
||||
assert mock_process.call_count == 1
|
||||
passed_data = mock_process.call_args[0][2]
|
||||
assert isinstance(passed_data, dict)
|
||||
assert "response" not in passed_data
|
||||
assert "candidates" in passed_data
|
||||
|
||||
|
||||
def test_handle_sse_event_caches_thought_signature_for_antigravity() -> None:
|
||||
from src.core.api_format.conversion.thinking_cache import signature_cache
|
||||
|
||||
signature_cache.clear()
|
||||
|
||||
handler = _make_handler()
|
||||
ctx = StreamContext(model="claude-sonnet-4-5", api_format="gemini:cli")
|
||||
ctx.provider_type = "antigravity"
|
||||
|
||||
# 签名须 >= MIN_SIGNATURE_LENGTH(50),否则会被忽略
|
||||
long_sig = "a" * 60
|
||||
|
||||
payload = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "t1",
|
||||
"thought": True,
|
||||
"thoughtSignature": long_sig,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(handler, "_process_event_data") as _mock_process:
|
||||
cast(CliHandlerProtocol, handler)._handle_sse_event(
|
||||
ctx, None, json.dumps(payload), record_chunk=False
|
||||
)
|
||||
|
||||
assert signature_cache.get_or_dummy("claude-sonnet-4-5", "t1") == long_sig
|
||||
|
||||
|
||||
def test_provider_type_drives_antigravity_usage_path() -> None:
|
||||
handler = _make_handler()
|
||||
|
||||
event = {"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 2}}
|
||||
usage = handler._extract_usage_from_event(event, provider_type="antigravity")
|
||||
|
||||
assert usage["input_tokens"] == 10
|
||||
assert usage["output_tokens"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_antigravity_forces_conversion_path_in_stream_with_prefetch() -> None:
|
||||
handler = _make_handler()
|
||||
|
||||
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
|
||||
ctx.provider_type = "antigravity"
|
||||
ctx.needs_conversion = False # same-format case normally would passthrough
|
||||
|
||||
class _AsyncIter:
|
||||
def __init__(self, items: list[bytes]) -> None:
|
||||
self._it = iter(items)
|
||||
|
||||
def __aiter__(self) -> _AsyncIter:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> bytes:
|
||||
try:
|
||||
return next(self._it)
|
||||
except StopIteration as e:
|
||||
raise StopAsyncIteration from e
|
||||
|
||||
prefetched = [
|
||||
b'data: {"response": {"candidates": []}, "responseId": "1"}\n',
|
||||
]
|
||||
byte_iter = _AsyncIter([]) # no more bytes after prefetch
|
||||
response_ctx = SimpleNamespace(__aexit__=AsyncMock(return_value=None))
|
||||
|
||||
with patch.object(
|
||||
handler,
|
||||
"_convert_sse_line",
|
||||
return_value=(["data: {}"], []),
|
||||
) as mock_convert:
|
||||
out = []
|
||||
async for chunk in handler._create_response_stream_with_prefetch(
|
||||
ctx, byte_iter, response_ctx, prefetched # type: ignore[arg-type]
|
||||
):
|
||||
out.append(chunk)
|
||||
|
||||
assert mock_convert.call_count >= 1
|
||||
assert any(b"data: {}" in c for c in out)
|
||||
|
||||
|
||||
def test_wrap_v1internal_request_injects_thought_signature_from_tool_cache() -> None:
|
||||
from src.core.api_format.conversion.thinking_cache import signature_cache
|
||||
|
||||
signature_cache.clear()
|
||||
|
||||
tool_id = "toolu_123"
|
||||
sig = "a" * 60
|
||||
signature_cache.cache_tool_signature(tool_id, sig)
|
||||
|
||||
gemini_request = {
|
||||
"model": "gemini-2.0-flash",
|
||||
"contents": [
|
||||
{"role": "user", "parts": [{"text": "hi"}]},
|
||||
{"role": "model", "parts": [{"function_call": {"name": "do", "id": tool_id}}]},
|
||||
],
|
||||
}
|
||||
|
||||
wrapped = wrap_v1internal_request(
|
||||
gemini_request,
|
||||
project_id="project-123",
|
||||
model="gemini-2.0-flash",
|
||||
)
|
||||
|
||||
model_turn = wrapped["request"]["contents"][1]
|
||||
part = model_turn["parts"][0]
|
||||
assert part["thoughtSignature"] == sig
|
||||
|
||||
|
||||
def test_wrap_v1internal_request_injects_session_signature_when_tool_cache_missing() -> None:
|
||||
from src.core.api_format.conversion.thinking_cache import signature_cache
|
||||
|
||||
signature_cache.clear()
|
||||
|
||||
session_id = "sid-123"
|
||||
sig = "b" * 60
|
||||
signature_cache.cache_session_signature(session_id, sig, message_count=2)
|
||||
|
||||
gemini_request = {
|
||||
"model": "gemini-2.0-flash",
|
||||
"sessionId": session_id,
|
||||
"contents": [
|
||||
{"role": "user", "parts": [{"text": "hi"}]},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [{"functionCall": {"name": "do", "id": "toolu_missing"}}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
wrapped = wrap_v1internal_request(
|
||||
gemini_request,
|
||||
project_id="project-123",
|
||||
model="gemini-2.0-flash",
|
||||
)
|
||||
|
||||
model_turn = wrapped["request"]["contents"][1]
|
||||
part = model_turn["parts"][0]
|
||||
assert part["thoughtSignature"] == sig
|
||||
@@ -1,200 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import src.api.handlers.base.chat_handler_base as chatmod
|
||||
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
|
||||
from src.api.handlers.base.chat_sync_executor import ChatSyncExecutor
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.services.task.request_state import MutableRequestBodyState
|
||||
|
||||
|
||||
class _StopBuild(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _DummyAuthInfo:
|
||||
auth_header = "authorization"
|
||||
auth_value = "Bearer test"
|
||||
decrypted_auth_config = None
|
||||
|
||||
def as_tuple(self) -> tuple[str, str]:
|
||||
return self.auth_header, self.auth_value
|
||||
|
||||
|
||||
class _CaptureBuilder:
|
||||
def __init__(self) -> None:
|
||||
self.request_body: dict[str, Any] | None = None
|
||||
|
||||
def build(self, request_body: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
|
||||
self.request_body = request_body
|
||||
raise _StopBuild()
|
||||
|
||||
|
||||
class _DummyChatHandler(ChatHandlerBase):
|
||||
FORMAT_ID = "openai:chat"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.request_id = "req-test"
|
||||
self.api_key = SimpleNamespace(id="user-key-1")
|
||||
self._request_builder = _CaptureBuilder()
|
||||
self.allowed_api_formats = ["openai:chat"]
|
||||
self.api_family = None
|
||||
self.endpoint_kind = None
|
||||
|
||||
async def _convert_request(self, request: Any) -> Any:
|
||||
return request
|
||||
|
||||
def _extract_usage(self, response: dict) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
async def _get_mapped_model(
|
||||
self,
|
||||
source_model: str,
|
||||
provider_id: str,
|
||||
api_format: str | None = None,
|
||||
) -> str | None:
|
||||
del source_model, provider_id, api_format
|
||||
return None
|
||||
|
||||
def apply_mapped_model(self, request_body: dict[str, Any], mapped_model: str) -> dict[str, Any]:
|
||||
out = dict(request_body)
|
||||
out["model"] = mapped_model
|
||||
return out
|
||||
|
||||
def prepare_provider_request_body(self, request_body: dict[str, Any]) -> dict[str, Any]:
|
||||
request_body["messages"][0]["content"] = "prepared"
|
||||
return request_body
|
||||
|
||||
def finalize_provider_request(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
*,
|
||||
mapped_model: str | None,
|
||||
provider_api_format: str | None,
|
||||
) -> dict[str, Any]:
|
||||
del mapped_model, provider_api_format
|
||||
request_body["messages"].append({"role": "assistant", "content": "finalized"})
|
||||
return request_body
|
||||
|
||||
def get_model_for_url(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
mapped_model: str | None,
|
||||
) -> str | None:
|
||||
return mapped_model or str(request_body.get("model") or "")
|
||||
|
||||
|
||||
def _patch_chat_upstream(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
|
||||
return _DummyAuthInfo()
|
||||
|
||||
monkeypatch.setattr(chatmod, "get_provider_auth", _fake_get_provider_auth)
|
||||
monkeypatch.setattr(
|
||||
chatmod,
|
||||
"get_provider_behavior",
|
||||
lambda **kwargs: SimpleNamespace(
|
||||
envelope=None,
|
||||
same_format_variant=None,
|
||||
cross_format_variant=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(chatmod, "get_upstream_stream_policy", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
chatmod,
|
||||
"resolve_upstream_is_stream",
|
||||
lambda *, client_is_stream, policy: client_is_stream,
|
||||
)
|
||||
monkeypatch.setattr(chatmod, "enforce_stream_mode_for_upstream", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
chatmod,
|
||||
"maybe_patch_request_with_prompt_cache_key",
|
||||
lambda request_body, **kwargs: request_body,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_execute_stream_request_does_not_mutate_original_request_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_chat_upstream(monkeypatch)
|
||||
|
||||
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(
|
||||
mapping_matched_model=None, needs_conversion=False, output_limit=None
|
||||
)
|
||||
|
||||
original_request_body = {
|
||||
"model": "gpt-test",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
}
|
||||
snapshot = copy.deepcopy(original_request_body)
|
||||
request_state = MutableRequestBodyState(original_request_body)
|
||||
|
||||
with pytest.raises(_StopBuild):
|
||||
await handler._execute_stream_request(
|
||||
ctx,
|
||||
object(),
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
request_state.build_attempt_body(),
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
|
||||
assert original_request_body == snapshot
|
||||
assert handler._request_builder.request_body is not None
|
||||
assert handler._request_builder.request_body["messages"][0]["content"] == "prepared"
|
||||
assert handler._request_builder.request_body["messages"][-1]["content"] == "finalized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_sync_request_func_does_not_mutate_original_request_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_chat_upstream(monkeypatch)
|
||||
|
||||
handler = _DummyChatHandler()
|
||||
executor = ChatSyncExecutor(handler)
|
||||
|
||||
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(
|
||||
mapping_matched_model=None, needs_conversion=False, output_limit=None
|
||||
)
|
||||
|
||||
original_request_body = {
|
||||
"model": "gpt-test",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
}
|
||||
snapshot = copy.deepcopy(original_request_body)
|
||||
request_state = MutableRequestBodyState(original_request_body)
|
||||
|
||||
with pytest.raises(_StopBuild):
|
||||
await executor._sync_request_func(
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
candidate,
|
||||
model="gpt-test",
|
||||
api_format="openai:chat",
|
||||
original_headers={},
|
||||
request_state=request_state,
|
||||
)
|
||||
|
||||
assert original_request_body == snapshot
|
||||
assert handler._request_builder.request_body is not None
|
||||
assert handler._request_builder.request_body["messages"][0]["content"] == "prepared"
|
||||
assert handler._request_builder.request_body["messages"][-1]["content"] == "finalized"
|
||||
@@ -1,566 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
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.execution_runtime_plan import (
|
||||
ExecutionPlan,
|
||||
ExecutionPlanBody,
|
||||
PreparedExecutionPlan,
|
||||
)
|
||||
from src.services.request.execution_runtime_client import (
|
||||
ExecutionRuntimeClientError,
|
||||
ExecutionRuntimeSyncResult,
|
||||
)
|
||||
|
||||
|
||||
class _FakeEnvelope:
|
||||
name = "fake-envelope"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.status_codes: list[int] = []
|
||||
self.postprocessed_payloads: list[dict[str, object]] = []
|
||||
|
||||
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
|
||||
self.status_codes.append(status_code)
|
||||
|
||||
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None:
|
||||
raise AssertionError("connection error hook should not be used in this test")
|
||||
|
||||
def unwrap_response(self, data: dict[str, object]) -> dict[str, object]:
|
||||
return dict(data["payload"]) # type: ignore[index]
|
||||
|
||||
def postprocess_unwrapped_response(self, *, model: str, data: dict[str, object]) -> None:
|
||||
self.postprocessed_payloads.append(dict(data))
|
||||
|
||||
|
||||
class _FakeNormalizer:
|
||||
def response_from_internal(
|
||||
self,
|
||||
internal_resp: object,
|
||||
*,
|
||||
requested_model: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"aggregated": True,
|
||||
"requested_model": requested_model,
|
||||
"internal_id": getattr(internal_resp, "id", "missing"),
|
||||
}
|
||||
|
||||
|
||||
def _make_prepared_plan() -> PreparedExecutionPlan:
|
||||
return PreparedExecutionPlan(
|
||||
contract=ExecutionPlan(
|
||||
request_id="req-test",
|
||||
candidate_id=None,
|
||||
provider_name="openai",
|
||||
provider_id="prov-1",
|
||||
endpoint_id="ep-1",
|
||||
key_id="key-1",
|
||||
method="POST",
|
||||
url="https://example.com/v1/chat/completions",
|
||||
headers={"content-type": "application/json"},
|
||||
body=ExecutionPlanBody(json_body={"model": "gpt-4.1"}),
|
||||
stream=False,
|
||||
provider_api_format="openai:chat",
|
||||
client_api_format="openai:chat",
|
||||
model_name="gpt-4.1",
|
||||
),
|
||||
payload={"model": "gpt-4.1"},
|
||||
headers={"content-type": "application/json"},
|
||||
upstream_is_stream=False,
|
||||
needs_conversion=False,
|
||||
provider_type="openai",
|
||||
request_timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
def _make_proxy_prepared_plan() -> PreparedExecutionPlan:
|
||||
prepared = _make_prepared_plan()
|
||||
prepared.contract.proxy = chat_sync_mod.ExecutionProxySnapshot(
|
||||
enabled=True,
|
||||
mode="http",
|
||||
label="proxy.internal",
|
||||
url="http://proxy.internal:8080",
|
||||
)
|
||||
prepared.proxy_config = {"url": "http://proxy.internal:8080"}
|
||||
return prepared
|
||||
|
||||
|
||||
def _make_tunnel_prepared_plan() -> PreparedExecutionPlan:
|
||||
prepared = _make_prepared_plan()
|
||||
prepared.contract.proxy = chat_sync_mod.ExecutionProxySnapshot(
|
||||
enabled=True,
|
||||
mode="tunnel",
|
||||
node_id="node-1",
|
||||
label="relay-node",
|
||||
)
|
||||
prepared.delegate_config = {"tunnel": True, "node_id": "node-1"}
|
||||
prepared.proxy_config = {"node_id": "node-1"}
|
||||
return prepared
|
||||
|
||||
|
||||
def _make_upstream_stream_prepared_plan() -> PreparedExecutionPlan:
|
||||
prepared = _make_prepared_plan()
|
||||
prepared.contract.stream = True
|
||||
prepared.upstream_is_stream = True
|
||||
return prepared
|
||||
|
||||
|
||||
def _make_tls_prepared_plan() -> PreparedExecutionPlan:
|
||||
prepared = _make_prepared_plan()
|
||||
prepared.contract.tls_profile = "claude_code_nodejs"
|
||||
prepared.provider_type = "claude_code"
|
||||
return prepared
|
||||
|
||||
|
||||
def _make_executor() -> ChatSyncExecutor:
|
||||
handler = SimpleNamespace(request_id="req-test")
|
||||
executor = ChatSyncExecutor(handler)
|
||||
executor._ctx.provider_api_format_for_error = "openai:chat"
|
||||
executor._ctx.client_api_format_for_error = "openai:chat"
|
||||
executor._ctx.needs_conversion_for_error = False
|
||||
return executor
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_uses_rust_executor_when_available(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_prepared_plan()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.request_id == "req-test"
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"id": "chatcmpl-1"},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert response == {"id": "chatcmpl-1"}
|
||||
assert executor._ctx.status_code == 200
|
||||
assert executor._ctx.response_json == {"id": "chatcmpl-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_allows_supported_proxy_urls_for_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_proxy_prepared_plan()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.proxy is not None
|
||||
assert plan.proxy.url == "http://proxy.internal:8080"
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"id": "chatcmpl-proxy"},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert response == {"id": "chatcmpl-proxy"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_allows_tunnel_delegate_for_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_tunnel_prepared_plan()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.proxy is not None
|
||||
assert plan.proxy.mode == "tunnel"
|
||||
assert plan.proxy.node_id == "node-1"
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"id": "chatcmpl-tunnel"},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert response == {"id": "chatcmpl-tunnel"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_allows_tls_profile_for_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_tls_prepared_plan()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.tls_profile == "claude_code_nodejs"
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"id": "chatcmpl-tls"},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="claude-3.7-sonnet",
|
||||
)
|
||||
|
||||
assert response == {"id": "chatcmpl-tls"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_applies_envelope_postprocessing_after_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_prepared_plan()
|
||||
prepared_plan.envelope = _FakeEnvelope()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"payload": {"id": "wrapped-1", "message": "ok"}},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert response == {"id": "wrapped-1", "message": "ok"}
|
||||
assert prepared_plan.envelope.status_codes == [200]
|
||||
assert prepared_plan.envelope.postprocessed_payloads == [
|
||||
{"id": "wrapped-1", "message": "ok"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_applies_format_conversion_after_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_prepared_plan()
|
||||
prepared_plan.needs_conversion = True
|
||||
prepared_plan.contract.provider_api_format = "gemini:chat"
|
||||
prepared_plan.contract.client_api_format = "openai:chat"
|
||||
executor._ctx.provider_api_format_for_error = "gemini:chat"
|
||||
executor._ctx.client_api_format_for_error = "openai:chat"
|
||||
executor._ctx.needs_conversion_for_error = True
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
class _FakeRegistry:
|
||||
def convert_response(
|
||||
self,
|
||||
response_json: dict[str, object],
|
||||
source_format: str,
|
||||
target_format: str,
|
||||
*,
|
||||
requested_model: str,
|
||||
) -> dict[str, object]:
|
||||
assert source_format == "gemini:chat"
|
||||
assert target_format == "openai:chat"
|
||||
assert requested_model == "gpt-4.1"
|
||||
return {
|
||||
"converted": True,
|
||||
"source_id": response_json["provider_id"],
|
||||
}
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.provider_api_format == "gemini:chat"
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"provider_id": "gemini-1"},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(chat_sync_mod, "get_format_converter_registry", lambda: _FakeRegistry())
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert response == {"converted": True, "source_id": "gemini-1"}
|
||||
assert executor._ctx.provider_response_json == {"provider_id": "gemini-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_aggregates_upstream_stream_after_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_upstream_stream_prepared_plan()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
class _FakeRegistry:
|
||||
def get_normalizer(self, format_id: str) -> _FakeNormalizer:
|
||||
assert format_id == "openai:chat"
|
||||
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:
|
||||
async for chunk in byte_iter: # type: ignore[attr-defined]
|
||||
captured_chunks.append(chunk)
|
||||
assert provider_api_format == "openai:chat"
|
||||
assert provider_name == "provider"
|
||||
assert model == "gpt-4.1"
|
||||
assert request_id == "req-test"
|
||||
return SimpleNamespace(id="agg-1")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.stream is True
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_body_bytes=b"data: {\"id\":\"chunk-1\"}\n\ndata: [DONE]\n\n",
|
||||
headers={"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(chat_sync_mod, "get_format_converter_registry", lambda: _FakeRegistry())
|
||||
monkeypatch.setattr(
|
||||
"src.api.handlers.base.upstream_stream_bridge.aggregate_upstream_stream_to_internal_response",
|
||||
_fake_aggregate,
|
||||
)
|
||||
|
||||
response = await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert response == {
|
||||
"aggregated": True,
|
||||
"requested_model": "gpt-4.1",
|
||||
"internal_id": "agg-1",
|
||||
}
|
||||
assert captured_chunks == [b"data: {\"id\":\"chunk-1\"}\n\ndata: [DONE]\n\n"]
|
||||
assert executor._ctx.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_turns_rust_http_error_into_httpx_status_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_prepared_plan()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.url.endswith("/chat/completions")
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=429,
|
||||
response_json={"error": {"message": "slow down"}},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert exc_info.value.response.status_code == 429
|
||||
assert '"message": "slow down"' in exc_info.value.upstream_response # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_preserves_embedded_error_semantics_from_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_prepared_plan()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.provider_api_format == "openai:chat"
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={
|
||||
"error": {
|
||||
"message": "bad request",
|
||||
"type": "invalid_request_error",
|
||||
"code": 400,
|
||||
}
|
||||
},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddedErrorException) as exc_info:
|
||||
await executor._execute_sync_plan(
|
||||
prepared_plan=prepared_plan,
|
||||
provider=SimpleNamespace(name="provider"),
|
||||
model="gpt-4.1",
|
||||
)
|
||||
|
||||
assert exc_info.value.error_message == "bad request"
|
||||
assert exc_info.value.error_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_plan_raises_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor = _make_executor()
|
||||
prepared_plan = _make_prepared_plan()
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
assert plan.request_id == "req-test"
|
||||
raise ExecutionRuntimeClientError("executor down")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(chat_sync_mod.config, "executor_backend", "rust")
|
||||
|
||||
async def _should_not_call_rust(self: object, plan: ExecutionPlan) -> ExecutionRuntimeSyncResult:
|
||||
del self, plan
|
||||
raise AssertionError("rust executor should not be called")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chat_sync_mod.ExecutionRuntimeClient,
|
||||
"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"
|
||||
)
|
||||
@@ -1,760 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
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.execution_runtime_client import (
|
||||
ExecutionRuntimeClientError,
|
||||
ExecutionRuntimeStreamResult,
|
||||
)
|
||||
|
||||
|
||||
class _DummyAuthInfo:
|
||||
auth_header = "authorization"
|
||||
auth_value = "Bearer test"
|
||||
decrypted_auth_config = None
|
||||
|
||||
def as_tuple(self) -> tuple[str, str]:
|
||||
return self.auth_header, self.auth_value
|
||||
|
||||
|
||||
class _PassBuilder:
|
||||
def build(self, request_body: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
|
||||
return request_body, {"content-type": "application/json"}
|
||||
|
||||
|
||||
class _DummyStreamResponseCtx:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeStreamProcessor:
|
||||
def __init__(self) -> None:
|
||||
self.prefetched_chunks: list[bytes] | None = None
|
||||
self.response_ctx: _DummyStreamResponseCtx | None = None
|
||||
|
||||
async def prefetch_and_check_error(
|
||||
self,
|
||||
byte_iterator: Any,
|
||||
provider: Any,
|
||||
endpoint: Any,
|
||||
ctx: Any,
|
||||
max_prefetch_lines: int = 5,
|
||||
max_prefetch_bytes: int = 65536,
|
||||
) -> list[bytes]:
|
||||
del provider, endpoint, ctx, max_prefetch_lines, max_prefetch_bytes
|
||||
first = await anext(byte_iterator)
|
||||
self.prefetched_chunks = [first]
|
||||
return self.prefetched_chunks
|
||||
|
||||
async def create_response_stream(
|
||||
self,
|
||||
ctx: Any,
|
||||
byte_iterator: Any,
|
||||
response_ctx: _DummyStreamResponseCtx,
|
||||
prefetched_chunks: list[bytes] | None = None,
|
||||
*,
|
||||
start_time: float | None = None,
|
||||
) -> AsyncGenerator[bytes]:
|
||||
del ctx, start_time
|
||||
self.response_ctx = response_ctx
|
||||
try:
|
||||
for chunk in prefetched_chunks or []:
|
||||
yield chunk
|
||||
async for chunk in byte_iterator:
|
||||
yield chunk
|
||||
finally:
|
||||
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"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.request_id = "req-test"
|
||||
self.api_key = SimpleNamespace(id="user-key-1")
|
||||
self._request_builder = _PassBuilder()
|
||||
self.allowed_api_formats = ["openai:chat"]
|
||||
self.api_family = None
|
||||
self.endpoint_kind = None
|
||||
self.start_time = 0.0
|
||||
|
||||
async def _convert_request(self, request: Any) -> Any:
|
||||
return request
|
||||
|
||||
def _extract_usage(self, response: dict) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
async def _get_mapped_model(
|
||||
self,
|
||||
source_model: str,
|
||||
provider_id: str,
|
||||
api_format: str | None = None,
|
||||
) -> str | None:
|
||||
del source_model, provider_id, api_format
|
||||
return None
|
||||
|
||||
def apply_mapped_model(self, request_body: dict[str, Any], mapped_model: str) -> dict[str, Any]:
|
||||
out = dict(request_body)
|
||||
out["model"] = mapped_model
|
||||
return out
|
||||
|
||||
def prepare_provider_request_body(self, request_body: dict[str, Any]) -> dict[str, Any]:
|
||||
return dict(request_body)
|
||||
|
||||
def finalize_provider_request(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
*,
|
||||
mapped_model: str | None,
|
||||
provider_api_format: str | None,
|
||||
) -> dict[str, Any]:
|
||||
del mapped_model, provider_api_format
|
||||
return dict(request_body)
|
||||
|
||||
def get_model_for_url(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
mapped_model: str | None,
|
||||
) -> str | None:
|
||||
return mapped_model or str(request_body.get("model") or "")
|
||||
|
||||
|
||||
def _patch_stream_setup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
proxy_info: dict[str, Any] | None = None,
|
||||
delegate_config: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
|
||||
del endpoint, key
|
||||
return _DummyAuthInfo()
|
||||
|
||||
async def _fake_resolve_proxy_info(proxy_config: Any) -> Any:
|
||||
del proxy_config
|
||||
return proxy_info
|
||||
|
||||
async def _fake_resolve_delegate(proxy_config: Any) -> Any:
|
||||
del proxy_config
|
||||
return delegate_config
|
||||
|
||||
async def _fake_get_system_proxy() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(chatmod, "get_provider_auth", _fake_get_provider_auth)
|
||||
monkeypatch.setattr(
|
||||
chatmod,
|
||||
"get_provider_behavior",
|
||||
lambda **kwargs: SimpleNamespace(
|
||||
envelope=None,
|
||||
same_format_variant=None,
|
||||
cross_format_variant=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(chatmod, "build_provider_url", lambda *args, **kwargs: "https://upstream.test/v1/chat/completions")
|
||||
monkeypatch.setattr(chatmod, "get_upstream_stream_policy", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
chatmod,
|
||||
"resolve_upstream_is_stream",
|
||||
lambda *, client_is_stream, policy: client_is_stream,
|
||||
)
|
||||
monkeypatch.setattr(chatmod, "enforce_stream_mode_for_upstream", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
chatmod,
|
||||
"maybe_patch_request_with_prompt_cache_key",
|
||||
lambda request_body, **kwargs: request_body,
|
||||
)
|
||||
monkeypatch.setattr(proxymod, "resolve_effective_proxy", lambda provider_proxy, key_proxy=None: None)
|
||||
monkeypatch.setattr(proxymod, "resolve_proxy_info_async", _fake_resolve_proxy_info)
|
||||
monkeypatch.setattr(proxymod, "get_proxy_label", lambda proxy_info: "direct")
|
||||
monkeypatch.setattr(proxymod, "resolve_delegate_config_async", _fake_resolve_delegate)
|
||||
monkeypatch.setattr(proxymod, "get_system_proxy_config_async", _fake_get_system_proxy)
|
||||
monkeypatch.setattr(proxymod, "build_proxy_url_async", _fake_get_system_proxy)
|
||||
|
||||
|
||||
async def _iter_chunks(chunks: list[bytes]) -> AsyncGenerator[bytes]:
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_uses_rust_executor_when_available(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_stream_setup(monkeypatch)
|
||||
monkeypatch.setattr(chatmod.config, "executor_backend", "rust")
|
||||
|
||||
handler = _DummyChatHandler()
|
||||
stream_processor = _FakeStreamProcessor()
|
||||
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,
|
||||
)
|
||||
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "stream") is True
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream", "x-upstream-test": "true"},
|
||||
byte_iterator=_iter_chunks(
|
||||
[
|
||||
b"data: {\"id\":\"chunk-1\"}\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(chatmod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
|
||||
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: {\"id\":\"chunk-1\"}\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
assert ctx.status_code == 200
|
||||
assert ctx.response_headers["x-upstream-test"] == "true"
|
||||
assert stream_processor.prefetched_chunks == [b"data: {\"id\":\"chunk-1\"}\n\n"]
|
||||
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) -> ExecutionRuntimeStreamResult:
|
||||
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.ExecutionRuntimeClient, "execute_sync_json", _fake_execute_sync_json)
|
||||
monkeypatch.setattr(chatmod.ExecutionRuntimeClient, "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,
|
||||
) -> None:
|
||||
_patch_stream_setup(monkeypatch)
|
||||
monkeypatch.setattr(chatmod.config, "executor_backend", "rust")
|
||||
|
||||
handler = _DummyChatHandler()
|
||||
stream_processor = _FakeStreamProcessor()
|
||||
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,
|
||||
)
|
||||
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "stream") is True
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
byte_iterator=_iter_chunks(
|
||||
[
|
||||
b"data: {\"id\":\"chunk-1\"}\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(chatmod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
|
||||
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: {\"id\":\"chunk-1\"}\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_allows_tunnel_delegate_for_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_stream_setup(
|
||||
monkeypatch,
|
||||
proxy_info={"node_id": "node-1", "node_name": "relay-node", "mode": "tunnel"},
|
||||
delegate_config={"tunnel": True, "node_id": "node-1"},
|
||||
)
|
||||
monkeypatch.setattr(chatmod.config, "executor_backend", "rust")
|
||||
|
||||
handler = _DummyChatHandler()
|
||||
stream_processor = _FakeStreamProcessor()
|
||||
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={"enabled": True, "node_id": "node-1"},
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "proxy") is not None
|
||||
assert getattr(plan.proxy, "mode") == "tunnel"
|
||||
assert getattr(plan.proxy, "node_id") == "node-1"
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
byte_iterator=_iter_chunks([b"data: [DONE]\n\n"]),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(chatmod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
|
||||
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: [DONE]\n\n"]
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_allows_tls_profile_for_rust(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_stream_setup(monkeypatch)
|
||||
monkeypatch.setattr(chatmod.config, "executor_backend", "rust")
|
||||
|
||||
handler = _DummyChatHandler()
|
||||
stream_processor = _FakeStreamProcessor()
|
||||
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,
|
||||
)
|
||||
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
|
||||
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=True,
|
||||
needs_conversion=False,
|
||||
provider_api_format="openai:chat",
|
||||
client_api_format="openai:chat",
|
||||
auth_info=_DummyAuthInfo(),
|
||||
tls_profile="claude_code_nodejs",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_DummyChatHandler,
|
||||
"_prepare_provider_request",
|
||||
_fake_prepare_provider_request,
|
||||
)
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "tls_profile") == "claude_code_nodejs"
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
byte_iterator=_iter_chunks([b"data: [DONE]\n\n"]),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(chatmod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
|
||||
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: [DONE]\n\n"]
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_turns_rust_upstream_error_into_http_status_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_stream_setup(monkeypatch)
|
||||
monkeypatch.setattr(chatmod.config, "executor_backend", "rust")
|
||||
|
||||
handler = _DummyChatHandler()
|
||||
stream_processor = _FakeStreamProcessor()
|
||||
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,
|
||||
)
|
||||
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "stream") is True
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=429,
|
||||
headers={"content-type": "application/json"},
|
||||
byte_iterator=_iter_chunks([b'{"error":{"message":"slow down"}}']),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(chatmod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await handler._execute_stream_request(
|
||||
ctx,
|
||||
stream_processor,
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
{"model": "gpt-test", "messages": [{"role": "user", "content": "hello"}]},
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
|
||||
assert exc_info.value.response.status_code == 429
|
||||
assert "slow down" in exc_info.value.upstream_response # type: ignore[attr-defined]
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_raises_when_rust_unavailable(
|
||||
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 _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
del plan
|
||||
raise ExecutionRuntimeClientError("executor down")
|
||||
|
||||
async def _fake_get_upstream_client(*args: Any, **kwargs: Any) -> object:
|
||||
raise AssertionError("python fallback should not be used")
|
||||
|
||||
monkeypatch.setattr(chatmod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
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 == "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) -> ExecutionRuntimeStreamResult:
|
||||
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_execution_runtime_contract_eligible", lambda plan: False)
|
||||
monkeypatch.setattr(chatmod.ExecutionRuntimeClient, "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"
|
||||
)
|
||||
@@ -1,38 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from src.api.handlers.base.cli_event_mixin import CliEventMixin
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
|
||||
|
||||
class _DummyCliEventHandler(CliEventMixin):
|
||||
request_id = "req-test"
|
||||
|
||||
def _process_event_data(
|
||||
self,
|
||||
ctx: StreamContext,
|
||||
event_type: str,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
del ctx, event_type, data
|
||||
|
||||
|
||||
def test_convert_sse_line_drops_done_for_openai_cli() -> None:
|
||||
handler = _DummyCliEventHandler()
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:cli")
|
||||
ctx.client_api_format = "openai:cli"
|
||||
|
||||
lines, converted = handler._convert_sse_line(ctx, "data: [DONE]", [])
|
||||
|
||||
assert lines == []
|
||||
assert converted == []
|
||||
|
||||
|
||||
def test_convert_sse_line_keeps_done_for_openai_chat() -> None:
|
||||
handler = _DummyCliEventHandler()
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:chat")
|
||||
ctx.client_api_format = "openai:chat"
|
||||
|
||||
lines, converted = handler._convert_sse_line(ctx, "data: [DONE]", [])
|
||||
|
||||
assert lines == ["data: [DONE]"]
|
||||
assert converted == []
|
||||
@@ -1,264 +0,0 @@
|
||||
"""
|
||||
CliMessageHandlerBase._convert_sse_line 单元测试
|
||||
|
||||
测试覆盖:
|
||||
1. 基本转换(空行、非 data 行、JSON 解析失败)
|
||||
2. 一入多出场景
|
||||
3. 状态追踪
|
||||
4. 错误处理
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
|
||||
# Mock CliMessageHandlerBase 用于测试
|
||||
class MockCliHandler:
|
||||
"""Mock handler for testing _convert_sse_line"""
|
||||
|
||||
def _convert_sse_line(
|
||||
self,
|
||||
ctx: StreamContext,
|
||||
line: str,
|
||||
events: list,
|
||||
) -> list[str]:
|
||||
"""复制自 CliMessageHandlerBase._convert_sse_line"""
|
||||
from src.core.api_format.conversion import (
|
||||
format_conversion_registry,
|
||||
register_default_normalizers,
|
||||
)
|
||||
|
||||
register_default_normalizers()
|
||||
|
||||
# 如果是空行或特殊控制行,直接返回
|
||||
if not line or line.strip() == "" or line == "data: [DONE]":
|
||||
return [line] if line else []
|
||||
|
||||
# 如果不是 data 行,直接透传
|
||||
if not line.startswith("data:"):
|
||||
return [line]
|
||||
|
||||
# 提取 data 内容
|
||||
data_content = line[5:].strip()
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
data_obj = json.loads(data_content)
|
||||
except json.JSONDecodeError:
|
||||
return [line]
|
||||
|
||||
# 初始化流式转换状态
|
||||
# 使用客户端请求的模型名(ctx.model),而非映射后的模型名(ctx.mapped_model)
|
||||
if ctx.stream_conversion_state is None:
|
||||
ctx.stream_conversion_state = StreamState(
|
||||
model=ctx.model,
|
||||
message_id=ctx.response_id or ctx.request_id,
|
||||
)
|
||||
|
||||
provider_format = ctx.provider_api_format or ""
|
||||
client_format = ctx.client_api_format or ""
|
||||
|
||||
try:
|
||||
converted_events = format_conversion_registry.convert_stream_chunk(
|
||||
data_obj,
|
||||
provider_format,
|
||||
client_format,
|
||||
state=ctx.stream_conversion_state,
|
||||
)
|
||||
|
||||
result = []
|
||||
for evt in converted_events:
|
||||
result.append(f"data: {json.dumps(evt, ensure_ascii=False)}")
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
return [line]
|
||||
|
||||
|
||||
class TestConvertSseLineBasic:
|
||||
"""基本转换测试"""
|
||||
|
||||
def test_empty_line_returns_empty_list(self) -> None:
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="test", api_format="openai:chat")
|
||||
|
||||
result = handler._convert_sse_line(ctx, "", [])
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_whitespace_line_returns_line(self) -> None:
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="test", api_format="openai:chat")
|
||||
|
||||
result = handler._convert_sse_line(ctx, " ", [])
|
||||
|
||||
assert result == [" "]
|
||||
|
||||
def test_done_marker_returns_as_is(self) -> None:
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="test", api_format="openai:chat")
|
||||
|
||||
result = handler._convert_sse_line(ctx, "data: [DONE]", [])
|
||||
|
||||
assert result == ["data: [DONE]"]
|
||||
|
||||
def test_non_data_line_passthrough(self) -> None:
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="test", api_format="openai:chat")
|
||||
|
||||
result = handler._convert_sse_line(ctx, "event: message_start", [])
|
||||
|
||||
assert result == ["event: message_start"]
|
||||
|
||||
def test_invalid_json_passthrough(self) -> None:
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="test", api_format="openai:chat")
|
||||
|
||||
result = handler._convert_sse_line(ctx, "data: {invalid json}", [])
|
||||
|
||||
assert result == ["data: {invalid json}"]
|
||||
|
||||
|
||||
class TestConvertSseLineWithMockConverter:
|
||||
"""使用 Mock 转换器的测试"""
|
||||
|
||||
def test_same_format_returns_original(self) -> None:
|
||||
"""同格式无需转换"""
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="test", api_format="openai:chat")
|
||||
ctx.provider_api_format = "openai:chat"
|
||||
ctx.client_api_format = "openai:chat"
|
||||
|
||||
chunk = {"choices": [{"delta": {"content": "hello"}}]}
|
||||
line = f"data: {json.dumps(chunk)}"
|
||||
|
||||
result = handler._convert_sse_line(ctx, line, [])
|
||||
|
||||
assert len(result) == 1
|
||||
assert json.loads(result[0][6:]) == chunk
|
||||
|
||||
def test_state_initialization(self) -> None:
|
||||
"""测试状态自动初始化
|
||||
|
||||
流式转换状态应使用用户请求的原始模型名(ctx.model),
|
||||
而非映射后的模型名(ctx.mapped_model),确保返回给客户端的响应使用原始模型名。
|
||||
"""
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="gpt-4", api_format="openai:chat")
|
||||
ctx.provider_api_format = "openai:chat"
|
||||
ctx.client_api_format = "openai:chat"
|
||||
ctx.mapped_model = "claude-3-5-sonnet" # 映射后的模型名(发给上游的)
|
||||
ctx.request_id = "req_123"
|
||||
|
||||
chunk = {"choices": [{"delta": {"content": "test"}}]}
|
||||
line = f"data: {json.dumps(chunk)}"
|
||||
|
||||
handler._convert_sse_line(ctx, line, [])
|
||||
|
||||
# 验证状态已初始化,使用用户请求的原始模型名
|
||||
assert ctx.stream_conversion_state is not None
|
||||
assert ctx.stream_conversion_state.model == "gpt-4" # 应使用原始模型名,非 mapped_model
|
||||
assert ctx.stream_conversion_state.message_id == "req_123"
|
||||
|
||||
|
||||
class TestConvertSseLineOneInManyOut:
|
||||
"""一入多出测试(需要注册转换器)"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_converters(self):
|
||||
"""确保 Canonical normalizers 已注册"""
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
|
||||
register_default_normalizers()
|
||||
|
||||
yield
|
||||
|
||||
def test_openai_to_claude_conversion(self) -> None:
|
||||
"""测试 OpenAI -> Claude 流式转换"""
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="gpt-4", api_format="openai:chat")
|
||||
ctx.provider_api_format = "openai:chat"
|
||||
ctx.client_api_format = "claude:chat"
|
||||
ctx.mapped_model = "claude-3-5-sonnet"
|
||||
ctx.request_id = "req_test"
|
||||
|
||||
# 第一个 chunk:带 role
|
||||
chunk1 = {
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
||||
}
|
||||
line1 = f"data: {json.dumps(chunk1)}"
|
||||
|
||||
result1 = handler._convert_sse_line(ctx, line1, [])
|
||||
|
||||
# 应返回 message_start 事件
|
||||
assert len(result1) >= 1
|
||||
first_event = json.loads(result1[0][6:])
|
||||
assert first_event.get("type") == "message_start"
|
||||
|
||||
def test_claude_to_openai_conversion(self) -> None:
|
||||
"""测试 Claude -> OpenAI 流式转换"""
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="claude-3-5-sonnet", api_format="claude:chat")
|
||||
ctx.provider_api_format = "claude:chat"
|
||||
ctx.client_api_format = "openai:chat"
|
||||
ctx.mapped_model = "gpt-4"
|
||||
ctx.request_id = "msg_test"
|
||||
|
||||
# Claude message_start 事件
|
||||
event = {"type": "message_start", "message": {"id": "msg_123", "role": "assistant"}}
|
||||
line = f"data: {json.dumps(event)}"
|
||||
|
||||
result = handler._convert_sse_line(ctx, line, [])
|
||||
|
||||
# 应返回 OpenAI 格式的 chunk
|
||||
assert len(result) >= 1
|
||||
chunk = json.loads(result[0][6:])
|
||||
assert "choices" in chunk
|
||||
|
||||
def test_multiple_chunks_state_persistence(self) -> None:
|
||||
"""测试多个 chunk 之间状态持久化"""
|
||||
handler = MockCliHandler()
|
||||
ctx = StreamContext(model="gpt-4", api_format="openai:chat")
|
||||
ctx.provider_api_format = "openai:chat"
|
||||
ctx.client_api_format = "claude:chat"
|
||||
ctx.mapped_model = "claude-3-5-sonnet"
|
||||
|
||||
# 第一个 chunk
|
||||
chunk1 = {"choices": [{"delta": {"role": "assistant"}}]}
|
||||
handler._convert_sse_line(ctx, f"data: {json.dumps(chunk1)}", [])
|
||||
|
||||
state_after_first = ctx.stream_conversion_state
|
||||
|
||||
# 第二个 chunk
|
||||
chunk2 = {"choices": [{"delta": {"content": "hello"}}]}
|
||||
handler._convert_sse_line(ctx, f"data: {json.dumps(chunk2)}", [])
|
||||
|
||||
# 状态应该是同一个对象
|
||||
assert ctx.stream_conversion_state is state_after_first
|
||||
|
||||
|
||||
class TestStreamContextIntegration:
|
||||
"""StreamContext 集成测试"""
|
||||
|
||||
def test_stream_conversion_state_reset_on_retry(self) -> None:
|
||||
"""测试重试时重置流式转换状态"""
|
||||
ctx = StreamContext(model="test", api_format="openai:chat")
|
||||
ctx.stream_conversion_state = StreamState(model="test", message_id="123")
|
||||
|
||||
ctx.reset_for_retry()
|
||||
|
||||
assert ctx.stream_conversion_state is None
|
||||
|
||||
def test_stream_conversion_state_field_exists(self) -> None:
|
||||
"""测试 StreamContext 有 stream_conversion_state 字段"""
|
||||
ctx = StreamContext(model="test", api_format="openai:chat")
|
||||
|
||||
assert hasattr(ctx, "stream_conversion_state")
|
||||
assert ctx.stream_conversion_state is None
|
||||
@@ -1,161 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.cli_monitor_mixin import CliMonitorMixin
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
|
||||
|
||||
class _DummyMonitor(CliMonitorMixin):
|
||||
pass
|
||||
|
||||
|
||||
class _RequestStub:
|
||||
def __init__(self, responses: list[bool | Exception]):
|
||||
self._responses = responses
|
||||
|
||||
async def is_disconnected(self) -> bool:
|
||||
if self._responses:
|
||||
value = self._responses.pop(0)
|
||||
else:
|
||||
value = False
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
return value
|
||||
|
||||
|
||||
async def _cancel_immediately() -> AsyncGenerator[bytes, None]:
|
||||
if False:
|
||||
yield b""
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
|
||||
async def _yield_once_then_cancel(ctx: StreamContext) -> AsyncGenerator[bytes, None]:
|
||||
ctx.append_text("partial output")
|
||||
yield b"data: chunk\n\n"
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
|
||||
async def _yield_once_then_hang(ctx: StreamContext) -> AsyncGenerator[bytes, None]:
|
||||
ctx.append_text("partial output")
|
||||
yield b"data: chunk\n\n"
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
async def _yield_after_delay_then_complete() -> AsyncGenerator[bytes, None]:
|
||||
await asyncio.sleep(0.25)
|
||||
yield b"data: first\n\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_monitored_stream_marks_client_disconnected_when_confirmed() -> None:
|
||||
monitor = _DummyMonitor()
|
||||
monitor.CANCEL_DISCONNECT_RETRY_DELAYS_SECONDS = ()
|
||||
ctx = StreamContext(model="test-model", api_format="openai:cli", request_id="req-client")
|
||||
|
||||
request = _RequestStub([True])
|
||||
monitored = monitor._create_monitored_stream(ctx, _cancel_immediately(), request)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
async for _ in monitored:
|
||||
pass
|
||||
|
||||
assert ctx.status_code == 499
|
||||
assert ctx.error_message == "client_disconnected"
|
||||
assert "cancel_origin=client_disconnected" in (ctx.upstream_response or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_monitored_stream_marks_server_cancelled_when_confirmed_connected() -> None:
|
||||
monitor = _DummyMonitor()
|
||||
monitor.CANCEL_DISCONNECT_RETRY_DELAYS_SECONDS = ()
|
||||
ctx = StreamContext(model="test-model", api_format="openai:cli", request_id="req-server")
|
||||
|
||||
request = _RequestStub([False])
|
||||
monitored = monitor._create_monitored_stream(ctx, _cancel_immediately(), request)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
async for _ in monitored:
|
||||
pass
|
||||
|
||||
assert ctx.status_code == 503
|
||||
assert ctx.error_message == "server_cancelled"
|
||||
assert "cancel_origin=server_cancelled" in (ctx.upstream_response or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_monitored_stream_marks_cancelled_unknown_when_disconnect_check_uncertain() -> (
|
||||
None
|
||||
):
|
||||
monitor = _DummyMonitor()
|
||||
monitor.CANCEL_DISCONNECT_RETRY_DELAYS_SECONDS = ()
|
||||
ctx = StreamContext(model="test-model", api_format="openai:cli", request_id="req-unknown")
|
||||
|
||||
request = _RequestStub([asyncio.TimeoutError()])
|
||||
monitored = monitor._create_monitored_stream(ctx, _cancel_immediately(), request)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
async for _ in monitored:
|
||||
pass
|
||||
|
||||
assert ctx.status_code == 503
|
||||
assert ctx.error_message == "cancelled_unknown"
|
||||
assert "cancel_origin=cancelled_unknown" in (ctx.upstream_response or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_monitored_stream_estimates_output_tokens_before_unknown_cancel_log() -> None:
|
||||
monitor = _DummyMonitor()
|
||||
monitor.CANCEL_DISCONNECT_RETRY_DELAYS_SECONDS = ()
|
||||
ctx = StreamContext(model="test-model", api_format="openai:cli", request_id="req-estimate")
|
||||
|
||||
request = _RequestStub([asyncio.TimeoutError()])
|
||||
monitored = monitor._create_monitored_stream(ctx, _yield_once_then_cancel(ctx), request)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
async for _ in monitored:
|
||||
pass
|
||||
|
||||
expected_output_tokens = max(1, len("partial output") // 4)
|
||||
assert ctx.status_code == 503
|
||||
assert ctx.error_message == "cancelled_unknown"
|
||||
assert ctx.output_tokens == expected_output_tokens
|
||||
assert f"output_tokens={expected_output_tokens}" in (ctx.upstream_response or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_monitored_stream_marks_idle_timeout_before_worker_timeout() -> None:
|
||||
monitor = _DummyMonitor()
|
||||
monitor.CANCEL_DISCONNECT_RETRY_DELAYS_SECONDS = ()
|
||||
monitor.STREAM_IDLE_TIMEOUT_SECONDS = 1.0
|
||||
ctx = StreamContext(model="test-model", api_format="openai:cli", request_id="req-idle-timeout")
|
||||
|
||||
monitored = monitor._create_monitored_stream(ctx, _yield_once_then_hang(ctx), None)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
async for _ in monitored:
|
||||
pass
|
||||
|
||||
expected_output_tokens = max(1, len("partial output") // 4)
|
||||
assert ctx.status_code == 504
|
||||
assert ctx.error_message == "stream_idle_timeout"
|
||||
assert ctx.output_tokens == expected_output_tokens
|
||||
assert "cancel_origin=stream_idle_timeout" in (ctx.upstream_response or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_monitored_stream_does_not_idle_timeout_before_first_chunk() -> None:
|
||||
monitor = _DummyMonitor()
|
||||
monitor.CANCEL_DISCONNECT_RETRY_DELAYS_SECONDS = ()
|
||||
monitor.STREAM_IDLE_TIMEOUT_SECONDS = 0.05
|
||||
ctx = StreamContext(model="test-model", api_format="openai:cli", request_id="req-first-chunk")
|
||||
|
||||
monitored = monitor._create_monitored_stream(ctx, _yield_after_delay_then_complete(), None)
|
||||
chunks = [chunk async for chunk in monitored]
|
||||
|
||||
assert chunks == [b"data: first\n\n"]
|
||||
assert ctx.status_code == 200
|
||||
assert ctx.error_message is None
|
||||
@@ -1,74 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.cli_stream_mixin import CliStreamMixin
|
||||
from src.api.handlers.base.cli_sync_mixin import CliSyncMixin
|
||||
|
||||
|
||||
class _StopExecution(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _DummySyncHandler(CliSyncMixin):
|
||||
FORMAT_ID = "openai:cli"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.allowed_api_formats = ["openai:compact"]
|
||||
self.primary_api_format = "openai:compact"
|
||||
self.pending_calls: list[dict[str, object]] = []
|
||||
|
||||
def extract_model_from_request(
|
||||
self, request_body: dict[str, object], path_params: dict[str, object] | None
|
||||
) -> str:
|
||||
return str(request_body.get("model") or "unknown")
|
||||
|
||||
def _create_pending_usage(self, **kwargs: object) -> bool:
|
||||
self.pending_calls.append(kwargs)
|
||||
raise _StopExecution()
|
||||
|
||||
|
||||
class _DummyStreamHandler(CliStreamMixin):
|
||||
FORMAT_ID = "openai:cli"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.allowed_api_formats = ["openai:compact"]
|
||||
self.primary_api_format = "openai:compact"
|
||||
self.pending_calls: list[dict[str, object]] = []
|
||||
|
||||
def extract_model_from_request(
|
||||
self, request_body: dict[str, object], path_params: dict[str, object] | None
|
||||
) -> str:
|
||||
return str(request_body.get("model") or "unknown")
|
||||
|
||||
def _create_pending_usage(self, **kwargs: object) -> bool:
|
||||
self.pending_calls.append(kwargs)
|
||||
raise _StopExecution()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_pending_usage_uses_primary_api_format() -> None:
|
||||
handler = _DummySyncHandler()
|
||||
|
||||
with pytest.raises(_StopExecution):
|
||||
await handler.process_sync( # type: ignore[misc]
|
||||
original_request_body={"model": "gpt-5.3-codex"},
|
||||
original_headers={},
|
||||
)
|
||||
|
||||
assert handler.pending_calls
|
||||
assert handler.pending_calls[0]["api_format"] == "openai:compact"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_pending_usage_uses_primary_api_format() -> None:
|
||||
handler = _DummyStreamHandler()
|
||||
|
||||
with pytest.raises(_StopExecution):
|
||||
await handler.process_stream( # type: ignore[misc]
|
||||
original_request_body={"model": "gpt-5.3-codex"},
|
||||
original_headers={},
|
||||
)
|
||||
|
||||
assert handler.pending_calls
|
||||
assert handler.pending_calls[0]["api_format"] == "openai:compact"
|
||||
@@ -1,155 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import src.api.handlers.base.cli_request_mixin as request_mixmod
|
||||
from src.api.handlers.base.cli_request_mixin import CliRequestMixin
|
||||
from src.api.handlers.base.cli_stream_mixin import CliStreamMixin
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.services.task.request_state import MutableRequestBodyState
|
||||
|
||||
|
||||
class _StopBuild(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _DummyAuthInfo:
|
||||
auth_header = "authorization"
|
||||
auth_value = "Bearer test"
|
||||
decrypted_auth_config = None
|
||||
|
||||
def as_tuple(self) -> tuple[str, str]:
|
||||
return self.auth_header, self.auth_value
|
||||
|
||||
|
||||
class _CaptureBuilder:
|
||||
def __init__(self) -> None:
|
||||
self.request_body: dict[str, Any] | None = None
|
||||
|
||||
def build(self, request_body: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
|
||||
self.request_body = request_body
|
||||
raise _StopBuild()
|
||||
|
||||
|
||||
class _DummyCliStreamHandler(CliRequestMixin, CliStreamMixin):
|
||||
FORMAT_ID = "openai:cli"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.primary_api_format = "openai:cli"
|
||||
self.request_id = "req-test"
|
||||
self.api_key = SimpleNamespace(id="user-key-1")
|
||||
self._request_builder = _CaptureBuilder()
|
||||
|
||||
async def _get_mapped_model(self, source_model: str, provider_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
def apply_mapped_model(self, request_body: dict[str, Any], mapped_model: str) -> dict[str, Any]:
|
||||
out = dict(request_body)
|
||||
out["model"] = mapped_model
|
||||
return out
|
||||
|
||||
def prepare_provider_request_body(self, request_body: dict[str, Any]) -> dict[str, Any]:
|
||||
request_body["input"][0]["content"][0]["text"] = "prepared"
|
||||
return request_body
|
||||
|
||||
def finalize_provider_request(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
*,
|
||||
mapped_model: str | None,
|
||||
provider_api_format: str | None,
|
||||
) -> dict[str, Any]:
|
||||
request_body["input"][0]["content"].append({"type": "input_text", "text": "finalized"})
|
||||
return request_body
|
||||
|
||||
def get_model_for_url(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
mapped_model: str | None,
|
||||
) -> str | None:
|
||||
return mapped_model or str(request_body.get("model") or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_stream_request_does_not_mutate_original_request_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
|
||||
return _DummyAuthInfo()
|
||||
|
||||
monkeypatch.setattr(request_mixmod, "get_provider_auth", _fake_get_provider_auth)
|
||||
monkeypatch.setattr(
|
||||
request_mixmod,
|
||||
"get_provider_behavior",
|
||||
lambda **kwargs: SimpleNamespace(
|
||||
envelope=None,
|
||||
same_format_variant=None,
|
||||
cross_format_variant=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(request_mixmod, "get_upstream_stream_policy", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
request_mixmod,
|
||||
"resolve_upstream_is_stream",
|
||||
lambda *, client_is_stream, policy: client_is_stream,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
request_mixmod,
|
||||
"enforce_stream_mode_for_upstream",
|
||||
lambda *args, **kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
request_mixmod,
|
||||
"maybe_patch_request_with_prompt_cache_key",
|
||||
lambda request_body, **kwargs: request_body,
|
||||
)
|
||||
|
||||
handler = _DummyCliStreamHandler()
|
||||
ctx = StreamContext(model="gpt-test", api_format="openai:cli")
|
||||
ctx.client_api_format = "openai:cli"
|
||||
|
||||
provider = SimpleNamespace(name="provider", id="provider-1", provider_type="", proxy=None)
|
||||
endpoint = SimpleNamespace(
|
||||
id="endpoint-1",
|
||||
api_format="openai:cli",
|
||||
base_url="https://x",
|
||||
custom_path=None,
|
||||
)
|
||||
key = SimpleNamespace(id="key-1", proxy=None)
|
||||
candidate = SimpleNamespace(
|
||||
mapping_matched_model=None, needs_conversion=False, output_limit=None
|
||||
)
|
||||
|
||||
original_request_body = {
|
||||
"model": "gpt-test",
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "hello"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
snapshot = copy.deepcopy(original_request_body)
|
||||
request_state = MutableRequestBodyState(original_request_body)
|
||||
|
||||
with pytest.raises(_StopBuild):
|
||||
await handler._execute_stream_request(
|
||||
ctx,
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
request_state.build_attempt_body(),
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
|
||||
assert original_request_body == snapshot
|
||||
assert handler._request_builder.request_body is not None
|
||||
assert handler._request_builder.request_body["input"][0]["content"][0]["text"] == "prepared"
|
||||
assert handler._request_builder.request_body["input"][0]["content"][-1]["text"] == "finalized"
|
||||
@@ -1,821 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import src.api.handlers.base.cli_stream_mixin as cli_stream_mod
|
||||
import src.api.handlers.base.cli_sync_mixin as cli_sync_mod
|
||||
import src.services.proxy_node.resolver as proxymod
|
||||
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.execution_runtime_client import (
|
||||
ExecutionRuntimeClientError,
|
||||
ExecutionRuntimeStreamResult,
|
||||
ExecutionRuntimeSyncResult,
|
||||
)
|
||||
|
||||
|
||||
class _DummyParser:
|
||||
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
|
||||
del response
|
||||
return {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_creation_tokens": 0,
|
||||
}
|
||||
|
||||
def extract_text_content(self, response: dict[str, Any]) -> str:
|
||||
return str(response.get("id") or "")
|
||||
|
||||
|
||||
class _DummyTelemetry:
|
||||
async def record_success(self, **kwargs: Any) -> int:
|
||||
del kwargs
|
||||
return 0
|
||||
|
||||
async def record_failure(self, **kwargs: Any) -> None:
|
||||
del kwargs
|
||||
|
||||
|
||||
class _DummySyncHandler(CliSyncMixin):
|
||||
FORMAT_ID = "openai:cli"
|
||||
|
||||
def __init__(self, *, upstream_is_stream: bool = False) -> None:
|
||||
self.db = None
|
||||
self.redis = None
|
||||
self.user = SimpleNamespace(id="user-1")
|
||||
self.api_key = SimpleNamespace(id="user-key-1")
|
||||
self.request_id = "req-cli-sync"
|
||||
self.client_ip = "127.0.0.1"
|
||||
self.user_agent = "pytest"
|
||||
self.start_time = 0.0
|
||||
self.allowed_api_formats = ["openai:cli"]
|
||||
self.primary_api_format = "openai:cli"
|
||||
self.api_family = None
|
||||
self.endpoint_kind = None
|
||||
self.telemetry = _DummyTelemetry()
|
||||
self.perf_metrics = None
|
||||
self._parser = _DummyParser()
|
||||
self._upstream_is_stream = upstream_is_stream
|
||||
|
||||
@property
|
||||
def parser(self) -> _DummyParser:
|
||||
return self._parser
|
||||
|
||||
def _create_pending_usage(self, **kwargs: object) -> bool:
|
||||
del kwargs
|
||||
return True
|
||||
|
||||
def _build_request_metadata(self, http_request: Any | None = None) -> dict[str, Any]:
|
||||
del http_request
|
||||
return {}
|
||||
|
||||
def _merge_scheduling_metadata(
|
||||
self,
|
||||
request_metadata: dict[str, Any] | None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
del kwargs
|
||||
return dict(request_metadata or {})
|
||||
|
||||
def _resolve_capability_requirements(
|
||||
self,
|
||||
model_name: str,
|
||||
request_headers: dict[str, str] | None = None,
|
||||
request_body: dict[str, Any] | None = None,
|
||||
) -> dict[str, bool]:
|
||||
del model_name, request_headers, request_body
|
||||
return {}
|
||||
|
||||
async def _resolve_preferred_key_ids(
|
||||
self,
|
||||
model_name: str,
|
||||
request_body: dict[str, Any] | None = None,
|
||||
) -> list[str] | None:
|
||||
del model_name, request_body
|
||||
return None
|
||||
|
||||
def extract_model_from_request(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
del path_params
|
||||
return str(request_body.get("model") or "unknown")
|
||||
|
||||
async def _get_mapped_model(self, source_model: str, provider_id: str) -> str | None:
|
||||
del source_model, provider_id
|
||||
return None
|
||||
|
||||
async def _build_upstream_request(self, **kwargs: Any) -> Any:
|
||||
payload = dict(kwargs["request_body"])
|
||||
return SimpleNamespace(
|
||||
payload=payload,
|
||||
headers={"content-type": "application/json"},
|
||||
url="https://upstream.test/v1/responses",
|
||||
url_model=str(payload.get("model") or ""),
|
||||
envelope=None,
|
||||
upstream_is_stream=self._upstream_is_stream,
|
||||
tls_profile=None,
|
||||
selected_base_url=None,
|
||||
)
|
||||
|
||||
def _extract_response_metadata(self, response_json: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"id": response_json.get("id")}
|
||||
|
||||
|
||||
class _DummyStreamResponseCtx:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _DummyCliStreamHandler(CliStreamMixin):
|
||||
FORMAT_ID = "openai:cli"
|
||||
|
||||
def __init__(self, *, upstream_is_stream: bool) -> None:
|
||||
self.request_id = "req-cli-stream"
|
||||
self.api_key = SimpleNamespace(id="user-key-1")
|
||||
self._upstream_is_stream = upstream_is_stream
|
||||
|
||||
async def _get_mapped_model(self, source_model: str, provider_id: str) -> str | None:
|
||||
del source_model, provider_id
|
||||
return None
|
||||
|
||||
async def _build_upstream_request(self, **kwargs: Any) -> Any:
|
||||
payload = dict(kwargs["request_body"])
|
||||
return SimpleNamespace(
|
||||
payload=payload,
|
||||
headers={"content-type": "application/json"},
|
||||
url="https://upstream.test/v1/responses",
|
||||
url_model=str(payload.get("model") or ""),
|
||||
envelope=None,
|
||||
upstream_is_stream=self._upstream_is_stream,
|
||||
tls_profile=None,
|
||||
selected_base_url=None,
|
||||
)
|
||||
|
||||
def apply_mapped_model(self, request_body: dict[str, Any], mapped_model: str) -> dict[str, Any]:
|
||||
out = dict(request_body)
|
||||
out["model"] = mapped_model
|
||||
return out
|
||||
|
||||
def _extract_response_metadata(self, response_json: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"id": response_json.get("id")}
|
||||
|
||||
def _record_converted_chunks(self, ctx: Any, converted_events: Any) -> None:
|
||||
del ctx, converted_events
|
||||
|
||||
def _mark_first_output(self, ctx: Any, output_state: dict[str, Any]) -> None:
|
||||
del ctx
|
||||
output_state["first_yield"] = False
|
||||
|
||||
async def _prefetch_and_check_embedded_error(
|
||||
self,
|
||||
byte_iterator: Any,
|
||||
provider: Any,
|
||||
endpoint: Any,
|
||||
ctx: Any,
|
||||
) -> list[bytes]:
|
||||
del provider, endpoint, ctx
|
||||
first = await anext(byte_iterator)
|
||||
return [first]
|
||||
|
||||
async def _create_response_stream_with_prefetch(
|
||||
self,
|
||||
ctx: Any,
|
||||
byte_iterator: Any,
|
||||
response_ctx: _DummyStreamResponseCtx,
|
||||
prefetched_chunks: list[bytes],
|
||||
) -> AsyncGenerator[bytes]:
|
||||
del ctx
|
||||
|
||||
async def _gen() -> AsyncGenerator[bytes]:
|
||||
try:
|
||||
for chunk in prefetched_chunks:
|
||||
yield chunk
|
||||
async for chunk in byte_iterator:
|
||||
yield chunk
|
||||
finally:
|
||||
await response_ctx.__aexit__(None, None, None)
|
||||
|
||||
return _gen()
|
||||
|
||||
|
||||
async def _iter_chunks(chunks: list[bytes]) -> AsyncGenerator[bytes]:
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
def _patch_proxy_resolver(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(proxymod, "resolve_effective_proxy", lambda provider_proxy, key_proxy=None: None)
|
||||
monkeypatch.setattr(proxymod, "get_proxy_label", lambda proxy_info: "direct")
|
||||
|
||||
async def _fake_resolve_proxy_info(proxy_config: Any) -> Any:
|
||||
del proxy_config
|
||||
return None
|
||||
|
||||
async def _fake_resolve_delegate(proxy_config: Any) -> Any:
|
||||
del proxy_config
|
||||
return None
|
||||
|
||||
async def _fake_build_proxy_url(proxy_config: Any) -> Any:
|
||||
del proxy_config
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(proxymod, "resolve_proxy_info_async", _fake_resolve_proxy_info)
|
||||
monkeypatch.setattr(proxymod, "resolve_delegate_config_async", _fake_resolve_delegate)
|
||||
monkeypatch.setattr(proxymod, "build_proxy_url_async", _fake_build_proxy_url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_process_sync_uses_rust_executor_when_available(
|
||||
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)
|
||||
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,
|
||||
)
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> ExecutionRuntimeSyncResult:
|
||||
assert getattr(plan, "provider_api_format") == "openai:cli"
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"id": "resp-rust-cli"},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(taskmod, "TaskService", _FakeTaskService)
|
||||
monkeypatch.setattr(
|
||||
cli_sync_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
|
||||
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) == {"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) -> ExecutionRuntimeSyncResult:
|
||||
assert getattr(plan, "provider_api_format") == "openai:cli"
|
||||
assert getattr(plan, "stream") is True
|
||||
return ExecutionRuntimeSyncResult(
|
||||
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.ExecutionRuntimeClient,
|
||||
"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) -> ExecutionRuntimeSyncResult:
|
||||
del self, plan
|
||||
raise ExecutionRuntimeClientError("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.ExecutionRuntimeClient,
|
||||
"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_execution_runtime_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) -> ExecutionRuntimeSyncResult:
|
||||
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.ExecutionRuntimeClient,
|
||||
"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,
|
||||
) -> None:
|
||||
handler = _DummyCliStreamHandler(upstream_is_stream=False)
|
||||
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_execute_sync_json(self: object, plan: object) -> ExecutionRuntimeSyncResult:
|
||||
assert getattr(plan, "stream") is False
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"id": "sync-bridge-rust"},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async def _fake_streamify(**kwargs: Any) -> AsyncGenerator[bytes]:
|
||||
assert kwargs["response_json"] == {"id": "sync-bridge-rust"}
|
||||
yield b"data: cli-bridge\n\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(handler, "_streamify_sync_response", _fake_streamify)
|
||||
|
||||
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, auth_type="", api_key="sk-test")
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
|
||||
stream = await handler._execute_stream_request(
|
||||
ctx,
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
{"model": "gpt-test", "input": "hello"},
|
||||
{},
|
||||
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) -> ExecutionRuntimeStreamResult:
|
||||
del self, plan
|
||||
raise ExecutionRuntimeClientError("executor down")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.ExecutionRuntimeClient,
|
||||
"execute_stream",
|
||||
_fake_execute_stream,
|
||||
)
|
||||
else:
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> ExecutionRuntimeSyncResult:
|
||||
del self, plan
|
||||
raise ExecutionRuntimeClientError("executor down")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.ExecutionRuntimeClient,
|
||||
"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_execution_runtime_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) -> ExecutionRuntimeSyncResult:
|
||||
raise AssertionError("rust sync executor should not be used when contract is ineligible")
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
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.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.ExecutionRuntimeClient,
|
||||
"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,
|
||||
) -> None:
|
||||
handler = _DummyCliStreamHandler(upstream_is_stream=True)
|
||||
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_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "stream") is True
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream", "x-upstream-test": "true"},
|
||||
byte_iterator=_iter_chunks(
|
||||
[
|
||||
b"data: {\"id\":\"chunk-1\"}\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
),
|
||||
response_ctx=_DummyStreamResponseCtx(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli_stream_mod.ExecutionRuntimeClient,
|
||||
"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")
|
||||
candidate = SimpleNamespace(
|
||||
request_candidate_id="cand-1",
|
||||
mapping_matched_model=None,
|
||||
needs_conversion=False,
|
||||
output_limit=None,
|
||||
)
|
||||
|
||||
stream = await handler._execute_stream_request(
|
||||
ctx,
|
||||
provider,
|
||||
endpoint,
|
||||
key,
|
||||
{"model": "gpt-test", "input": "hello"},
|
||||
{},
|
||||
candidate=candidate,
|
||||
)
|
||||
if hasattr(stream, "__await__"):
|
||||
stream = await stream
|
||||
chunks = [chunk async for chunk in stream]
|
||||
|
||||
assert chunks == [
|
||||
b"data: {\"id\":\"chunk-1\"}\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
assert ctx.status_code == 200
|
||||
assert ctx.response_headers["x-upstream-test"] == "true"
|
||||
@@ -1,32 +0,0 @@
|
||||
import json
|
||||
|
||||
from src.api.handlers.base.cli_sse_helpers import _format_converted_events_to_sse
|
||||
|
||||
|
||||
def test_format_converted_events_to_sse_uses_event_lines_for_openai_cli() -> None:
|
||||
events = [
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": "msg_123",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": "Hi",
|
||||
"logprobs": [],
|
||||
"sequence_number": 1,
|
||||
}
|
||||
]
|
||||
|
||||
lines = _format_converted_events_to_sse(events, "openai:cli")
|
||||
|
||||
assert lines == [
|
||||
"event: response.output_text.delta\n"
|
||||
f"data: {json.dumps(events[0], ensure_ascii=False)}\n"
|
||||
]
|
||||
|
||||
|
||||
def test_format_converted_events_to_sse_keeps_data_only_for_openai_chat() -> None:
|
||||
events = [{"id": "chatcmpl-123", "object": "chat.completion.chunk", "choices": []}]
|
||||
|
||||
lines = _format_converted_events_to_sse(events, "openai:chat")
|
||||
|
||||
assert lines == [f"data: {json.dumps(events[0], ensure_ascii=False)}\n"]
|
||||
@@ -1,264 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import src.api.handlers.base.cli_request_mixin as mixmod
|
||||
from src.api.handlers.base.cli_request_mixin import CliRequestMixin
|
||||
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
|
||||
from src.core.api_format.metadata import CODEX_DEFAULT_BODY_RULES
|
||||
from src.services.provider.adapters.codex.context import (
|
||||
CodexRequestContext,
|
||||
set_codex_request_context,
|
||||
)
|
||||
from src.services.provider.prompt_cache import build_stable_codex_prompt_cache_key
|
||||
|
||||
|
||||
class _DummyAuthInfo:
|
||||
def __init__(self, decrypted_auth_config: dict[str, Any] | None = None) -> None:
|
||||
self.auth_header = "Authorization"
|
||||
self.auth_value = "Bearer upstream-token"
|
||||
self.decrypted_auth_config = decrypted_auth_config
|
||||
|
||||
def as_tuple(self) -> tuple[str, str]:
|
||||
return self.auth_header, self.auth_value
|
||||
|
||||
|
||||
class _DummyCliRequestHandler(CliRequestMixin):
|
||||
FORMAT_ID = "openai:cli"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.api_key = SimpleNamespace(id="user-key-123")
|
||||
self._request_builder = PassthroughRequestBuilder()
|
||||
|
||||
|
||||
def _build_codex_provider() -> Any:
|
||||
return SimpleNamespace(
|
||||
id="provider-1",
|
||||
provider_type="codex",
|
||||
config=None,
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def _build_codex_endpoint(*, api_format: str) -> Any:
|
||||
provider = _build_codex_provider()
|
||||
return SimpleNamespace(
|
||||
id="endpoint-1",
|
||||
api_family="openai",
|
||||
endpoint_kind="cli",
|
||||
api_format=api_format,
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
custom_path=None,
|
||||
body_rules=list(CODEX_DEFAULT_BODY_RULES),
|
||||
header_rules=None,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
def _build_key() -> Any:
|
||||
return SimpleNamespace(id="key-1", api_key="unused", proxy=None)
|
||||
|
||||
|
||||
def _build_headers() -> dict[str, str]:
|
||||
return {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "Codex Desktop/0.108.0-alpha.12",
|
||||
"originator": "Codex Desktop",
|
||||
"x-codex-turn-metadata": '{"turn_id":"abc"}',
|
||||
"host": "aether.hetunai.cn",
|
||||
"content-length": "123",
|
||||
"x-forwarded-scheme": "https",
|
||||
}
|
||||
|
||||
|
||||
def _assert_common_codex_headers(headers: dict[str, str]) -> None:
|
||||
assert headers["accept"] == "application/json"
|
||||
assert headers["content-type"] == "application/json"
|
||||
assert headers["user-agent"] == "Codex Desktop/0.108.0-alpha.12"
|
||||
assert headers["originator"] == "Codex Desktop"
|
||||
assert headers["x-codex-turn-metadata"] == '{"turn_id":"abc"}'
|
||||
assert headers["Authorization"] == "Bearer upstream-token"
|
||||
assert "host" not in headers
|
||||
assert "content-length" not in headers
|
||||
assert "x-forwarded-scheme" not in headers
|
||||
|
||||
|
||||
def _expected_codex_header_id(prompt_cache_key: str) -> str:
|
||||
return hashlib.sha256(prompt_cache_key.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_upstream_request_codex_cli_injects_prompt_cache_and_forces_stream(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
|
||||
return _DummyAuthInfo()
|
||||
|
||||
monkeypatch.setattr(mixmod, "get_provider_auth", _fake_get_provider_auth)
|
||||
|
||||
handler = _DummyCliRequestHandler()
|
||||
endpoint = _build_codex_endpoint(api_format="openai:cli")
|
||||
provider = endpoint.provider
|
||||
key = _build_key()
|
||||
|
||||
result = await handler._build_upstream_request(
|
||||
provider=provider,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
request_body={
|
||||
"model": "gpt-5",
|
||||
"input": [],
|
||||
"stream": False,
|
||||
"max_output_tokens": 4096,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.8,
|
||||
},
|
||||
original_headers=_build_headers(),
|
||||
query_params=None,
|
||||
client_api_format="openai:cli",
|
||||
provider_api_format="openai:cli",
|
||||
fallback_model="gpt-5",
|
||||
mapped_model=None,
|
||||
client_is_stream=False,
|
||||
)
|
||||
|
||||
assert result.url == "https://chatgpt.com/backend-api/codex/responses"
|
||||
assert result.upstream_is_stream is True
|
||||
assert result.payload["stream"] is True
|
||||
assert result.payload["instructions"] == "You are GPT-5."
|
||||
assert result.payload["store"] is False
|
||||
assert result.payload["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123")
|
||||
assert "max_output_tokens" not in result.payload
|
||||
assert "temperature" not in result.payload
|
||||
assert "top_p" not in result.payload
|
||||
short_id = _expected_codex_header_id(result.payload["prompt_cache_key"])
|
||||
assert result.headers["session_id"] == short_id
|
||||
assert result.headers["conversation_id"] == short_id
|
||||
_assert_common_codex_headers(result.headers)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_upstream_request_codex_compact_drops_stream_and_injects_prompt_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
|
||||
return _DummyAuthInfo({"account_id": "acc-1"})
|
||||
|
||||
monkeypatch.setattr(mixmod, "get_provider_auth", _fake_get_provider_auth)
|
||||
|
||||
handler = _DummyCliRequestHandler()
|
||||
endpoint = _build_codex_endpoint(api_format="openai:compact")
|
||||
provider = endpoint.provider
|
||||
key = _build_key()
|
||||
|
||||
result = await handler._build_upstream_request(
|
||||
provider=provider,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
request_body={"model": "gpt-5", "input": [], "stream": True},
|
||||
original_headers=_build_headers(),
|
||||
query_params=None,
|
||||
client_api_format="openai:cli",
|
||||
provider_api_format="openai:compact",
|
||||
fallback_model="gpt-5",
|
||||
mapped_model=None,
|
||||
client_is_stream=False,
|
||||
)
|
||||
|
||||
assert result.url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
assert result.upstream_is_stream is False
|
||||
assert "stream" not in result.payload
|
||||
assert result.payload["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123")
|
||||
assert result.payload["instructions"] == "You are GPT-5."
|
||||
assert result.payload["store"] is False
|
||||
short_id = _expected_codex_header_id(result.payload["prompt_cache_key"])
|
||||
assert result.headers["chatgpt-account-id"] == "acc-1"
|
||||
assert result.headers["session_id"] == short_id
|
||||
assert "conversation_id" not in result.headers
|
||||
_assert_common_codex_headers(result.headers)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_upstream_request_legacy_codex_compact_context_uses_compact_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
|
||||
return _DummyAuthInfo()
|
||||
|
||||
monkeypatch.setattr(mixmod, "get_provider_auth", _fake_get_provider_auth)
|
||||
|
||||
handler = _DummyCliRequestHandler()
|
||||
endpoint = _build_codex_endpoint(api_format="openai:cli")
|
||||
provider = endpoint.provider
|
||||
key = _build_key()
|
||||
|
||||
try:
|
||||
set_codex_request_context(CodexRequestContext(is_compact=True))
|
||||
result = await handler._build_upstream_request(
|
||||
provider=provider,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
request_body={"model": "gpt-5", "input": [], "stream": True},
|
||||
original_headers=_build_headers(),
|
||||
query_params=None,
|
||||
client_api_format="openai:cli",
|
||||
provider_api_format="openai:cli",
|
||||
fallback_model="gpt-5",
|
||||
mapped_model=None,
|
||||
client_is_stream=False,
|
||||
)
|
||||
finally:
|
||||
set_codex_request_context(None)
|
||||
|
||||
assert result.url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
assert result.upstream_is_stream is False
|
||||
assert "stream" not in result.payload
|
||||
assert result.payload["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123")
|
||||
assert result.payload["instructions"] == "You are GPT-5."
|
||||
short_id = _expected_codex_header_id(result.payload["prompt_cache_key"])
|
||||
assert result.headers["session_id"] == short_id
|
||||
assert "conversation_id" not in result.headers
|
||||
_assert_common_codex_headers(result.headers)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_upstream_request_codex_cli_preserves_explicit_prompt_cache_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
|
||||
return _DummyAuthInfo()
|
||||
|
||||
monkeypatch.setattr(mixmod, "get_provider_auth", _fake_get_provider_auth)
|
||||
|
||||
handler = _DummyCliRequestHandler()
|
||||
endpoint = _build_codex_endpoint(api_format="openai:cli")
|
||||
provider = endpoint.provider
|
||||
key = _build_key()
|
||||
|
||||
result = await handler._build_upstream_request(
|
||||
provider=provider,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
request_body={
|
||||
"model": "gpt-5",
|
||||
"input": [],
|
||||
"prompt_cache_key": "client-cache-key",
|
||||
},
|
||||
original_headers=_build_headers(),
|
||||
query_params=None,
|
||||
client_api_format="openai:cli",
|
||||
provider_api_format="openai:cli",
|
||||
fallback_model="gpt-5",
|
||||
mapped_model=None,
|
||||
client_is_stream=False,
|
||||
)
|
||||
|
||||
assert result.url == "https://chatgpt.com/backend-api/codex/responses"
|
||||
assert result.payload["prompt_cache_key"] == "client-cache-key"
|
||||
assert result.payload["stream"] is True
|
||||
_assert_common_codex_headers(result.headers)
|
||||
@@ -1,192 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.endpoint_checker import EndpointCheckRequest, HttpRequestExecutor
|
||||
from src.services.request.execution_runtime_client import (
|
||||
ExecutionRuntimeStreamResult,
|
||||
ExecutionRuntimeSyncResult,
|
||||
)
|
||||
|
||||
|
||||
class _DummyStreamContext:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_checker_sync_prefers_rust_executor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.handlers.base import endpoint_checker as mod
|
||||
from src.services.request import execution_runtime_client as rust_mod
|
||||
|
||||
monkeypatch.setattr(mod.config, "executor_backend", "rust")
|
||||
|
||||
executor = HttpRequestExecutor(timeout=15.0)
|
||||
proxy_snapshot = object()
|
||||
monkeypatch.setattr(
|
||||
executor,
|
||||
"_build_rust_proxy_snapshot",
|
||||
AsyncMock(return_value=proxy_snapshot),
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _fake_execute_sync_json(
|
||||
self: object,
|
||||
plan: Any,
|
||||
) -> ExecutionRuntimeSyncResult:
|
||||
captured["plan"] = plan
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
response_json={"id": "resp_1", "usage": {"prompt_tokens": 1, "completion_tokens": 2}},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rust_mod.ExecutionRuntimeClient, "execute_sync_json", _fake_execute_sync_json)
|
||||
|
||||
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",
|
||||
api_key_id="key_1",
|
||||
provider_id="provider_1",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status_code == 200
|
||||
assert result.response_data == {
|
||||
"id": "resp_1",
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 2},
|
||||
}
|
||||
assert captured["plan"].proxy is proxy_snapshot
|
||||
assert captured["plan"].method == "POST"
|
||||
assert captured["plan"].url == "https://upstream.test/v1/chat/completions"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_checker_stream_prefers_rust_executor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.handlers.base import endpoint_checker as mod
|
||||
from src.services.request import execution_runtime_client as rust_mod
|
||||
|
||||
monkeypatch.setattr(mod.config, "executor_backend", "rust")
|
||||
|
||||
executor = HttpRequestExecutor(timeout=15.0)
|
||||
monkeypatch.setattr(
|
||||
executor,
|
||||
"_build_rust_proxy_snapshot",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
async def _byte_iter() -> Any:
|
||||
yield b'data: {"choices":[{"delta":{"content":"Hel'
|
||||
yield b'lo"}}]}\n\n'
|
||||
yield b'data: {"choices":[{"delta":{"content":" world"},"finish_reason":"stop"}]}\n\n'
|
||||
|
||||
stream_ctx = _DummyStreamContext()
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: Any) -> ExecutionRuntimeStreamResult:
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
byte_iterator=_byte_iter(),
|
||||
response_ctx=stream_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rust_mod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
|
||||
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"}],
|
||||
"stream": True,
|
||||
},
|
||||
api_format="openai:chat",
|
||||
provider_name="openai",
|
||||
model_name="gpt-test",
|
||||
is_stream=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status_code == 200
|
||||
assert result.response_data == {
|
||||
"choices": [{"delta": {"content": " world"}, "finish_reason": "stop"}]
|
||||
}
|
||||
assert stream_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_checker_proxy_snapshot_falls_back_to_system_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.proxy_node import resolver as resolver_mod
|
||||
|
||||
executor = HttpRequestExecutor()
|
||||
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"get_system_proxy_config_async",
|
||||
AsyncMock(return_value={"enabled": True, "url": "http://system-proxy.test:8080"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"resolve_delegate_config_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"build_proxy_url_async",
|
||||
AsyncMock(return_value="http://system-proxy.test:8080"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"resolve_proxy_info_async",
|
||||
AsyncMock(return_value={"mode": "http", "label": "system-proxy"}),
|
||||
)
|
||||
|
||||
snapshot = await executor._build_rust_proxy_snapshot(None)
|
||||
|
||||
assert snapshot is not None
|
||||
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"
|
||||
@@ -1,113 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.api.handlers.base.parsers import OpenAICliResponseParser, OpenAIResponseParser
|
||||
from src.api.handlers.base.response_parser import (
|
||||
ParsedChunk,
|
||||
ParsedResponse,
|
||||
ResponseParser,
|
||||
StreamStats,
|
||||
)
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
|
||||
|
||||
class _DummyParser(ResponseParser):
|
||||
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
|
||||
return None
|
||||
|
||||
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
|
||||
return ParsedResponse(raw_response=response, status_code=status_code)
|
||||
|
||||
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
def extract_text_content(self, response: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def test_openai_response_parser_extracts_cached_tokens_from_prompt_tokens_details() -> None:
|
||||
parser = OpenAIResponseParser()
|
||||
|
||||
usage = parser.extract_usage_from_response(
|
||||
{
|
||||
"usage": {
|
||||
"prompt_tokens": 120,
|
||||
"completion_tokens": 18,
|
||||
"prompt_tokens_details": {"cached_tokens": 96},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert usage["input_tokens"] == 120
|
||||
assert usage["output_tokens"] == 18
|
||||
assert usage["cache_read_tokens"] == 96
|
||||
|
||||
|
||||
def test_openai_cli_response_parser_extracts_cached_tokens_from_input_tokens_details() -> None:
|
||||
parser = OpenAICliResponseParser()
|
||||
|
||||
usage = parser.extract_usage_from_response(
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"usage": {
|
||||
"input_tokens": 2048,
|
||||
"output_tokens": 128,
|
||||
"input_tokens_details": {"cached_tokens": 1792},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert usage["input_tokens"] == 2048
|
||||
assert usage["output_tokens"] == 128
|
||||
assert usage["cache_read_tokens"] == 1792
|
||||
|
||||
|
||||
def test_stream_processor_extracts_cached_tokens_from_openai_cli_converted_event() -> None:
|
||||
processor = StreamProcessor(request_id="req_test", default_parser=_DummyParser())
|
||||
ctx = StreamContext(model="gpt-5", api_format="openai:chat")
|
||||
|
||||
processor._extract_usage_from_converted_event(
|
||||
ctx,
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"usage": {
|
||||
"input_tokens": 4096,
|
||||
"output_tokens": 64,
|
||||
"input_tokens_details": {"cached_tokens": 3584},
|
||||
}
|
||||
},
|
||||
},
|
||||
"response.completed",
|
||||
)
|
||||
|
||||
assert ctx.input_tokens == 4096
|
||||
assert ctx.output_tokens == 64
|
||||
assert ctx.cached_tokens == 3584
|
||||
|
||||
|
||||
def test_stream_processor_extracts_cached_tokens_from_openai_chat_converted_event() -> None:
|
||||
processor = StreamProcessor(request_id="req_test", default_parser=_DummyParser())
|
||||
ctx = StreamContext(model="gpt-5", api_format="openai:chat")
|
||||
|
||||
processor._extract_usage_from_converted_event(
|
||||
ctx,
|
||||
{
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [],
|
||||
"usage": {
|
||||
"prompt_tokens": 512,
|
||||
"completion_tokens": 21,
|
||||
"prompt_tokens_details": {"cached_tokens": 480},
|
||||
},
|
||||
},
|
||||
"chat.completion.chunk",
|
||||
)
|
||||
|
||||
assert ctx.input_tokens == 512
|
||||
assert ctx.output_tokens == 21
|
||||
assert ctx.cached_tokens == 480
|
||||
@@ -1,32 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.services.task.request_state import MutableRequestBodyState
|
||||
|
||||
|
||||
def test_mutable_request_body_state_keeps_original_and_attempts_isolated() -> None:
|
||||
original = {
|
||||
"model": "gpt-5",
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
|
||||
}
|
||||
|
||||
state = MutableRequestBodyState(original)
|
||||
|
||||
first_attempt = state.build_attempt_body()
|
||||
first_attempt["input"][0]["content"][0]["text"] = "attempt-1"
|
||||
|
||||
assert original["input"][0]["content"][0]["text"] == "hello"
|
||||
assert state.current_body["input"][0]["content"][0]["text"] == "hello"
|
||||
|
||||
rectified = state.build_attempt_body()
|
||||
rectified["input"][0]["content"][0]["text"] = "rectified"
|
||||
state.mark_rectified(rectified, stage=1)
|
||||
|
||||
second_attempt = state.build_attempt_body()
|
||||
second_attempt["input"][0]["content"][0]["text"] = "attempt-2"
|
||||
|
||||
assert state.is_rectified() is True
|
||||
assert state.rectify_stage() == 1
|
||||
assert state.current_body["input"][0]["content"][0]["text"] == "rectified"
|
||||
assert original["input"][0]["content"][0]["text"] == "hello"
|
||||
assert state.consume_rectified_this_turn() is True
|
||||
assert state.consume_rectified_this_turn() is False
|
||||
@@ -1,197 +0,0 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
|
||||
|
||||
from src.api.handlers.base import stream_context
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
|
||||
|
||||
def test_collected_text_append_and_property() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
assert ctx.collected_text == ""
|
||||
assert ctx.collected_text_length == 0
|
||||
|
||||
ctx.append_text("hello")
|
||||
ctx.append_text(" ")
|
||||
ctx.append_text("world")
|
||||
assert ctx.collected_text == "hello world"
|
||||
assert ctx.collected_text_length == len("hello world")
|
||||
|
||||
|
||||
def test_collected_text_is_capped_but_total_length_is_preserved() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
cap = stream_context._MAX_COLLECTED_TEXT_CHARS
|
||||
|
||||
ctx.append_text("a" * (cap - 4))
|
||||
ctx.append_text("b" * 10)
|
||||
|
||||
assert len(ctx.collected_text) == cap
|
||||
assert ctx.collected_text == ("a" * (cap - 4)) + ("b" * 4)
|
||||
assert ctx.collected_text_length == cap + 6
|
||||
|
||||
|
||||
def test_reset_for_retry_clears_state() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.append_text("x")
|
||||
ctx.update_usage(input_tokens=10, output_tokens=5)
|
||||
ctx.parsed_chunks.append({"type": "chunk"})
|
||||
ctx.chunk_count = 3
|
||||
ctx.data_count = 2
|
||||
ctx.has_completion = True
|
||||
ctx.status_code = 418
|
||||
ctx.error_message = "boom"
|
||||
|
||||
ctx.reset_for_retry()
|
||||
|
||||
assert ctx.collected_text == ""
|
||||
assert ctx.input_tokens == 0
|
||||
assert ctx.output_tokens == 0
|
||||
assert ctx.parsed_chunks == []
|
||||
assert ctx.chunk_count == 0
|
||||
assert ctx.data_count == 0
|
||||
assert ctx.has_completion is False
|
||||
assert ctx.status_code == 200
|
||||
assert ctx.error_message is None
|
||||
|
||||
|
||||
def test_release_recorded_chunks_clears_both_chunk_lists() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.parsed_chunks.append({"type": "client"})
|
||||
ctx.provider_parsed_chunks.append({"type": "provider"})
|
||||
|
||||
ctx.release_recorded_chunks()
|
||||
|
||||
assert ctx.parsed_chunks == []
|
||||
assert ctx.provider_parsed_chunks == []
|
||||
|
||||
|
||||
def test_managed_recorded_bodies_builds_then_releases_chunks() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.parsed_chunks.append({"type": "client"})
|
||||
ctx.provider_parsed_chunks.append({"type": "provider"})
|
||||
ctx.data_count = 1
|
||||
|
||||
with ctx.managed_recorded_bodies(123) as recorded_bodies:
|
||||
assert recorded_bodies.response_body is not None
|
||||
assert recorded_bodies.response_body["chunks"] == [{"type": "provider"}]
|
||||
assert recorded_bodies.client_response_body is not None
|
||||
assert recorded_bodies.client_response_body["chunks"] == [{"type": "client"}]
|
||||
|
||||
assert ctx.parsed_chunks == []
|
||||
assert ctx.provider_parsed_chunks == []
|
||||
assert recorded_bodies.response_body is None
|
||||
assert recorded_bodies.client_response_body is None
|
||||
|
||||
|
||||
def test_record_first_byte_time(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""测试记录首字时间"""
|
||||
ctx = StreamContext(model="claude-3", api_format="claude_messages")
|
||||
start_time = 100.0
|
||||
monkeypatch.setattr(stream_context.time, "time", lambda: 100.0123) # 12.3ms
|
||||
|
||||
# 记录首字时间
|
||||
ctx.record_first_byte_time(start_time)
|
||||
|
||||
# 验证首字时间已记录
|
||||
assert ctx.first_byte_time_ms == 12
|
||||
|
||||
|
||||
def test_record_first_byte_time_idempotent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""测试首字时间只记录一次"""
|
||||
ctx = StreamContext(model="claude-3", api_format="claude_messages")
|
||||
start_time = 100.0
|
||||
|
||||
# 第一次记录
|
||||
monkeypatch.setattr(stream_context.time, "time", lambda: 100.010)
|
||||
ctx.record_first_byte_time(start_time)
|
||||
first_value = ctx.first_byte_time_ms
|
||||
|
||||
# 第二次记录(应该被忽略)
|
||||
monkeypatch.setattr(stream_context.time, "time", lambda: 100.020)
|
||||
ctx.record_first_byte_time(start_time)
|
||||
second_value = ctx.first_byte_time_ms
|
||||
|
||||
# 验证值没有改变
|
||||
assert first_value == second_value
|
||||
|
||||
|
||||
def test_reset_for_retry_clears_first_byte_time(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""测试重试时清除首字时间"""
|
||||
ctx = StreamContext(model="claude-3", api_format="claude_messages")
|
||||
start_time = 100.0
|
||||
|
||||
# 记录首字时间
|
||||
monkeypatch.setattr(stream_context.time, "time", lambda: 100.010)
|
||||
ctx.record_first_byte_time(start_time)
|
||||
assert ctx.first_byte_time_ms is not None
|
||||
|
||||
# 重置
|
||||
ctx.reset_for_retry()
|
||||
|
||||
# 验证首字时间已清除
|
||||
assert ctx.first_byte_time_ms is None
|
||||
|
||||
|
||||
def test_get_log_summary_with_first_byte_time() -> None:
|
||||
"""测试日志摘要包含首字时间"""
|
||||
ctx = StreamContext(model="claude-3", api_format="claude_messages")
|
||||
ctx.provider_name = "anthropic"
|
||||
ctx.input_tokens = 100
|
||||
ctx.output_tokens = 50
|
||||
ctx.first_byte_time_ms = 123
|
||||
|
||||
summary = ctx.get_log_summary("request-id-123", 456)
|
||||
|
||||
# 验证包含首字时间和总时间(大写格式)
|
||||
assert "TTFB: 123ms" in summary
|
||||
assert "Total: 456ms" in summary
|
||||
assert "in:100 out:50" in summary
|
||||
|
||||
|
||||
def test_get_log_summary_without_first_byte_time() -> None:
|
||||
"""测试日志摘要在没有首字时间时的格式"""
|
||||
ctx = StreamContext(model="claude-3", api_format="claude_messages")
|
||||
ctx.provider_name = "anthropic"
|
||||
ctx.input_tokens = 100
|
||||
ctx.output_tokens = 50
|
||||
# first_byte_time_ms 保持为 None
|
||||
|
||||
summary = ctx.get_log_summary("request-id-123", 456)
|
||||
|
||||
# 验证不包含首字时间标记,但有总时间(使用大写 TTFB 和 Total)
|
||||
assert "TTFB:" not in summary
|
||||
assert "Total: 456ms" in summary
|
||||
assert "in:100 out:50" in summary
|
||||
|
||||
|
||||
def test_ensure_estimated_output_tokens_uses_collected_text() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.append_text("partial output")
|
||||
|
||||
changed = ctx.ensure_estimated_output_tokens()
|
||||
|
||||
assert changed is True
|
||||
assert ctx.output_tokens == max(1, len("partial output") // 4)
|
||||
|
||||
|
||||
def test_should_estimate_incomplete_tokens_for_interrupted_partial_stream() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.status_code = 503
|
||||
ctx.chunk_count = 3
|
||||
|
||||
assert ctx.should_estimate_incomplete_tokens() is True
|
||||
|
||||
|
||||
def test_should_estimate_incomplete_tokens_when_output_already_estimated() -> None:
|
||||
"""ensure_estimated_output_tokens 已补了 output,但 input 仍为 0 时仍需估算。"""
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.status_code = 503
|
||||
ctx.chunk_count = 3
|
||||
ctx.append_text("partial")
|
||||
ctx.ensure_estimated_output_tokens()
|
||||
|
||||
assert ctx.output_tokens > 0
|
||||
assert ctx.input_tokens == 0
|
||||
assert ctx.should_estimate_incomplete_tokens() is True
|
||||
@@ -1,123 +0,0 @@
|
||||
import json
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.response_parser import (
|
||||
ParsedChunk,
|
||||
ParsedResponse,
|
||||
ResponseParser,
|
||||
StreamStats,
|
||||
)
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.utils.sse_parser import SSEEventParser
|
||||
|
||||
|
||||
class DummyParser(ResponseParser):
|
||||
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
|
||||
return None
|
||||
|
||||
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
|
||||
return ParsedResponse(raw_response=response, status_code=status_code)
|
||||
|
||||
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
def extract_text_content(self, response: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def test_process_line_strips_newlines_and_finalizes_event() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
|
||||
sse_parser = SSEEventParser()
|
||||
|
||||
processor._process_line(ctx, sse_parser, 'data: {"type":"response.completed"}\n')
|
||||
processor._process_line(ctx, sse_parser, "\n")
|
||||
|
||||
assert ctx.has_completion is True
|
||||
|
||||
|
||||
def test_process_line_updates_openai_usage_from_usage_only_chunk() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.provider_api_format = "openai:chat"
|
||||
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
|
||||
sse_parser = SSEEventParser()
|
||||
|
||||
usage_chunk = {
|
||||
"id": "chatcmpl_test",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
|
||||
processor._process_line(ctx, sse_parser, f"data: {json.dumps(usage_chunk)}\n")
|
||||
processor._process_line(ctx, sse_parser, "\n")
|
||||
|
||||
assert ctx.input_tokens == 10
|
||||
assert ctx.output_tokens == 5
|
||||
|
||||
|
||||
def test_process_line_handles_openai_usage_chunk_followed_by_done_without_blank_line() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.provider_api_format = "openai:chat"
|
||||
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
|
||||
sse_parser = SSEEventParser()
|
||||
|
||||
usage_chunk = {
|
||||
"id": "chatcmpl_test",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
|
||||
}
|
||||
|
||||
# Some SSE implementations may emit consecutive data lines without an empty separator.
|
||||
processor._process_line(ctx, sse_parser, f"data: {json.dumps(usage_chunk)}\n")
|
||||
processor._process_line(ctx, sse_parser, "data: [DONE]\n")
|
||||
processor._process_line(ctx, sse_parser, "\n")
|
||||
|
||||
assert ctx.input_tokens == 7
|
||||
assert ctx.output_tokens == 3
|
||||
assert ctx.has_completion is True
|
||||
|
||||
|
||||
class _DummyResponseCtx:
|
||||
async def __aexit__(self, exc_type: type | None, exc: BaseException | None, tb: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_response_stream_flushes_usage_on_remote_protocol_error() -> None:
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.provider_api_format = "openai:chat"
|
||||
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
|
||||
|
||||
usage_chunk = {
|
||||
"id": "chatcmpl_test",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
|
||||
}
|
||||
|
||||
async def _iter_bytes_then_remote_protocol_error() -> AsyncIterator[bytes]:
|
||||
yield f"data: {json.dumps(usage_chunk)}\n".encode("utf-8")
|
||||
raise httpx.RemoteProtocolError("boom")
|
||||
|
||||
out = b""
|
||||
async for b in processor.create_response_stream(
|
||||
ctx=ctx,
|
||||
byte_iterator=_iter_bytes_then_remote_protocol_error(),
|
||||
response_ctx=_DummyResponseCtx(),
|
||||
prefetched_chunks=[],
|
||||
start_time=None,
|
||||
):
|
||||
out += b
|
||||
|
||||
# Stream ends gracefully (no exception), but usage is best-effort captured and request is marked failed.
|
||||
assert b"data:" in out
|
||||
assert ctx.input_tokens == 11
|
||||
assert ctx.output_tokens == 4
|
||||
assert ctx.status_code == 502
|
||||
assert (ctx.error_message or "").startswith("upstream_stream_error:")
|
||||
@@ -1,100 +0,0 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.response_parser import ParsedResponse, ResponseParser, StreamStats
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
|
||||
|
||||
class DummyParser(ResponseParser):
|
||||
def parse_sse_line(self, line: str, stats: StreamStats) -> Any | None: # noqa: ANN401
|
||||
return None
|
||||
|
||||
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
|
||||
return ParsedResponse(raw_response=response, status_code=status_code)
|
||||
|
||||
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
def extract_text_content(self, response: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
async def _empty_async_iter() -> Any:
|
||||
if False: # pragma: no cover
|
||||
yield b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_response_stream_converts_claude_to_openai() -> None:
|
||||
register_default_normalizers()
|
||||
|
||||
ctx = StreamContext(model="test-model", api_format="openai:chat")
|
||||
ctx.client_api_format = "openai:chat"
|
||||
ctx.provider_api_format = "claude:chat"
|
||||
ctx.needs_conversion = True
|
||||
|
||||
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
|
||||
|
||||
response_ctx = AsyncMock()
|
||||
response_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
message_start = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-test",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
},
|
||||
}
|
||||
content_delta = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hi"},
|
||||
}
|
||||
|
||||
prefetched_chunks = [
|
||||
b"event: message_start\n",
|
||||
f"data: {json.dumps(message_start)}\n".encode("utf-8"),
|
||||
b"\n",
|
||||
f"data: {json.dumps(content_delta)}\n".encode("utf-8"),
|
||||
b"\n",
|
||||
]
|
||||
|
||||
out = b"".join(
|
||||
[
|
||||
chunk
|
||||
async for chunk in processor.create_response_stream(
|
||||
ctx,
|
||||
byte_iterator=_empty_async_iter(),
|
||||
response_ctx=response_ctx,
|
||||
prefetched_chunks=prefetched_chunks,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
text = out.decode("utf-8")
|
||||
assert "event:" not in text
|
||||
|
||||
events = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data: "):
|
||||
if line == "data: [DONE]":
|
||||
continue
|
||||
events.append(json.loads(line[6:]))
|
||||
|
||||
assert len(events) >= 2
|
||||
assert any(e.get("object") == "chat.completion.chunk" for e in events)
|
||||
assert any(
|
||||
e.get("choices", [{}])[0].get("delta", {}).get("content") == "Hi"
|
||||
for e in events
|
||||
if isinstance(e, dict)
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
import json
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.parsers import get_parser_for_format
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
|
||||
|
||||
class _DummyResponseCtx:
|
||||
async def __aexit__(self, exc_type: type | None, exc: BaseException | None, tb: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def _iter_bytes(chunks: list[bytes]) -> AsyncIterator[bytes]:
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_processor_converts_gemini_json_lines_without_data_prefix() -> None:
|
||||
register_default_normalizers()
|
||||
|
||||
ctx = StreamContext(model="gemini-test", api_format="openai:chat")
|
||||
ctx.provider_api_format = "gemini:chat"
|
||||
ctx.client_api_format = "openai:chat"
|
||||
ctx.needs_conversion = True
|
||||
ctx.request_id = "req_test"
|
||||
ctx.mapped_model = "gemini-test"
|
||||
|
||||
# Simulate Gemini JSON-array/chunks stream: wrapper lines + two JSON objects.
|
||||
chunk1 = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Hello"}], "role": "model"},
|
||||
}
|
||||
]
|
||||
}
|
||||
chunk2 = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Hello world"}], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3},
|
||||
}
|
||||
|
||||
upstream_lines = [
|
||||
b"[\n",
|
||||
(json.dumps(chunk1) + ",\n").encode("utf-8"),
|
||||
(json.dumps(chunk2) + "\n").encode("utf-8"),
|
||||
b"]\n",
|
||||
]
|
||||
|
||||
processor = StreamProcessor(
|
||||
request_id="req_test",
|
||||
default_parser=get_parser_for_format("openai:chat"),
|
||||
)
|
||||
|
||||
out = b""
|
||||
async for b in processor.create_response_stream(
|
||||
ctx=ctx,
|
||||
byte_iterator=_iter_bytes(upstream_lines),
|
||||
response_ctx=_DummyResponseCtx(),
|
||||
prefetched_chunks=[],
|
||||
start_time=None,
|
||||
):
|
||||
out += b
|
||||
|
||||
text = out.decode("utf-8", errors="replace")
|
||||
data_lines = [ln for ln in text.splitlines() if ln.startswith("data: ")]
|
||||
|
||||
# OpenAI termination marker should be present (StreamProcessor will append if upstream doesn't send it).
|
||||
assert "data: [DONE]" in data_lines
|
||||
|
||||
# Parse JSON events (excluding [DONE]) and validate we have expected deltas.
|
||||
events = [json.loads(ln[6:]) for ln in data_lines if ln != "data: [DONE]"]
|
||||
|
||||
delta_contents: list[str] = []
|
||||
for evt in events:
|
||||
for choice in evt.get("choices", []) or []:
|
||||
delta = choice.get("delta") or {}
|
||||
if "content" in delta and delta["content"]:
|
||||
delta_contents.append(delta["content"])
|
||||
|
||||
assert "Hello" in "".join(delta_contents)
|
||||
assert " world" in "".join(delta_contents)
|
||||
@@ -1,88 +0,0 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.response_parser import ParsedResponse, ResponseParser, StreamStats
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
|
||||
|
||||
class _DummyParser(ResponseParser):
|
||||
def parse_sse_line(self, line: str, stats: StreamStats) -> Any | None: # noqa: ANN401
|
||||
return None
|
||||
|
||||
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
|
||||
return ParsedResponse(raw_response=response, status_code=status_code)
|
||||
|
||||
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
def extract_text_content(self, response: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
async def _empty_async_iter() -> Any:
|
||||
if False: # pragma: no cover
|
||||
yield b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_response_stream_converts_claude_to_openai_cli_with_event_lines() -> None:
|
||||
register_default_normalizers()
|
||||
|
||||
ctx = StreamContext(model="test-model", api_format="openai:cli")
|
||||
ctx.client_api_format = "openai:cli"
|
||||
ctx.provider_api_format = "claude:chat"
|
||||
ctx.needs_conversion = True
|
||||
|
||||
processor = StreamProcessor(request_id="test-request", default_parser=_DummyParser())
|
||||
|
||||
response_ctx = AsyncMock()
|
||||
response_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
message_start = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-test",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
},
|
||||
}
|
||||
content_delta = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hi"},
|
||||
}
|
||||
|
||||
prefetched_chunks = [
|
||||
b"event: message_start\n",
|
||||
f"data: {json.dumps(message_start)}\n".encode("utf-8"),
|
||||
b"\n",
|
||||
f"data: {json.dumps(content_delta)}\n".encode("utf-8"),
|
||||
b"\n",
|
||||
]
|
||||
|
||||
out = b"".join(
|
||||
[
|
||||
chunk
|
||||
async for chunk in processor.create_response_stream(
|
||||
ctx,
|
||||
byte_iterator=_empty_async_iter(),
|
||||
response_ctx=response_ctx,
|
||||
prefetched_chunks=prefetched_chunks,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
text = out.decode("utf-8")
|
||||
assert "event: response.output_item.added\n" in text
|
||||
assert "event: response.content_part.added\n" in text
|
||||
assert "event: response.output_text.delta\n" in text
|
||||
assert "data: [DONE]" not in text
|
||||
@@ -1,136 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
|
||||
|
||||
from src.api.handlers.base import stream_telemetry as stream_telemetry_module
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
|
||||
|
||||
|
||||
class _DummyDb:
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_stream_stats_estimates_tokens_for_failed_partial_stream(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = StreamTelemetryRecorder(
|
||||
request_id="req-telemetry",
|
||||
user_id="1",
|
||||
api_key_id="2",
|
||||
client_ip="127.0.0.1",
|
||||
format_id="openai:chat",
|
||||
)
|
||||
recorder._get_telemetry_writer = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=SimpleNamespace(include_bodies=False)
|
||||
)
|
||||
recorder._dispatch_record = AsyncMock() # type: ignore[method-assign]
|
||||
recorder._update_candidate_status = AsyncMock() # type: ignore[method-assign]
|
||||
|
||||
ctx = StreamContext(
|
||||
model="test-model",
|
||||
api_format="openai:chat",
|
||||
request_id="req-telemetry",
|
||||
user_id=1,
|
||||
api_key_id=2,
|
||||
)
|
||||
ctx.provider_name = "test-provider"
|
||||
ctx.status_code = 503
|
||||
ctx.data_count = 2
|
||||
ctx.chunk_count = 4
|
||||
ctx.append_text("partial output")
|
||||
|
||||
monkeypatch.setattr(stream_telemetry_module, "get_db", lambda: iter([_DummyDb()]))
|
||||
monkeypatch.setattr(
|
||||
stream_telemetry_module.SystemConfigService,
|
||||
"should_log_body",
|
||||
lambda _db: False,
|
||||
)
|
||||
monkeypatch.setattr(stream_telemetry_module.config, "stream_stats_delay", 0)
|
||||
|
||||
await recorder.record_stream_stats(
|
||||
ctx,
|
||||
original_headers={},
|
||||
original_request_body={"input": [{"content": "hello world"}]},
|
||||
start_time=time.time(),
|
||||
)
|
||||
|
||||
assert ctx.input_tokens > 0
|
||||
assert ctx.output_tokens == max(1, len("partial output") // 4)
|
||||
recorder._dispatch_record.assert_awaited_once() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_stream_stats_releases_parsed_chunks_after_dispatch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = StreamTelemetryRecorder(
|
||||
request_id="req-release",
|
||||
user_id="1",
|
||||
api_key_id="2",
|
||||
client_ip="127.0.0.1",
|
||||
format_id="openai:chat",
|
||||
)
|
||||
recorder._get_telemetry_writer = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=SimpleNamespace(include_bodies=True)
|
||||
)
|
||||
recorder._update_candidate_status = AsyncMock() # type: ignore[method-assign]
|
||||
|
||||
dispatch_payloads: list[dict[str, Any]] = []
|
||||
|
||||
async def _capture_dispatch(*args: Any, **kwargs: Any) -> None:
|
||||
dispatch_payloads.append(
|
||||
{
|
||||
"response_body": args[5],
|
||||
"client_response_body": kwargs.get("client_response_body"),
|
||||
}
|
||||
)
|
||||
|
||||
recorder._dispatch_record = _capture_dispatch # type: ignore[method-assign]
|
||||
|
||||
ctx = StreamContext(
|
||||
model="test-model",
|
||||
api_format="openai:chat",
|
||||
request_id="req-release",
|
||||
user_id=1,
|
||||
api_key_id=2,
|
||||
)
|
||||
ctx.provider_name = "test-provider"
|
||||
ctx.parsed_chunks.extend([{"type": "chunk-1"}, {"type": "chunk-2"}])
|
||||
ctx.data_count = 2
|
||||
ctx.chunk_count = 2
|
||||
|
||||
monkeypatch.setattr(stream_telemetry_module, "get_db", lambda: iter([_DummyDb()]))
|
||||
monkeypatch.setattr(
|
||||
stream_telemetry_module.SystemConfigService,
|
||||
"should_log_body",
|
||||
lambda _db: True,
|
||||
)
|
||||
monkeypatch.setattr(stream_telemetry_module.config, "stream_stats_delay", 0)
|
||||
|
||||
await recorder.record_stream_stats(
|
||||
ctx,
|
||||
original_headers={},
|
||||
original_request_body={"input": [{"content": "hello world"}]},
|
||||
start_time=time.time(),
|
||||
)
|
||||
|
||||
assert len(dispatch_payloads) == 1
|
||||
response_body = dispatch_payloads[0]["response_body"]
|
||||
assert response_body["chunks"] == [{"type": "chunk-1"}, {"type": "chunk-2"}]
|
||||
assert response_body["metadata"]["stream"] is True
|
||||
assert response_body["metadata"]["total_chunks"] == 2
|
||||
assert response_body["metadata"]["data_count"] == 2
|
||||
assert dispatch_payloads[0]["client_response_body"] is None
|
||||
assert ctx.parsed_chunks == []
|
||||
assert ctx.provider_parsed_chunks == []
|
||||
@@ -1,145 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.upstream_stream_bridge import (
|
||||
aggregate_upstream_stream_to_internal_response,
|
||||
)
|
||||
from src.config.constants import StreamDefaults
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
from src.core.api_format.conversion.internal import TextBlock
|
||||
from src.core.exceptions import ProviderNotAvailableException
|
||||
|
||||
|
||||
async def _iter_stream_lines(lines: list[str]) -> AsyncIterator[bytes]:
|
||||
for line in lines:
|
||||
yield line.encode("utf-8")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_claude_stream_uses_message_start_usage_when_message_delta_absent() -> None:
|
||||
register_default_normalizers()
|
||||
|
||||
lines = [
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_bridge_usage",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"content": [],
|
||||
"usage": {
|
||||
"input_tokens": 120,
|
||||
"output_tokens": 0,
|
||||
"cache_read_input_tokens": 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "hello"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
"data: "
|
||||
+ json.dumps({"type": "content_block_stop", "index": 0}, ensure_ascii=False)
|
||||
+ "\n",
|
||||
]
|
||||
|
||||
internal = await aggregate_upstream_stream_to_internal_response(
|
||||
_iter_stream_lines(lines),
|
||||
provider_api_format="claude:cli",
|
||||
provider_name="claude_code",
|
||||
model="claude-sonnet-4-5",
|
||||
request_id="req_bridge_usage",
|
||||
)
|
||||
|
||||
assert internal.usage is not None
|
||||
assert internal.usage.input_tokens == 120
|
||||
assert internal.usage.output_tokens == 0
|
||||
assert internal.usage.cache_read_tokens == 11
|
||||
assert len(internal.content) == 1
|
||||
assert isinstance(internal.content[0], TextBlock)
|
||||
assert internal.content[0].text == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_stream_raises_when_buffer_exceeds_limit() -> None:
|
||||
register_default_normalizers()
|
||||
|
||||
async def _iter_overflow_bytes() -> AsyncIterator[bytes]:
|
||||
yield b"x" * (StreamDefaults.MAX_STREAM_BUFFER_BYTES + 1)
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException):
|
||||
await aggregate_upstream_stream_to_internal_response(
|
||||
_iter_overflow_bytes(),
|
||||
provider_api_format="claude:cli",
|
||||
provider_name="claude_code",
|
||||
model="claude-sonnet-4-5",
|
||||
request_id="req_bridge_overflow",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_stream_raises_when_total_buffer_exceeds_hard_limit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
register_default_normalizers()
|
||||
monkeypatch.setattr(StreamDefaults, "MAX_STREAM_BUFFER_BYTES", 64)
|
||||
monkeypatch.setattr(StreamDefaults, "MAX_STREAM_BUFFER_TOTAL_BYTES", 80)
|
||||
|
||||
async def _iter_total_overflow_bytes() -> AsyncIterator[bytes]:
|
||||
yield b":" + (b"a" * 30) + b"\n" + b":" + (b"b" * 30) + b"\n" + b":" + (b"c" * 30) + b"\n"
|
||||
|
||||
with pytest.raises(ProviderNotAvailableException):
|
||||
await aggregate_upstream_stream_to_internal_response(
|
||||
_iter_total_overflow_bytes(),
|
||||
provider_api_format="claude:cli",
|
||||
provider_name="claude_code",
|
||||
model="claude-sonnet-4-5",
|
||||
request_id="req_bridge_total_overflow",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_stream_allows_large_chunk_with_multiple_complete_lines(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
register_default_normalizers()
|
||||
monkeypatch.setattr(StreamDefaults, "MAX_STREAM_BUFFER_BYTES", 64)
|
||||
|
||||
async def _iter_multiline_bytes() -> AsyncIterator[bytes]:
|
||||
yield b":" + (b"a" * 30) + b"\n" + b":" + (b"b" * 30) + b"\n" + b":" + (b"c" * 30) + b"\n"
|
||||
|
||||
internal = await aggregate_upstream_stream_to_internal_response(
|
||||
_iter_multiline_bytes(),
|
||||
provider_api_format="claude:cli",
|
||||
provider_name="claude_code",
|
||||
model="claude-sonnet-4-5",
|
||||
request_id="req_bridge_multiline",
|
||||
)
|
||||
|
||||
assert internal is not None
|
||||
@@ -1,191 +0,0 @@
|
||||
"""测试 handler 基础工具函数"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.utils import (
|
||||
build_json_response_for_client,
|
||||
build_sse_headers,
|
||||
filter_proxy_response_headers,
|
||||
resolve_client_accept_encoding,
|
||||
resolve_client_content_encoding,
|
||||
)
|
||||
from src.core.usage_tokens import extract_cache_creation_tokens
|
||||
|
||||
|
||||
class TestExtractCacheCreationTokens:
|
||||
"""测试 extract_cache_creation_tokens 函数"""
|
||||
|
||||
def test_new_format_only(self) -> None:
|
||||
"""测试只有新格式字段"""
|
||||
usage = {
|
||||
"claude_cache_creation_5_m_tokens": 100,
|
||||
"claude_cache_creation_1_h_tokens": 200,
|
||||
}
|
||||
assert extract_cache_creation_tokens(usage) == 300
|
||||
|
||||
def test_new_format_5m_only(self) -> None:
|
||||
"""测试只有 5 分钟缓存"""
|
||||
usage = {
|
||||
"claude_cache_creation_5_m_tokens": 150,
|
||||
"claude_cache_creation_1_h_tokens": 0,
|
||||
}
|
||||
assert extract_cache_creation_tokens(usage) == 150
|
||||
|
||||
def test_new_format_1h_only(self) -> None:
|
||||
"""测试只有 1 小时缓存"""
|
||||
usage = {
|
||||
"claude_cache_creation_5_m_tokens": 0,
|
||||
"claude_cache_creation_1_h_tokens": 250,
|
||||
}
|
||||
assert extract_cache_creation_tokens(usage) == 250
|
||||
|
||||
def test_old_format_only(self) -> None:
|
||||
"""测试只有旧格式字段"""
|
||||
usage = {
|
||||
"cache_creation_input_tokens": 500,
|
||||
}
|
||||
assert extract_cache_creation_tokens(usage) == 500
|
||||
|
||||
def test_both_formats_prefers_new(self) -> None:
|
||||
"""测试同时存在时优先使用新格式"""
|
||||
usage = {
|
||||
"claude_cache_creation_5_m_tokens": 100,
|
||||
"claude_cache_creation_1_h_tokens": 200,
|
||||
"cache_creation_input_tokens": 999, # 应该被忽略
|
||||
}
|
||||
assert extract_cache_creation_tokens(usage) == 300
|
||||
|
||||
def test_empty_usage(self) -> None:
|
||||
"""测试空字典"""
|
||||
usage: dict[str, int] = {}
|
||||
assert extract_cache_creation_tokens(usage) == 0
|
||||
|
||||
def test_all_zeros(self) -> None:
|
||||
"""测试所有字段都为 0"""
|
||||
usage = {
|
||||
"claude_cache_creation_5_m_tokens": 0,
|
||||
"claude_cache_creation_1_h_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
}
|
||||
assert extract_cache_creation_tokens(usage) == 0
|
||||
|
||||
def test_partial_new_format_with_old_format_fallback(self) -> None:
|
||||
"""测试新格式字段不存在时回退到旧格式"""
|
||||
usage = {
|
||||
"cache_creation_input_tokens": 123,
|
||||
}
|
||||
assert extract_cache_creation_tokens(usage) == 123
|
||||
|
||||
def test_new_format_zero_should_not_fallback(self) -> None:
|
||||
"""测试新格式字段存在但为 0 时,不应 fallback 到旧格式"""
|
||||
usage = {
|
||||
"claude_cache_creation_5_m_tokens": 0,
|
||||
"claude_cache_creation_1_h_tokens": 0,
|
||||
"cache_creation_input_tokens": 456,
|
||||
}
|
||||
# 新格式字段存在,即使值为 0 也应该使用新格式(返回 0)
|
||||
# 而不是 fallback 到旧格式(返回 456)
|
||||
assert extract_cache_creation_tokens(usage) == 0
|
||||
|
||||
def test_unrelated_fields_ignored(self) -> None:
|
||||
"""测试忽略无关字段"""
|
||||
usage = {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 2000,
|
||||
"cache_read_input_tokens": 300,
|
||||
"claude_cache_creation_5_m_tokens": 50,
|
||||
"claude_cache_creation_1_h_tokens": 75,
|
||||
}
|
||||
assert extract_cache_creation_tokens(usage) == 125
|
||||
|
||||
|
||||
class TestBuildSSEHeaders:
|
||||
def test_default_headers(self) -> None:
|
||||
headers = build_sse_headers()
|
||||
assert headers["Cache-Control"] == "no-cache, no-transform"
|
||||
assert headers["X-Accel-Buffering"] == "no"
|
||||
|
||||
def test_merge_extra_headers(self) -> None:
|
||||
headers = build_sse_headers({"X-Test": "1", "Cache-Control": "custom"})
|
||||
assert headers["X-Test"] == "1"
|
||||
assert headers["Cache-Control"] == "custom"
|
||||
|
||||
|
||||
class TestFilterProxyResponseHeaders:
|
||||
def test_none_returns_empty(self) -> None:
|
||||
assert filter_proxy_response_headers(None) == {}
|
||||
|
||||
def test_filters_blocklisted_headers_case_insensitive(self) -> None:
|
||||
headers = {
|
||||
"Content-Length": "123",
|
||||
"content-encoding": "gzip",
|
||||
"Transfer-Encoding": "chunked",
|
||||
"Connection": "keep-alive",
|
||||
"Keep-Alive": "timeout=5",
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-Id": "abc",
|
||||
"Anthropic-RateLimit-Requests-Remaining": "10",
|
||||
}
|
||||
|
||||
result = filter_proxy_response_headers(headers)
|
||||
|
||||
assert "Content-Length" not in result
|
||||
assert "content-encoding" not in result
|
||||
assert "Transfer-Encoding" not in result
|
||||
assert "Connection" not in result
|
||||
assert "Keep-Alive" not in result
|
||||
assert "Content-Type" not in result
|
||||
|
||||
assert result["X-Request-Id"] == "abc"
|
||||
assert result["Anthropic-RateLimit-Requests-Remaining"] == "10"
|
||||
|
||||
|
||||
class TestResolveClientEncoding:
|
||||
def test_content_encoding_prefers_hint(self) -> None:
|
||||
headers = {"content-encoding": "gzip"}
|
||||
result = resolve_client_content_encoding(headers, hinted_content_encoding="br")
|
||||
assert result == "br"
|
||||
|
||||
def test_content_encoding_fallback_to_headers(self) -> None:
|
||||
headers = {"Content-Encoding": "gzip"}
|
||||
result = resolve_client_content_encoding(headers)
|
||||
assert result == "gzip"
|
||||
|
||||
def test_accept_encoding_prefers_hint(self) -> None:
|
||||
headers = {"accept-encoding": "gzip"}
|
||||
result = resolve_client_accept_encoding(headers, hinted_accept_encoding="br")
|
||||
assert result == "br"
|
||||
|
||||
def test_accept_encoding_fallback_to_headers(self) -> None:
|
||||
headers = {"Accept-Encoding": "gzip, deflate"}
|
||||
result = resolve_client_accept_encoding(headers)
|
||||
assert result == "gzip, deflate"
|
||||
|
||||
|
||||
class TestBuildJsonResponseForClient:
|
||||
def test_returns_gzip_response_when_client_accepts_gzip(self) -> None:
|
||||
response = build_json_response_for_client(
|
||||
status_code=200,
|
||||
content={"ok": True},
|
||||
headers={"content-type": "application/json"},
|
||||
client_accept_encoding="gzip, deflate",
|
||||
)
|
||||
|
||||
assert response.headers.get("content-encoding") == "gzip"
|
||||
assert "accept-encoding" in response.headers.get("vary", "").lower()
|
||||
decompressed = gzip.decompress(bytes(response.body))
|
||||
assert json.loads(decompressed.decode("utf-8")) == {"ok": True}
|
||||
|
||||
def test_returns_plain_json_when_gzip_not_accepted(self) -> None:
|
||||
response = build_json_response_for_client(
|
||||
status_code=200,
|
||||
content={"ok": True},
|
||||
headers={"content-type": "application/json"},
|
||||
client_accept_encoding="gzip;q=0, deflate",
|
||||
)
|
||||
|
||||
assert response.headers.get("content-encoding") is None
|
||||
assert json.loads(bytes(response.body).decode("utf-8")) == {"ok": True}
|
||||
@@ -1,19 +0,0 @@
|
||||
from src.api.handlers.gemini.stream_parser import GeminiStreamParser
|
||||
|
||||
|
||||
def test_is_done_event_true_for_new_finish_reason_values() -> None:
|
||||
parser = GeminiStreamParser()
|
||||
event = {"candidates": [{"finishReason": "MALFORMED_FUNCTION_CALL"}]}
|
||||
assert parser.is_done_event(event) is True
|
||||
|
||||
|
||||
def test_is_done_event_false_for_unspecified() -> None:
|
||||
parser = GeminiStreamParser()
|
||||
event = {"candidates": [{"finishReason": "FINISH_REASON_UNSPECIFIED"}]}
|
||||
assert parser.is_done_event(event) is False
|
||||
|
||||
|
||||
def test_is_done_event_false_when_no_candidates_or_reason() -> None:
|
||||
parser = GeminiStreamParser()
|
||||
assert parser.is_done_event({}) is False
|
||||
assert parser.is_done_event({"candidates": [{}]}) is False
|
||||
@@ -1,251 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
import src.api.handlers.gemini.video_handler as video_mod
|
||||
import src.services.proxy_node.resolver as resolver_mod
|
||||
import src.services.request.execution_runtime_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.execution_runtime_client import ExecutionRuntimeStreamResult
|
||||
|
||||
|
||||
class _DummyStreamResponseCtx:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
async def _iter_chunks(chunks: list[bytes]) -> AsyncGenerator[bytes]:
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
def _make_handler() -> GeminiVeoHandler:
|
||||
return GeminiVeoHandler(
|
||||
db=SimpleNamespace(),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
api_key=SimpleNamespace(id="api-key-1"),
|
||||
request_id="req-gemini-video-test",
|
||||
client_ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
start_time=0.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_create_task_uses_rust_sync_helper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
handler = _make_handler()
|
||||
monkeypatch.setattr(video_mod.UsageService, "create_pending_usage", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
handler._normalizer,
|
||||
"video_request_to_internal",
|
||||
lambda body: SimpleNamespace(model=str(body.get("model") or "veo-3")),
|
||||
)
|
||||
|
||||
candidate = SimpleNamespace(provider=SimpleNamespace(name="provider-1", id="prov-1"))
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep-1",
|
||||
provider_id="prov-1",
|
||||
api_family="gemini",
|
||||
endpoint_kind="video",
|
||||
base_url="https://generativelanguage.googleapis.com",
|
||||
body_rules=None,
|
||||
)
|
||||
provider_key = SimpleNamespace(id="key-1")
|
||||
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_resolve_upstream_key",
|
||||
AsyncMock(return_value=("upstream-key", endpoint, provider_key, None)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_build_upstream_url",
|
||||
lambda base_url, model: f"https://generativelanguage.googleapis.com/v1beta/models/{model}:predictLongRunning",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_build_upstream_headers",
|
||||
lambda original_headers, upstream_key, endpoint, auth_info, **kwargs: {
|
||||
"x-goog-api-key": upstream_key
|
||||
},
|
||||
)
|
||||
async def _fake_rust_sync(**kwargs: object) -> httpx.Response:
|
||||
assert kwargs["method"] == "POST"
|
||||
assert kwargs["provider_id"] == "prov-1"
|
||||
assert kwargs["endpoint_id"] == "ep-1"
|
||||
assert kwargs["key_id"] == "key-1"
|
||||
assert kwargs["body"] == {"model": "veo-3", "prompt": "hello"}
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", str(kwargs["url"])),
|
||||
json={"name": "operations/ext-1"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(handler, "_try_rust_sync_http_response", _fake_rust_sync)
|
||||
|
||||
async def _fake_submit_with_failover(**kwargs: object) -> JSONResponse:
|
||||
response = await kwargs["submit_func"](candidate)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "operations/ext-1"
|
||||
return JSONResponse(status_code=400, content={"error": {"message": "stop"}})
|
||||
|
||||
monkeypatch.setattr(handler, "_submit_with_failover", _fake_submit_with_failover)
|
||||
|
||||
response = await handler.handle_create_task(
|
||||
http_request=SimpleNamespace(
|
||||
headers={},
|
||||
url=SimpleNamespace(scheme="https", netloc="example.com"),
|
||||
),
|
||||
original_headers={},
|
||||
original_request_body={"model": "veo-3", "prompt": "hello"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_download_content_uses_rust_executor_with_proxy_snapshot(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(video_mod.config, "executor_backend", "rust")
|
||||
handler = _make_handler()
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
|
||||
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",
|
||||
),
|
||||
)
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep-1",
|
||||
provider_id="prov-1",
|
||||
api_family="gemini",
|
||||
endpoint_kind="video",
|
||||
base_url="https://generativelanguage.googleapis.com",
|
||||
proxy={"enabled": True, "url": "http://proxy.local:8080"},
|
||||
)
|
||||
key = SimpleNamespace(id="key-1", api_key="encrypted", proxy=None)
|
||||
monkeypatch.setattr(handler, "_get_endpoint_and_key", lambda task: (endpoint, key))
|
||||
monkeypatch.setattr(video_mod.crypto_service, "decrypt", lambda _: "upstream-key")
|
||||
monkeypatch.setattr(
|
||||
video_mod,
|
||||
"get_provider_auth",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
video_mod,
|
||||
"resolve_provider_proxy",
|
||||
lambda endpoint, key: {"enabled": True, "url": "http://proxy.local:8080"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"resolve_effective_proxy",
|
||||
lambda provider_proxy, key_proxy=None: provider_proxy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"get_system_proxy_config_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"resolve_delegate_config_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
async def _fake_build_proxy_url_async(proxy_config: object) -> str:
|
||||
assert proxy_config == {"enabled": True, "url": "http://proxy.local:8080"}
|
||||
return "http://proxy.local:8080"
|
||||
|
||||
async def _fake_resolve_proxy_info_async(proxy_config: object) -> dict[str, str]:
|
||||
assert proxy_config == {"enabled": True, "url": "http://proxy.local:8080"}
|
||||
return {"url": "http://proxy.local:8080"}
|
||||
|
||||
monkeypatch.setattr(resolver_mod, "build_proxy_url_async", _fake_build_proxy_url_async)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"resolve_proxy_info_async",
|
||||
_fake_resolve_proxy_info_async,
|
||||
)
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "method") == "GET"
|
||||
assert getattr(plan, "url") == "https://storage.example.com/video.mp4"
|
||||
assert getattr(plan, "headers") == {"x-goog-api-key": "upstream-key"}
|
||||
assert getattr(plan, "proxy").url == "http://proxy.local:8080"
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "video/mp4", "x-rust-download": "true"},
|
||||
byte_iterator=_iter_chunks([b"gemini-", b"video"]),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rust_client_mod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
response = await handler.handle_download_content(
|
||||
task_id="operations/ext-1",
|
||||
http_request=SimpleNamespace(),
|
||||
original_headers={},
|
||||
query_params=None,
|
||||
)
|
||||
|
||||
assert isinstance(response, StreamingResponse)
|
||||
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,
|
||||
)
|
||||
@@ -1,189 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
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.execution_runtime_client import (
|
||||
ExecutionRuntimeClientError,
|
||||
ExecutionRuntimeStreamResult,
|
||||
)
|
||||
|
||||
|
||||
class _DummyStreamResponseCtx:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
async def _iter_chunks(chunks: list[bytes]) -> AsyncGenerator[bytes]:
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
def _make_handler() -> OpenAIVideoHandler:
|
||||
return OpenAIVideoHandler(
|
||||
db=SimpleNamespace(),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
api_key=SimpleNamespace(id="api-key-1"),
|
||||
request_id="req-video-test",
|
||||
client_ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
start_time=0.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_download_content_uses_rust_executor_for_direct_video_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(video_mod.config, "executor_backend", "rust")
|
||||
handler = _make_handler()
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_get_task",
|
||||
lambda task_id: SimpleNamespace(
|
||||
id=task_id,
|
||||
status=VideoStatus.COMPLETED.value,
|
||||
video_url="https://cdn.example.com/video.mp4",
|
||||
model="sora-2",
|
||||
),
|
||||
)
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "method") == "GET"
|
||||
assert getattr(plan, "url") == "https://cdn.example.com/video.mp4"
|
||||
assert getattr(plan, "body").json_body is None
|
||||
assert getattr(plan, "body").body_bytes_b64 is None
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "video/mp4", "x-rust-download": "true"},
|
||||
byte_iterator=_iter_chunks([b"video-", b"bytes"]),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(video_mod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
|
||||
response = await handler.handle_download_content(
|
||||
task_id="task-1",
|
||||
http_request=SimpleNamespace(),
|
||||
original_headers={},
|
||||
query_params={"variant": "video"},
|
||||
)
|
||||
|
||||
assert isinstance(response, StreamingResponse)
|
||||
assert response.headers["x-rust-download"] == "true"
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
assert body == b"video-bytes"
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_download_content_uses_rust_executor_for_upstream_content_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(video_mod.config, "executor_backend", "rust")
|
||||
handler = _make_handler()
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_get_task",
|
||||
lambda task_id: SimpleNamespace(
|
||||
id=task_id,
|
||||
status=VideoStatus.COMPLETED.value,
|
||||
video_url=None,
|
||||
external_task_id="ext-1",
|
||||
model="sora-2",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_get_endpoint_and_key",
|
||||
lambda task: (
|
||||
SimpleNamespace(id="ep-1", provider_id="prov-1", base_url="https://api.openai.com"),
|
||||
SimpleNamespace(id="key-1", api_key="encrypted"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(video_mod.crypto_service, "decrypt", lambda _: "upstream-key")
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_build_upstream_url",
|
||||
lambda base_url, suffix=None: "https://api.openai.com/v1/videos/ext-1/content",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_build_upstream_headers",
|
||||
lambda original_headers, upstream_key, endpoint: {"authorization": f"Bearer {upstream_key}"},
|
||||
)
|
||||
|
||||
async def _fake_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "method") == "GET"
|
||||
assert getattr(plan, "url") == "https://api.openai.com/v1/videos/ext-1/content"
|
||||
assert getattr(plan, "headers") == {"authorization": "Bearer upstream-key"}
|
||||
assert getattr(plan, "provider_id") == "prov-1"
|
||||
assert getattr(plan, "endpoint_id") == "ep-1"
|
||||
assert getattr(plan, "key_id") == "key-1"
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "video/mp4", "x-rust-download": "true"},
|
||||
byte_iterator=_iter_chunks([b"upstream-", b"video"]),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(video_mod.ExecutionRuntimeClient, "execute_stream", _fake_execute_stream)
|
||||
|
||||
response = await handler.handle_download_content(
|
||||
task_id="task-1",
|
||||
http_request=SimpleNamespace(),
|
||||
original_headers={},
|
||||
query_params=None,
|
||||
)
|
||||
|
||||
assert isinstance(response, StreamingResponse)
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
assert body == b"upstream-video"
|
||||
assert dummy_ctx.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_download_content_raises_when_rust_executor_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(video_mod.config, "executor_backend", "rust")
|
||||
handler = _make_handler()
|
||||
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_get_task",
|
||||
lambda task_id: SimpleNamespace(
|
||||
id=task_id,
|
||||
status=VideoStatus.COMPLETED.value,
|
||||
video_url="https://cdn.example.com/video.mp4",
|
||||
model="sora-2",
|
||||
),
|
||||
)
|
||||
|
||||
async def _failing_execute_stream(self: object, plan: object) -> ExecutionRuntimeStreamResult:
|
||||
del self, plan
|
||||
raise ExecutionRuntimeClientError("executor down")
|
||||
|
||||
monkeypatch.setattr(video_mod.ExecutionRuntimeClient, "execute_stream", _failing_execute_stream)
|
||||
with pytest.raises(ProviderNotAvailableException):
|
||||
await handler.handle_download_content(
|
||||
task_id="task-1",
|
||||
http_request=SimpleNamespace(),
|
||||
original_headers={},
|
||||
query_params={"variant": "video"},
|
||||
)
|
||||
@@ -1,103 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import src.api.handlers.openai.video_handler as video_mod
|
||||
from src.api.handlers.openai.video_handler import OpenAIVideoHandler
|
||||
|
||||
|
||||
def _make_handler() -> OpenAIVideoHandler:
|
||||
return OpenAIVideoHandler(
|
||||
db=SimpleNamespace(),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
api_key=SimpleNamespace(id="api-key-1"),
|
||||
request_id="req-video-sync-test",
|
||||
client_ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
start_time=0.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_create_task_uses_rust_sync_helper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
handler = _make_handler()
|
||||
monkeypatch.setattr(video_mod.UsageService, "create_pending_usage", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
handler._normalizer,
|
||||
"video_request_to_internal",
|
||||
lambda body: SimpleNamespace(model=str(body.get("model") or "sora-2")),
|
||||
)
|
||||
|
||||
candidate = SimpleNamespace(provider=SimpleNamespace(name="provider-1", id="prov-1"))
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep-1",
|
||||
api_family="openai",
|
||||
endpoint_kind="video",
|
||||
base_url="https://api.openai.com",
|
||||
body_rules=None,
|
||||
)
|
||||
provider_key = SimpleNamespace(id="key-1")
|
||||
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_resolve_upstream_key",
|
||||
AsyncMock(return_value=("upstream-key", endpoint, provider_key)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_build_upstream_url",
|
||||
lambda base_url: "https://api.openai.com/v1/videos",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_build_upstream_headers",
|
||||
lambda original_headers, upstream_key, endpoint, **kwargs: {
|
||||
"authorization": f"Bearer {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["url"] == "https://api.openai.com/v1/videos"
|
||||
assert kwargs["provider_id"] == "prov-1"
|
||||
assert kwargs["endpoint_id"] == "ep-1"
|
||||
assert kwargs["key_id"] == "key-1"
|
||||
assert kwargs["body"] == {"model": "sora-2", "prompt": "hello"}
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", str(kwargs["url"])),
|
||||
json={"id": "ext-1"},
|
||||
)
|
||||
|
||||
create_failed = AsyncMock()
|
||||
monkeypatch.setattr(handler, "_try_rust_sync_http_response", _fake_rust_sync)
|
||||
monkeypatch.setattr(handler, "_create_failed_task_and_usage", create_failed)
|
||||
|
||||
async def _fake_submit_with_failover(**kwargs: object) -> JSONResponse:
|
||||
response = await kwargs["submit_func"](candidate)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == "ext-1"
|
||||
return JSONResponse(status_code=400, content={"error": {"message": "stop"}})
|
||||
|
||||
monkeypatch.setattr(handler, "_submit_with_failover", _fake_submit_with_failover)
|
||||
|
||||
response = await handler.handle_create_task(
|
||||
http_request=SimpleNamespace(),
|
||||
original_headers={},
|
||||
original_request_body={"model": "sora-2", "prompt": "hello"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
create_failed.assert_awaited_once()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
"""Compatibility shim for legacy imports.
|
||||
|
||||
Decision/plan tests now live in:
|
||||
- test_chat_decision_plan.py
|
||||
- test_cli_decision_plan.py
|
||||
- test_decision_plan_misc.py
|
||||
"""
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Gateway internal decision/plan builder tests."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.api.internal.gateway import (
|
||||
CONTROL_ACTION_HEADER,
|
||||
CONTROL_ACTION_PROXY_PUBLIC,
|
||||
CONTROL_EXECUTED_HEADER,
|
||||
GatewayAuthContext,
|
||||
GatewayExecutionDecisionResponse,
|
||||
GatewayExecuteRequest,
|
||||
GatewayExecutionPlanResponse,
|
||||
GatewayResolveRequest,
|
||||
GatewayStreamReportRequest,
|
||||
GatewaySyncReportRequest,
|
||||
_dispatch_gateway_sync_telemetry,
|
||||
_build_gateway_sync_error_payload,
|
||||
_build_gateway_sync_telemetry_writer,
|
||||
_run_gateway_stream_report_background,
|
||||
_run_gateway_sync_report_background,
|
||||
_stream_executor_requires_python_rewrite,
|
||||
_build_claude_chat_sync_decision,
|
||||
_build_claude_chat_stream_decision,
|
||||
_build_claude_cli_sync_decision,
|
||||
_build_claude_cli_stream_decision,
|
||||
_build_gemini_files_download_stream_decision,
|
||||
_build_gemini_files_proxy_sync_decision,
|
||||
_build_gemini_chat_sync_decision,
|
||||
_build_gemini_chat_stream_decision,
|
||||
_build_gemini_cli_sync_decision,
|
||||
_build_gemini_cli_stream_decision,
|
||||
_build_openai_chat_sync_decision,
|
||||
_build_openai_chat_stream_decision,
|
||||
_build_openai_cli_stream_decision,
|
||||
_build_openai_video_content_stream_decision,
|
||||
_extract_gateway_sync_error_message,
|
||||
_record_gateway_direct_candidate_graph,
|
||||
_resolve_gateway_sync_error_status_code,
|
||||
_build_openai_chat_sync_plan,
|
||||
_build_openai_cli_sync_decision,
|
||||
_build_openai_cli_stream_plan,
|
||||
_build_openai_cli_sync_plan,
|
||||
_is_streaming_sync_payload,
|
||||
_resolve_auth_context,
|
||||
_resolve_gateway_sync_adapter,
|
||||
classify_gateway_route,
|
||||
router,
|
||||
)
|
||||
from src.database import get_db
|
||||
from src.models.database import Base, RequestCandidate
|
||||
from src.services.orchestration.candidate_resolver import CandidateResolver
|
||||
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
||||
from src.services.request.execution_runtime_plan import ExecutionPlan, ExecutionPlanBody, PreparedExecutionPlan
|
||||
|
||||
|
||||
def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def test_stream_executor_requires_python_rewrite_allows_antigravity_same_format() -> None:
|
||||
class AntigravityEnvelope:
|
||||
name = "antigravity:v1internal"
|
||||
|
||||
def force_stream_rewrite(self) -> bool:
|
||||
return True
|
||||
|
||||
envelope = AntigravityEnvelope()
|
||||
assert (
|
||||
_stream_executor_requires_python_rewrite(
|
||||
envelope=envelope,
|
||||
needs_conversion=False,
|
||||
provider_api_format="gemini:cli",
|
||||
client_api_format="gemini:cli",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_stream_executor_requires_python_rewrite(
|
||||
envelope=envelope,
|
||||
needs_conversion=False,
|
||||
provider_api_format="gemini:chat",
|
||||
client_api_format="gemini:chat",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_stream_executor_requires_python_rewrite(
|
||||
envelope=envelope,
|
||||
needs_conversion=False,
|
||||
provider_api_format="gemini:cli",
|
||||
client_api_format="openai:cli",
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
_stream_executor_requires_python_rewrite(
|
||||
envelope=envelope,
|
||||
needs_conversion=True,
|
||||
provider_api_format="gemini:chat",
|
||||
client_api_format="openai:chat",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_stream_executor_requires_python_rewrite(
|
||||
envelope=envelope,
|
||||
needs_conversion=True,
|
||||
provider_api_format="gemini:cli",
|
||||
client_api_format="openai:cli",
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_stream_executor_requires_python_rewrite_allows_kiro_same_format() -> None:
|
||||
class KiroEnvelope:
|
||||
name = "kiro:generateAssistantResponse"
|
||||
|
||||
def force_stream_rewrite(self) -> bool:
|
||||
return True
|
||||
|
||||
envelope = KiroEnvelope()
|
||||
assert (
|
||||
_stream_executor_requires_python_rewrite(
|
||||
envelope=envelope,
|
||||
needs_conversion=False,
|
||||
provider_api_format="claude:cli",
|
||||
client_api_format="claude:cli",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_stream_executor_requires_python_rewrite(
|
||||
envelope=envelope,
|
||||
needs_conversion=False,
|
||||
provider_api_format="claude:chat",
|
||||
client_api_format="claude:chat",
|
||||
)
|
||||
is True
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
"""Compatibility shim for legacy imports.
|
||||
|
||||
Reporting tests now live in:
|
||||
- test_gateway_reporting_background.py
|
||||
- test_gateway_reporting_stream.py
|
||||
- test_gateway_reporting_success.py
|
||||
"""
|
||||
@@ -1,497 +0,0 @@
|
||||
"""Gateway internal report/finalize/trace route tests."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.api.internal.gateway import (
|
||||
CONTROL_ACTION_HEADER,
|
||||
CONTROL_ACTION_PROXY_PUBLIC,
|
||||
CONTROL_EXECUTED_HEADER,
|
||||
GatewayAuthContext,
|
||||
GatewayExecutionDecisionResponse,
|
||||
GatewayExecuteRequest,
|
||||
GatewayExecutionPlanResponse,
|
||||
GatewayResolveRequest,
|
||||
GatewayStreamReportRequest,
|
||||
GatewaySyncReportRequest,
|
||||
_dispatch_gateway_sync_telemetry,
|
||||
_build_gateway_sync_error_payload,
|
||||
_build_gateway_sync_telemetry_writer,
|
||||
_run_gateway_stream_report_background,
|
||||
_run_gateway_sync_report_background,
|
||||
_stream_executor_requires_python_rewrite,
|
||||
_build_claude_chat_sync_decision,
|
||||
_build_claude_chat_stream_decision,
|
||||
_build_claude_cli_sync_decision,
|
||||
_build_claude_cli_stream_decision,
|
||||
_build_gemini_files_download_stream_decision,
|
||||
_build_gemini_files_proxy_sync_decision,
|
||||
_build_gemini_chat_sync_decision,
|
||||
_build_gemini_chat_stream_decision,
|
||||
_build_gemini_cli_sync_decision,
|
||||
_build_gemini_cli_stream_decision,
|
||||
_build_openai_chat_sync_decision,
|
||||
_build_openai_chat_stream_decision,
|
||||
_build_openai_cli_stream_decision,
|
||||
_build_openai_video_content_stream_decision,
|
||||
_extract_gateway_sync_error_message,
|
||||
_record_gateway_direct_candidate_graph,
|
||||
_resolve_gateway_sync_error_status_code,
|
||||
_build_openai_chat_sync_plan,
|
||||
_build_openai_cli_sync_decision,
|
||||
_build_openai_cli_stream_plan,
|
||||
_build_openai_cli_sync_plan,
|
||||
_is_streaming_sync_payload,
|
||||
_resolve_auth_context,
|
||||
_resolve_gateway_sync_adapter,
|
||||
classify_gateway_route,
|
||||
router,
|
||||
)
|
||||
from src.database import get_db
|
||||
from src.models.database import Base, RequestCandidate
|
||||
from src.services.orchestration.candidate_resolver import CandidateResolver
|
||||
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
||||
from src.services.request.execution_runtime_plan import ExecutionPlan, ExecutionPlanBody, PreparedExecutionPlan
|
||||
|
||||
|
||||
def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval)
|
||||
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)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
async def _fake_store_mapping(**kwargs: object) -> None:
|
||||
seen.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.public.gemini_files._maybe_store_file_mapping_from_payload",
|
||||
_fake_store_mapping,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-report-files-123",
|
||||
"report_kind": "gemini_files_store_mapping",
|
||||
"report_context": {
|
||||
"file_key_id": "file-key-123",
|
||||
"user_id": "user-123",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {"name": "files/abc-123", "displayName": "Report File"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: "status_code" in seen)
|
||||
assert seen["status_code"] == 200
|
||||
assert seen["headers"] == {"content-type": "application/json"}
|
||||
assert seen["file_key_id"] == "file-key-123"
|
||||
assert seen["user_id"] == "user-123"
|
||||
assert json.loads(seen["content_bytes"]) == {
|
||||
"name": "files/abc-123",
|
||||
"displayName": "Report File",
|
||||
}
|
||||
|
||||
|
||||
def test_report_sync_route_applies_gemini_files_delete_mapping(
|
||||
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)
|
||||
|
||||
seen: list[str] = []
|
||||
|
||||
async def _fake_delete_mapping(file_name: str) -> None:
|
||||
seen.append(file_name)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.gemini_files_mapping.delete_file_key_mapping",
|
||||
_fake_delete_mapping,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-report-delete-files-123",
|
||||
"report_kind": "gemini_files_delete_mapping",
|
||||
"report_context": {"file_name": "files/abc-123"},
|
||||
"status_code": 200,
|
||||
"headers": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: bool(seen))
|
||||
assert seen == ["files/abc-123"]
|
||||
|
||||
|
||||
def test_report_sync_route_uses_lazy_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
background_mock = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._run_gateway_sync_report_background_with_session",
|
||||
background_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway.create_session",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("create_session should not be called")),
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-report-sync-lazy-123",
|
||||
"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 == 200
|
||||
assert response.json() == {"ok": True}
|
||||
background_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_sync_route_runs_video_create_success_inline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
background_with_session_mock = AsyncMock(return_value=None)
|
||||
run_background_mock = AsyncMock(return_value=None)
|
||||
cleanup_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._run_gateway_sync_report_background_with_session",
|
||||
background_with_session_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._run_gateway_sync_report_background",
|
||||
run_background_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._resolve_gateway_background_db",
|
||||
lambda app_obj: ("db-inline-123", cleanup_mock),
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-openai-video-create-inline-123",
|
||||
"report_kind": "openai_video_create_sync_success",
|
||||
"report_context": {"user_id": "user-123", "api_key_id": "key-123"},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {"id": "ext-video-123", "status": "submitted"},
|
||||
"client_body_json": {
|
||||
"id": "local-video-123",
|
||||
"object": "video",
|
||||
"status": "queued",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
background_with_session_mock.assert_not_awaited()
|
||||
run_background_mock.assert_awaited_once()
|
||||
cleanup_mock.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_gateway_sync_report_background_ensures_request_candidate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = object()
|
||||
ensure_mock = MagicMock(return_value=SimpleNamespace(id="cand-sync-123"))
|
||||
finalize_mock = MagicMock()
|
||||
apply_mock = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._ensure_gateway_request_candidate",
|
||||
ensure_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._mark_gateway_sync_candidate_terminal_state",
|
||||
finalize_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._apply_gateway_sync_report",
|
||||
apply_mock,
|
||||
)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-sync-candidate-123",
|
||||
report_kind="openai_chat_sync_success",
|
||||
report_context={
|
||||
"request_id": "req-sync-candidate-123",
|
||||
"candidate_id": "cand-sync-123",
|
||||
"provider_id": "provider-123",
|
||||
"endpoint_id": "endpoint-123",
|
||||
"key_id": "key-123",
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"id": "chatcmpl-123", "object": "chat.completion", "choices": []},
|
||||
)
|
||||
|
||||
await _run_gateway_sync_report_background(payload, db)
|
||||
|
||||
ensure_mock.assert_called_once_with(
|
||||
db=db,
|
||||
report_context=dict(payload.report_context or {}),
|
||||
trace_id="trace-sync-candidate-123",
|
||||
initial_status="pending",
|
||||
)
|
||||
apply_mock.assert_awaited_once_with(payload, db=db)
|
||||
finalize_mock.assert_called_once_with(
|
||||
db=db,
|
||||
candidate=ensure_mock.return_value,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_gateway_sync_report_background_updates_direct_candidate_terminal_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> 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-terminal-123",
|
||||
name="client-key",
|
||||
user_id="user-direct-terminal-123",
|
||||
user=SimpleNamespace(id="user-direct-terminal-123", username="direct-terminal-user"),
|
||||
)
|
||||
candidates = [
|
||||
ProviderCandidate(
|
||||
provider=SimpleNamespace(id="provider-selected-terminal-123", name="openai", max_retries=1),
|
||||
endpoint=SimpleNamespace(id="endpoint-selected-terminal-123"),
|
||||
key=SimpleNamespace(id="key-selected-terminal-123"),
|
||||
provider_api_format="openai:chat",
|
||||
),
|
||||
ProviderCandidate(
|
||||
provider=SimpleNamespace(id="provider-unused-terminal-123", name="openai", max_retries=1),
|
||||
endpoint=SimpleNamespace(id="endpoint-unused-terminal-123"),
|
||||
key=SimpleNamespace(id="key-unused-terminal-123"),
|
||||
provider_api_format="openai:chat",
|
||||
),
|
||||
]
|
||||
|
||||
_record_gateway_direct_candidate_graph(
|
||||
db=db,
|
||||
candidate_resolver=resolver,
|
||||
candidates=candidates,
|
||||
request_id="req-direct-terminal-123",
|
||||
user_api_key=user_api_key,
|
||||
required_capabilities=None,
|
||||
selected_candidate_index=0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._apply_gateway_sync_report",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-direct-terminal-123",
|
||||
report_kind="openai_chat_sync_success",
|
||||
report_context={
|
||||
"request_id": "req-direct-terminal-123",
|
||||
"candidate_id": getattr(candidates[0], "request_candidate_id"),
|
||||
"provider_id": "provider-selected-terminal-123",
|
||||
"endpoint_id": "endpoint-selected-terminal-123",
|
||||
"key_id": "key-selected-terminal-123",
|
||||
"client_api_format": "openai:chat",
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"id": "chatcmpl-123", "object": "chat.completion", "choices": []},
|
||||
)
|
||||
|
||||
await _run_gateway_sync_report_background(payload, db)
|
||||
|
||||
rows = (
|
||||
db.query(RequestCandidate)
|
||||
.filter(RequestCandidate.request_id == "req-direct-terminal-123")
|
||||
.order_by(RequestCandidate.candidate_index, RequestCandidate.retry_index)
|
||||
.all()
|
||||
)
|
||||
|
||||
assert len(rows) == 2
|
||||
assert rows[0].status == "success"
|
||||
assert rows[1].status == "unused"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("report_kind", "recorder_attr"),
|
||||
[
|
||||
("openai_chat_sync_error", "_record_gateway_chat_sync_failure"),
|
||||
("claude_chat_sync_error", "_record_gateway_chat_sync_failure"),
|
||||
("gemini_chat_sync_error", "_record_gateway_chat_sync_failure"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_apply_gateway_sync_report_routes_chat_error_to_failure_recorder(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
report_kind: str,
|
||||
recorder_attr: str,
|
||||
) -> None:
|
||||
db = object()
|
||||
recorder = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
f"src.api.internal.gateway.{recorder_attr}",
|
||||
recorder,
|
||||
)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-chat-error-report-123",
|
||||
report_kind=report_kind,
|
||||
report_context={"user_id": "user-123", "api_key_id": "key-123"},
|
||||
status_code=429,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"error": {"message": "rate limited"}},
|
||||
)
|
||||
|
||||
from src.api.internal.gateway import _apply_gateway_sync_report
|
||||
|
||||
await _apply_gateway_sync_report(payload, db=db)
|
||||
|
||||
recorder.assert_awaited_once_with(payload, db=db)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("report_kind", "recorder_attr"),
|
||||
[
|
||||
("openai_cli_sync_error", "_record_gateway_cli_sync_failure"),
|
||||
("openai_compact_sync_error", "_record_gateway_cli_sync_failure"),
|
||||
("claude_cli_sync_error", "_record_gateway_cli_sync_failure"),
|
||||
("gemini_cli_sync_error", "_record_gateway_cli_sync_failure"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_apply_gateway_sync_report_routes_cli_error_to_failure_recorder(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
report_kind: str,
|
||||
recorder_attr: str,
|
||||
) -> None:
|
||||
db = object()
|
||||
recorder = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
f"src.api.internal.gateway.{recorder_attr}",
|
||||
recorder,
|
||||
)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-cli-error-report-123",
|
||||
report_kind=report_kind,
|
||||
report_context={"user_id": "user-123", "api_key_id": "key-123"},
|
||||
status_code=429,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"error": {"message": "rate limited"}},
|
||||
)
|
||||
|
||||
from src.api.internal.gateway import _apply_gateway_sync_report
|
||||
|
||||
await _apply_gateway_sync_report(payload, db=db)
|
||||
|
||||
recorder.assert_awaited_once_with(payload, db=db)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"report_kind",
|
||||
[
|
||||
"openai_video_create_sync_error",
|
||||
"openai_video_remix_sync_error",
|
||||
"gemini_video_create_sync_error",
|
||||
"openai_video_delete_sync_error",
|
||||
"openai_video_cancel_sync_error",
|
||||
"gemini_video_cancel_sync_error",
|
||||
],
|
||||
)
|
||||
async def test_apply_gateway_sync_report_routes_video_error_to_failure_recorder(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
report_kind: str,
|
||||
) -> None:
|
||||
db = object()
|
||||
recorder = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._record_gateway_video_sync_failure",
|
||||
recorder,
|
||||
)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-video-error-report-123",
|
||||
report_kind=report_kind,
|
||||
report_context={"user_id": "user-123", "api_key_id": "key-123"},
|
||||
status_code=429,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"error": {"message": "rate limited"}},
|
||||
)
|
||||
|
||||
from src.api.internal.gateway import _apply_gateway_sync_report
|
||||
|
||||
await _apply_gateway_sync_report(payload, db=db)
|
||||
|
||||
recorder.assert_awaited_once_with(payload, db=db)
|
||||
@@ -1,387 +0,0 @@
|
||||
"""Gateway internal report/finalize/trace route tests."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.api.internal.gateway import (
|
||||
CONTROL_ACTION_HEADER,
|
||||
CONTROL_ACTION_PROXY_PUBLIC,
|
||||
CONTROL_EXECUTED_HEADER,
|
||||
GatewayAuthContext,
|
||||
GatewayExecutionDecisionResponse,
|
||||
GatewayExecuteRequest,
|
||||
GatewayExecutionPlanResponse,
|
||||
GatewayResolveRequest,
|
||||
GatewayStreamReportRequest,
|
||||
GatewaySyncReportRequest,
|
||||
_dispatch_gateway_sync_telemetry,
|
||||
_build_gateway_sync_error_payload,
|
||||
_build_gateway_sync_telemetry_writer,
|
||||
_run_gateway_stream_report_background,
|
||||
_run_gateway_sync_report_background,
|
||||
_stream_executor_requires_python_rewrite,
|
||||
_build_claude_chat_sync_decision,
|
||||
_build_claude_chat_stream_decision,
|
||||
_build_claude_cli_sync_decision,
|
||||
_build_claude_cli_stream_decision,
|
||||
_build_gemini_files_download_stream_decision,
|
||||
_build_gemini_files_proxy_sync_decision,
|
||||
_build_gemini_chat_sync_decision,
|
||||
_build_gemini_chat_stream_decision,
|
||||
_build_gemini_cli_sync_decision,
|
||||
_build_gemini_cli_stream_decision,
|
||||
_build_openai_chat_sync_decision,
|
||||
_build_openai_chat_stream_decision,
|
||||
_build_openai_cli_stream_decision,
|
||||
_build_openai_video_content_stream_decision,
|
||||
_extract_gateway_sync_error_message,
|
||||
_record_gateway_direct_candidate_graph,
|
||||
_resolve_gateway_sync_error_status_code,
|
||||
_build_openai_chat_sync_plan,
|
||||
_build_openai_cli_sync_decision,
|
||||
_build_openai_cli_stream_plan,
|
||||
_build_openai_cli_sync_plan,
|
||||
_is_streaming_sync_payload,
|
||||
_resolve_auth_context,
|
||||
_resolve_gateway_sync_adapter,
|
||||
classify_gateway_route,
|
||||
router,
|
||||
)
|
||||
from src.database import get_db
|
||||
from src.models.database import Base, RequestCandidate
|
||||
from src.services.orchestration.candidate_resolver import CandidateResolver
|
||||
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
||||
from src.services.request.execution_runtime_plan import ExecutionPlan, ExecutionPlanBody, PreparedExecutionPlan
|
||||
|
||||
|
||||
def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval)
|
||||
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:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
background_mock = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._run_gateway_stream_report_background_with_session",
|
||||
background_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway.create_session",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("create_session should not be called")),
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
"trace_id": "trace-report-stream-lazy-123",
|
||||
"report_kind": "openai_chat_stream_success",
|
||||
"report_context": {"user_id": "user-123", "api_key_id": "key-123"},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "text/event-stream"},
|
||||
"body_base64": base64.b64encode(b"data: ok\n\n").decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
background_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_gateway_stream_report_background_ensures_request_candidate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = object()
|
||||
ensure_mock = MagicMock(return_value=SimpleNamespace(id="cand-stream-123"))
|
||||
apply_mock = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._ensure_gateway_request_candidate",
|
||||
ensure_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._apply_gateway_stream_report",
|
||||
apply_mock,
|
||||
)
|
||||
|
||||
payload = GatewayStreamReportRequest(
|
||||
trace_id="trace-stream-candidate-123",
|
||||
report_kind="openai_chat_stream_success",
|
||||
report_context={
|
||||
"request_id": "req-stream-candidate-123",
|
||||
"candidate_id": "cand-stream-123",
|
||||
"provider_id": "provider-123",
|
||||
"endpoint_id": "endpoint-123",
|
||||
"key_id": "key-123",
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
body_base64=base64.b64encode(b"data: ok\n\n").decode("ascii"),
|
||||
)
|
||||
|
||||
await _run_gateway_stream_report_background(payload, db)
|
||||
|
||||
ensure_mock.assert_called_once_with(
|
||||
db=db,
|
||||
report_context=dict(payload.report_context or {}),
|
||||
trace_id="trace-stream-candidate-123",
|
||||
initial_status="streaming",
|
||||
)
|
||||
apply_mock.assert_awaited_once_with(payload, db=db)
|
||||
|
||||
|
||||
def test_report_stream_route_records_openai_chat_stream_success(
|
||||
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_stream_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
"trace_id": "trace-openai-chat-stream-report",
|
||||
"report_kind": "openai_chat_stream_success",
|
||||
"report_context": {"user_id": "user-123", "api_key_id": "key-123"},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "text/event-stream"},
|
||||
"body_base64": base64.b64encode(b"data: hello\\n\\ndata: [DONE]\\n\\n").decode("ascii"),
|
||||
"telemetry": {"elapsed_ms": 123, "ttfb_ms": 22},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_stream_route_records_claude_chat_stream_success(
|
||||
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_passthrough_chat_stream_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
"trace_id": "trace-claude-chat-stream-report",
|
||||
"report_kind": "claude_chat_stream_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "claude:chat",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "text/event-stream"},
|
||||
"body_base64": base64.b64encode(b"event: content_block_delta\n\n").decode("ascii"),
|
||||
"telemetry": {"elapsed_ms": 66},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_stream_route_records_gemini_chat_stream_success(
|
||||
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_passthrough_chat_stream_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
"trace_id": "trace-gemini-chat-stream-report",
|
||||
"report_kind": "gemini_chat_stream_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "gemini:chat",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "text/event-stream"},
|
||||
"body_base64": base64.b64encode(b'data: {"candidates":[]}\n\n').decode("ascii"),
|
||||
"telemetry": {"elapsed_ms": 44},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_stream_route_records_openai_cli_stream_success(
|
||||
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_passthrough_cli_stream_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
"trace_id": "trace-openai-cli-stream-report",
|
||||
"report_kind": "openai_cli_stream_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "openai:cli",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "text/event-stream"},
|
||||
"body_base64": base64.b64encode(
|
||||
b'event: response.completed\ndata: {"type":"response.completed"}\n\n'
|
||||
).decode("ascii"),
|
||||
"telemetry": {"elapsed_ms": 66},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_stream_route_records_claude_cli_stream_success(
|
||||
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_passthrough_cli_stream_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
"trace_id": "trace-claude-cli-stream-report",
|
||||
"report_kind": "claude_cli_stream_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "claude:cli",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "text/event-stream"},
|
||||
"body_base64": base64.b64encode(
|
||||
b'event: message_start\ndata: {"type":"message_start"}\n\n'
|
||||
).decode("ascii"),
|
||||
"telemetry": {"elapsed_ms": 44},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_stream_route_records_gemini_cli_stream_success(
|
||||
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_passthrough_cli_stream_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-stream",
|
||||
json={
|
||||
"trace_id": "trace-gemini-cli-stream-report",
|
||||
"report_kind": "gemini_cli_stream_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "gemini:cli",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "text/event-stream"},
|
||||
"body_base64": base64.b64encode(b'data: {"candidates":[]}\n\n').decode("ascii"),
|
||||
"telemetry": {"elapsed_ms": 44},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
@@ -1,752 +0,0 @@
|
||||
"""Gateway internal report/finalize/trace route tests."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.api.internal.gateway import (
|
||||
CONTROL_ACTION_HEADER,
|
||||
CONTROL_ACTION_PROXY_PUBLIC,
|
||||
CONTROL_EXECUTED_HEADER,
|
||||
GatewayAuthContext,
|
||||
GatewayExecutionDecisionResponse,
|
||||
GatewayExecuteRequest,
|
||||
GatewayExecutionPlanResponse,
|
||||
GatewayResolveRequest,
|
||||
GatewayStreamReportRequest,
|
||||
GatewaySyncReportRequest,
|
||||
_dispatch_gateway_sync_telemetry,
|
||||
_build_gateway_sync_error_payload,
|
||||
_build_gateway_sync_telemetry_writer,
|
||||
_run_gateway_stream_report_background,
|
||||
_run_gateway_sync_report_background,
|
||||
_stream_executor_requires_python_rewrite,
|
||||
_build_claude_chat_sync_decision,
|
||||
_build_claude_chat_stream_decision,
|
||||
_build_claude_cli_sync_decision,
|
||||
_build_claude_cli_stream_decision,
|
||||
_build_gemini_files_download_stream_decision,
|
||||
_build_gemini_files_proxy_sync_decision,
|
||||
_build_gemini_chat_sync_decision,
|
||||
_build_gemini_chat_stream_decision,
|
||||
_build_gemini_cli_sync_decision,
|
||||
_build_gemini_cli_stream_decision,
|
||||
_build_openai_chat_sync_decision,
|
||||
_build_openai_chat_stream_decision,
|
||||
_build_openai_cli_stream_decision,
|
||||
_build_openai_video_content_stream_decision,
|
||||
_extract_gateway_sync_error_message,
|
||||
_record_gateway_direct_candidate_graph,
|
||||
_resolve_gateway_sync_error_status_code,
|
||||
_build_openai_chat_sync_plan,
|
||||
_build_openai_cli_sync_decision,
|
||||
_build_openai_cli_stream_plan,
|
||||
_build_openai_cli_sync_plan,
|
||||
_is_streaming_sync_payload,
|
||||
_resolve_auth_context,
|
||||
_resolve_gateway_sync_adapter,
|
||||
classify_gateway_route,
|
||||
router,
|
||||
)
|
||||
from src.database import get_db
|
||||
from src.models.database import Base, RequestCandidate
|
||||
from src.services.orchestration.candidate_resolver import CandidateResolver
|
||||
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
||||
from src.services.request.execution_runtime_plan import ExecutionPlan, ExecutionPlanBody, PreparedExecutionPlan
|
||||
|
||||
|
||||
def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval)
|
||||
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:
|
||||
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 = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-openai-chat-report",
|
||||
"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": [],
|
||||
},
|
||||
"telemetry": {"elapsed_ms": 123},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_sync_route_records_openai_cli_sync_success(
|
||||
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_passthrough_cli_sync_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-openai-cli-report",
|
||||
"report_kind": "openai_cli_sync_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "openai:cli",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"output": [],
|
||||
},
|
||||
"telemetry": {"elapsed_ms": 91},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_sync_route_records_claude_cli_sync_success(
|
||||
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_passthrough_cli_sync_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-claude-cli-report",
|
||||
"report_kind": "claude_cli_sync_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "claude:cli",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"content": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_sync_route_records_gemini_cli_sync_success(
|
||||
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_passthrough_cli_sync_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-gemini-cli-report",
|
||||
"report_kind": "gemini_cli_sync_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "gemini:cli",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {
|
||||
"candidates": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("report_kind", "recorder_attr"),
|
||||
[
|
||||
("openai_video_create_sync_success", "_record_gateway_openai_video_create_sync_success"),
|
||||
("openai_video_remix_sync_success", "_record_gateway_openai_video_remix_sync_success"),
|
||||
("openai_video_delete_sync_success", "_record_gateway_openai_video_delete_sync_success"),
|
||||
("openai_video_cancel_sync_success", "_record_gateway_openai_video_cancel_sync_success"),
|
||||
("gemini_video_create_sync_success", "_record_gateway_gemini_video_create_sync_success"),
|
||||
("gemini_video_cancel_sync_success", "_record_gateway_gemini_video_cancel_sync_success"),
|
||||
],
|
||||
)
|
||||
def test_report_sync_route_records_video_sync_success_variants(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
report_kind: str,
|
||||
recorder_attr: str,
|
||||
) -> 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(f"src.api.internal.gateway.{recorder_attr}", record_mock)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": f"trace-{report_kind}",
|
||||
"report_kind": report_kind,
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"task_id": "task-123",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {},
|
||||
"telemetry": {"elapsed_ms": 33},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("recorder_attr", "finalize_attr"),
|
||||
[
|
||||
(
|
||||
"_record_gateway_openai_video_create_sync_success",
|
||||
"_finalize_gateway_openai_video_create_sync",
|
||||
),
|
||||
(
|
||||
"_record_gateway_openai_video_remix_sync_success",
|
||||
"_finalize_gateway_openai_video_remix_sync",
|
||||
),
|
||||
(
|
||||
"_record_gateway_gemini_video_create_sync_success",
|
||||
"_finalize_gateway_gemini_video_create_sync",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_video_sync_success_recorders_delegate_to_finalize(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
recorder_attr: str,
|
||||
finalize_attr: str,
|
||||
) -> None:
|
||||
import src.api.internal.gateway as gateway_module
|
||||
|
||||
finalize_mock = AsyncMock(return_value=JSONResponse({"ok": True}))
|
||||
monkeypatch.setattr(f"src.api.internal.gateway.{finalize_attr}", finalize_mock)
|
||||
|
||||
db = object()
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-video-sync-success-123",
|
||||
report_kind="video-sync-success",
|
||||
report_context={"user_id": "user-123", "api_key_id": "key-123"},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"id": "ext-123"},
|
||||
)
|
||||
|
||||
recorder = getattr(gateway_module, recorder_attr)
|
||||
await recorder(payload, db=db)
|
||||
|
||||
finalize_mock.assert_awaited_once_with(payload, db=db)
|
||||
|
||||
|
||||
def test_report_sync_route_records_claude_chat_sync_success(
|
||||
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_passthrough_chat_sync_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-claude-chat-report",
|
||||
"report_kind": "claude_chat_sync_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "claude:chat",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"content": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_report_sync_route_records_gemini_chat_sync_success(
|
||||
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_passthrough_chat_sync_success",
|
||||
record_mock,
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/report-sync",
|
||||
json={
|
||||
"trace_id": "trace-gemini-chat-report",
|
||||
"report_kind": "gemini_chat_sync_success",
|
||||
"report_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"client_api_format": "gemini:chat",
|
||||
},
|
||||
"status_code": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body_json": {
|
||||
"candidates": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
_wait_until(lambda: record_mock.await_count == 1)
|
||||
record_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_gateway_openai_chat_sync_success_uses_provider_and_client_bodies(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.internal import gateway as gateway_module
|
||||
|
||||
record_success = AsyncMock(return_value=None)
|
||||
fake_handler = SimpleNamespace(
|
||||
_normalize_response=lambda response: response,
|
||||
_extract_usage=lambda response: {
|
||||
"input_tokens": response.get("usage", {}).get("prompt_tokens", 0),
|
||||
"output_tokens": response.get("usage", {}).get("completion_tokens", 0),
|
||||
},
|
||||
telemetry=SimpleNamespace(record_success=record_success),
|
||||
)
|
||||
|
||||
class FakeOpenAIChatAdapter:
|
||||
API_FAMILY = SimpleNamespace(value="openai")
|
||||
ENDPOINT_KIND = SimpleNamespace(value="chat")
|
||||
|
||||
def _create_handler(self, **kwargs: Any) -> Any:
|
||||
return fake_handler
|
||||
|
||||
fake_user = object()
|
||||
fake_api_key = object()
|
||||
|
||||
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._values = iter((fake_user, fake_api_key))
|
||||
|
||||
def query(self, model: Any) -> FakeQuery:
|
||||
return FakeQuery(next(self._values))
|
||||
|
||||
monkeypatch.setattr("src.api.handlers.openai.OpenAIChatAdapter", FakeOpenAIChatAdapter)
|
||||
|
||||
provider_body = {
|
||||
"responseId": "resp-provider-123",
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "provider"}], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 2,
|
||||
"candidatesTokenCount": 3,
|
||||
"totalTokenCount": 5,
|
||||
},
|
||||
}
|
||||
client_body = {
|
||||
"id": "chatcmpl-client-123",
|
||||
"object": "chat.completion",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "client"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 2,
|
||||
"completion_tokens": 3,
|
||||
"total_tokens": 5,
|
||||
},
|
||||
}
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-openai-chat-conversion-123",
|
||||
report_kind="openai_chat_sync_success",
|
||||
report_context={
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"provider_name": "gemini",
|
||||
"provider_api_format": "gemini:chat",
|
||||
"client_api_format": "openai:chat",
|
||||
"model": "gpt-5",
|
||||
"mapped_model": "gpt-5",
|
||||
"request_id": "req-123",
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json=provider_body,
|
||||
client_body_json=client_body,
|
||||
telemetry={"elapsed_ms": 31},
|
||||
)
|
||||
|
||||
await gateway_module._record_gateway_openai_chat_sync_success(payload, db=FakeDb())
|
||||
|
||||
kwargs = record_success.await_args.kwargs
|
||||
assert kwargs["response_body"] == provider_body
|
||||
assert kwargs["client_response_body"] == client_body
|
||||
assert kwargs["has_format_conversion"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_gateway_passthrough_cli_sync_success_uses_provider_and_client_bodies(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.internal import gateway as gateway_module
|
||||
|
||||
record_success = AsyncMock(return_value=None)
|
||||
|
||||
class FakeCliHandler:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.parser = SimpleNamespace(
|
||||
extract_usage_from_response=lambda response: {
|
||||
"input_tokens": response.get("usage", {}).get("input_tokens", 0),
|
||||
"output_tokens": response.get("usage", {}).get("output_tokens", 0),
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
}
|
||||
)
|
||||
self.telemetry = SimpleNamespace(record_success=record_success)
|
||||
|
||||
def _extract_response_metadata(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"response_id": response.get("id")}
|
||||
|
||||
class FakeOpenAICliAdapter:
|
||||
API_FAMILY = SimpleNamespace(value="openai")
|
||||
ENDPOINT_KIND = SimpleNamespace(value="cli")
|
||||
HANDLER_CLASS = FakeCliHandler
|
||||
allowed_api_formats = ["openai:cli"]
|
||||
|
||||
@staticmethod
|
||||
def detect_capability_requirements(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
fake_user = object()
|
||||
fake_api_key = object()
|
||||
|
||||
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._values = iter((fake_user, fake_api_key))
|
||||
|
||||
def query(self, model: Any) -> FakeQuery:
|
||||
return FakeQuery(next(self._values))
|
||||
|
||||
monkeypatch.setattr("src.api.handlers.openai_cli.OpenAICliAdapter", FakeOpenAICliAdapter)
|
||||
|
||||
provider_body = {
|
||||
"responseId": "resp-provider-cli-123",
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "provider"}], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 2,
|
||||
"candidatesTokenCount": 3,
|
||||
"totalTokenCount": 5,
|
||||
},
|
||||
}
|
||||
client_body = {
|
||||
"id": "resp-client-cli-123",
|
||||
"object": "response",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 2,
|
||||
"output_tokens": 3,
|
||||
"total_tokens": 5,
|
||||
},
|
||||
}
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-openai-cli-conversion-123",
|
||||
report_kind="openai_cli_sync_success",
|
||||
report_context={
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"provider_name": "gemini",
|
||||
"provider_api_format": "gemini:cli",
|
||||
"client_api_format": "openai:cli",
|
||||
"model": "gpt-5",
|
||||
"mapped_model": "gpt-5",
|
||||
"request_id": "req-cli-123",
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json=provider_body,
|
||||
client_body_json=client_body,
|
||||
telemetry={"elapsed_ms": 29},
|
||||
)
|
||||
|
||||
await gateway_module._record_gateway_passthrough_cli_sync_success(
|
||||
payload, db=FakeDb()
|
||||
)
|
||||
|
||||
kwargs = record_success.await_args.kwargs
|
||||
assert kwargs["response_body"] == provider_body
|
||||
assert kwargs["client_response_body"] == client_body
|
||||
assert kwargs["has_format_conversion"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_gateway_passthrough_cli_sync_success_postprocesses_antigravity_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.internal import gateway as gateway_module
|
||||
|
||||
record_success = AsyncMock(return_value=None)
|
||||
seen_cache: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
class FakeCliHandler:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.parser = SimpleNamespace(
|
||||
extract_usage_from_response=lambda response: {
|
||||
"input_tokens": response.get("usageMetadata", {}).get("promptTokenCount", 0),
|
||||
"output_tokens": response.get("usageMetadata", {}).get(
|
||||
"candidatesTokenCount", 0
|
||||
),
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
}
|
||||
)
|
||||
self.telemetry = SimpleNamespace(record_success=record_success)
|
||||
|
||||
def _extract_response_metadata(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"response_id": response.get("_v1internal_response_id")}
|
||||
|
||||
class FakeGeminiCliAdapter:
|
||||
API_FAMILY = SimpleNamespace(value="gemini")
|
||||
ENDPOINT_KIND = SimpleNamespace(value="cli")
|
||||
HANDLER_CLASS = FakeCliHandler
|
||||
allowed_api_formats = ["gemini:cli"]
|
||||
|
||||
@staticmethod
|
||||
def detect_capability_requirements(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
fake_user = object()
|
||||
fake_api_key = object()
|
||||
|
||||
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._values = iter((fake_user, fake_api_key))
|
||||
|
||||
def query(self, model: Any) -> FakeQuery:
|
||||
return FakeQuery(next(self._values))
|
||||
|
||||
def _record_cache(model: str, response: dict[str, Any]) -> None:
|
||||
seen_cache.append((model, response))
|
||||
|
||||
monkeypatch.setattr("src.api.handlers.gemini_cli.GeminiCliAdapter", FakeGeminiCliAdapter)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.adapters.antigravity.envelope.cache_thought_signatures",
|
||||
_record_cache,
|
||||
)
|
||||
|
||||
provider_body = {
|
||||
"_v1internal_response_id": "resp-antigravity-123",
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "thinking",
|
||||
"thoughtSignature": "a" * 60,
|
||||
}
|
||||
],
|
||||
"role": "model",
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 2,
|
||||
"candidatesTokenCount": 3,
|
||||
"totalTokenCount": 5,
|
||||
},
|
||||
}
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-antigravity-cli-report-123",
|
||||
report_kind="gemini_cli_sync_success",
|
||||
report_context={
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"provider_name": "antigravity",
|
||||
"provider_api_format": "gemini:cli",
|
||||
"client_api_format": "gemini:cli",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"mapped_model": "claude-sonnet-4-5",
|
||||
"request_id": "req-antigravity-cli-123",
|
||||
"envelope_name": "antigravity:v1internal",
|
||||
},
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json=provider_body,
|
||||
telemetry={"elapsed_ms": 17},
|
||||
)
|
||||
|
||||
await gateway_module._record_gateway_passthrough_cli_sync_success(payload, db=FakeDb())
|
||||
|
||||
assert len(seen_cache) == 1
|
||||
assert seen_cache[0][0] == "claude-sonnet-4-5"
|
||||
assert seen_cache[0][1]["_v1internal_response_id"] == "resp-antigravity-123"
|
||||
@@ -1,353 +0,0 @@
|
||||
"""Gateway internal report/finalize/trace route tests."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.api.internal.gateway import (
|
||||
CONTROL_ACTION_HEADER,
|
||||
CONTROL_ACTION_PROXY_PUBLIC,
|
||||
CONTROL_EXECUTED_HEADER,
|
||||
GatewayAuthContext,
|
||||
GatewayExecutionDecisionResponse,
|
||||
GatewayExecuteRequest,
|
||||
GatewayExecutionPlanResponse,
|
||||
GatewayResolveRequest,
|
||||
GatewayStreamReportRequest,
|
||||
GatewaySyncReportRequest,
|
||||
_dispatch_gateway_sync_telemetry,
|
||||
_build_gateway_sync_error_payload,
|
||||
_build_gateway_sync_telemetry_writer,
|
||||
_run_gateway_stream_report_background,
|
||||
_run_gateway_sync_report_background,
|
||||
_stream_executor_requires_python_rewrite,
|
||||
_build_claude_chat_sync_decision,
|
||||
_build_claude_chat_stream_decision,
|
||||
_build_claude_cli_sync_decision,
|
||||
_build_claude_cli_stream_decision,
|
||||
_build_gemini_files_download_stream_decision,
|
||||
_build_gemini_files_proxy_sync_decision,
|
||||
_build_gemini_chat_sync_decision,
|
||||
_build_gemini_chat_stream_decision,
|
||||
_build_gemini_cli_sync_decision,
|
||||
_build_gemini_cli_stream_decision,
|
||||
_build_openai_chat_sync_decision,
|
||||
_build_openai_chat_stream_decision,
|
||||
_build_openai_cli_stream_decision,
|
||||
_build_openai_video_content_stream_decision,
|
||||
_extract_gateway_sync_error_message,
|
||||
_record_gateway_direct_candidate_graph,
|
||||
_resolve_gateway_sync_error_status_code,
|
||||
_build_openai_chat_sync_plan,
|
||||
_build_openai_cli_sync_decision,
|
||||
_build_openai_cli_stream_plan,
|
||||
_build_openai_cli_sync_plan,
|
||||
_is_streaming_sync_payload,
|
||||
_resolve_auth_context,
|
||||
_resolve_gateway_sync_adapter,
|
||||
classify_gateway_route,
|
||||
router,
|
||||
)
|
||||
from src.database import get_db
|
||||
from src.models.database import Base, RequestCandidate
|
||||
from src.services.orchestration.candidate_resolver import CandidateResolver
|
||||
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
||||
from src.services.request.execution_runtime_plan import ExecutionPlan, ExecutionPlanBody, PreparedExecutionPlan
|
||||
|
||||
|
||||
def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def test_extract_gateway_sync_error_message_prefers_nested_error_message() -> None:
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-error-message-123",
|
||||
report_kind="openai_chat_sync_finalize",
|
||||
status_code=429,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"error": {"message": "rate limited"}},
|
||||
)
|
||||
|
||||
assert _extract_gateway_sync_error_message(payload) == "rate limited"
|
||||
|
||||
|
||||
def test_extract_gateway_sync_error_message_falls_back_to_body_base64() -> None:
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-error-body-123",
|
||||
report_kind="openai_chat_sync_finalize",
|
||||
status_code=502,
|
||||
headers={"content-type": "text/plain"},
|
||||
body_base64=base64.b64encode(b"upstream exploded").decode("ascii"),
|
||||
)
|
||||
|
||||
assert _extract_gateway_sync_error_message(payload) == "upstream exploded"
|
||||
|
||||
|
||||
def test_build_gateway_sync_error_payload_returns_provider_error_when_no_conversion() -> None:
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-error-payload-123",
|
||||
report_kind="openai_chat_sync_finalize",
|
||||
status_code=400,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"error": {"message": "bad request"}},
|
||||
)
|
||||
|
||||
assert _build_gateway_sync_error_payload(
|
||||
payload,
|
||||
client_api_format="openai:chat",
|
||||
provider_api_format="openai:chat",
|
||||
needs_conversion=False,
|
||||
) == {"error": {"message": "bad request"}}
|
||||
|
||||
|
||||
def test_build_gateway_sync_error_payload_builds_generic_client_error_for_non_json() -> None:
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-error-generic-123",
|
||||
report_kind="openai_cli_sync_finalize",
|
||||
status_code=504,
|
||||
headers={"content-type": "text/plain"},
|
||||
body_base64=base64.b64encode(b"gateway timeout").decode("ascii"),
|
||||
)
|
||||
|
||||
result = _build_gateway_sync_error_payload(
|
||||
payload,
|
||||
client_api_format="openai:cli",
|
||||
provider_api_format="openai:cli",
|
||||
needs_conversion=False,
|
||||
)
|
||||
|
||||
assert result["error"]["message"] == "gateway timeout"
|
||||
|
||||
|
||||
def test_resolve_gateway_sync_error_status_code_prefers_embedded_code() -> None:
|
||||
class _FakeParser:
|
||||
def parse_response(self, response: dict[str, object], status_code: int) -> SimpleNamespace:
|
||||
del response, status_code
|
||||
return SimpleNamespace(embedded_status_code=429)
|
||||
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-error-status-123",
|
||||
report_kind="openai_chat_sync_finalize",
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"error": {"message": "rate limited", "code": 429}},
|
||||
)
|
||||
|
||||
assert _resolve_gateway_sync_error_status_code(payload, provider_parser=_FakeParser()) == 429
|
||||
|
||||
|
||||
def test_resolve_gateway_sync_error_status_code_defaults_embedded_200_to_400() -> None:
|
||||
payload = GatewaySyncReportRequest(
|
||||
trace_id="trace-error-status-default-123",
|
||||
report_kind="openai_chat_sync_finalize",
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body_json={"error": {"message": "bad request"}},
|
||||
)
|
||||
|
||||
assert _resolve_gateway_sync_error_status_code(payload) == 400
|
||||
|
||||
|
||||
def test_record_gateway_direct_candidate_graph_creates_full_candidate_graph() -> 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-123",
|
||||
name="client-key",
|
||||
user_id="user-direct-123",
|
||||
user=SimpleNamespace(id="user-direct-123", username="direct-user"),
|
||||
)
|
||||
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:chat",
|
||||
),
|
||||
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:chat",
|
||||
),
|
||||
ProviderCandidate(
|
||||
provider=SimpleNamespace(id="provider-skipped-123", name="openai", max_retries=1),
|
||||
endpoint=SimpleNamespace(id="endpoint-skipped-123"),
|
||||
key=SimpleNamespace(id="key-skipped-123"),
|
||||
is_skipped=True,
|
||||
skip_reason="capability_miss",
|
||||
provider_api_format="openai:chat",
|
||||
),
|
||||
]
|
||||
|
||||
_record_gateway_direct_candidate_graph(
|
||||
db=db,
|
||||
candidate_resolver=resolver,
|
||||
candidates=candidates,
|
||||
request_id="req-direct-candidate-graph-123",
|
||||
user_api_key=user_api_key,
|
||||
required_capabilities={"tools": True},
|
||||
selected_candidate_index=0,
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(RequestCandidate)
|
||||
.filter(RequestCandidate.request_id == "req-direct-candidate-graph-123")
|
||||
.order_by(RequestCandidate.candidate_index, RequestCandidate.retry_index)
|
||||
.all()
|
||||
)
|
||||
|
||||
assert len(rows) == 3
|
||||
assert rows[0].status == "pending"
|
||||
assert rows[1].status == "unused"
|
||||
assert rows[2].status == "skipped"
|
||||
assert getattr(candidates[0], "request_candidate_id") == rows[0].id
|
||||
assert rows[0].started_at is not None
|
||||
assert rows[1].finished_at is not None
|
||||
|
||||
|
||||
def test_record_gateway_direct_candidate_graph_uses_selected_pool_key_index() -> 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-pool-123",
|
||||
name="client-key",
|
||||
user_id="user-pool-123",
|
||||
user=SimpleNamespace(id="user-pool-123", username="pool-user"),
|
||||
)
|
||||
pool_keys = [
|
||||
SimpleNamespace(id="pool-key-0"),
|
||||
SimpleNamespace(id="pool-key-1"),
|
||||
]
|
||||
candidate = PoolCandidate(
|
||||
provider=SimpleNamespace(id="provider-pool-123", name="claude", max_retries=1),
|
||||
endpoint=SimpleNamespace(id="endpoint-pool-123"),
|
||||
key=pool_keys[1],
|
||||
pool_keys=pool_keys,
|
||||
provider_api_format="claude:cli",
|
||||
)
|
||||
candidate._pool_key_index = 1
|
||||
|
||||
_record_gateway_direct_candidate_graph(
|
||||
db=db,
|
||||
candidate_resolver=resolver,
|
||||
candidates=[candidate],
|
||||
request_id="req-direct-pool-graph-123",
|
||||
user_api_key=user_api_key,
|
||||
required_capabilities=None,
|
||||
selected_candidate_index=0,
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(RequestCandidate)
|
||||
.filter(RequestCandidate.request_id == "req-direct-pool-graph-123")
|
||||
.order_by(RequestCandidate.candidate_index, RequestCandidate.retry_index)
|
||||
.all()
|
||||
)
|
||||
|
||||
assert len(rows) == 2
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
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 == []
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Compatibility shim for legacy imports.
|
||||
|
||||
Report/finalize/trace tests now live in:
|
||||
- test_gateway_trace.py
|
||||
- test_gateway_reporting.py
|
||||
- test_gateway_finalize.py
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,325 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import src.api.public.gemini_files as gemini_files_mod
|
||||
import src.services.proxy_node.resolver as resolver_mod
|
||||
import src.services.request.execution_runtime_client as rust_client_mod
|
||||
from src.api.public.gemini_files import UpstreamContext
|
||||
from src.config.settings import config
|
||||
from src.services.request.execution_runtime_plan import ExecutionProxySnapshot
|
||||
from src.services.request.execution_runtime_client import (
|
||||
ExecutionRuntimeStreamResult,
|
||||
ExecutionRuntimeSyncResult,
|
||||
)
|
||||
|
||||
|
||||
class _DummyStreamResponseCtx:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeDBContext:
|
||||
def __init__(self, db: object) -> None:
|
||||
self._db = db
|
||||
|
||||
def __enter__(self) -> object:
|
||||
return self._db
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _iter_chunks(chunks: list[bytes]) -> AsyncGenerator[bytes]:
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_upstream_context_proxy_builds_tunnel_snapshot(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
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",
|
||||
provider_proxy={"enabled": True, "node_id": "node-1"},
|
||||
key_proxy=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"resolve_effective_proxy",
|
||||
lambda provider_proxy, key_proxy=None: provider_proxy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"get_system_proxy_config_async",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"resolve_delegate_config_async",
|
||||
AsyncMock(return_value={"tunnel": True, "node_id": "node-1"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"resolve_proxy_info_async",
|
||||
AsyncMock(return_value={"mode": "tunnel", "node_id": "node-1", "label": "Node 1"}),
|
||||
)
|
||||
|
||||
async def _unexpected_build_proxy_url_async(proxy_config: object) -> str:
|
||||
raise AssertionError(f"proxy url should not be built for tunnel: {proxy_config!r}")
|
||||
|
||||
monkeypatch.setattr(
|
||||
resolver_mod,
|
||||
"build_proxy_url_async",
|
||||
_unexpected_build_proxy_url_async,
|
||||
)
|
||||
|
||||
enriched = await gemini_files_mod._enrich_upstream_context_proxy(ctx)
|
||||
|
||||
assert enriched.proxy_config == {"enabled": True, "node_id": "node-1"}
|
||||
assert enriched.delegate_config == {"tunnel": True, "node_id": "node-1"}
|
||||
assert enriched.proxy_snapshot is not None
|
||||
assert enriched.proxy_snapshot.mode == "tunnel"
|
||||
assert enriched.proxy_snapshot.node_id == "node-1"
|
||||
assert enriched.proxy_snapshot.url is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_request_passes_proxy_snapshot_to_rust_executor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "executor_backend", "rust")
|
||||
proxy_snapshot = ExecutionProxySnapshot(
|
||||
enabled=True,
|
||||
mode="http",
|
||||
url="http://proxy.local:8080",
|
||||
)
|
||||
|
||||
async def _fake_execute_sync_json(self: object, plan: object) -> ExecutionRuntimeSyncResult:
|
||||
assert getattr(plan, "method") == "GET"
|
||||
assert getattr(plan, "provider_id") == "prov-1"
|
||||
assert getattr(plan, "endpoint_id") == "ep-1"
|
||||
assert getattr(plan, "proxy").url == "http://proxy.local:8080"
|
||||
return ExecutionRuntimeSyncResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json", "x-rust-files": "true"},
|
||||
response_json={"files": [{"name": "files/abc"}]},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
rust_client_mod.ExecutionRuntimeClient,
|
||||
"execute_sync_json",
|
||||
_fake_execute_sync_json,
|
||||
)
|
||||
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",
|
||||
provider_id="prov-1",
|
||||
endpoint_id="ep-1",
|
||||
proxy=proxy_snapshot,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["x-rust-files"] == "true"
|
||||
assert json.loads(response.body) == {"files": [{"name": "files/abc"}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_request_returns_503_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_try_rust_sync_proxy_request(*args: object, **kwargs: object) -> Response:
|
||||
del args, kwargs
|
||||
return gemini_files_mod._build_rust_unavailable_response()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gemini_files_mod,
|
||||
"_try_rust_sync_proxy_request",
|
||||
_fake_try_rust_sync_proxy_request,
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert response.status_code == 503
|
||||
assert body["error"]["code"] == 503
|
||||
assert body["error"]["status"] == "UNAVAILABLE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_uses_enriched_proxy_snapshot_for_regular_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "executor_backend", "rust")
|
||||
dummy_ctx = _DummyStreamResponseCtx()
|
||||
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",
|
||||
proxy_config={"enabled": True, "url": "http://proxy.local:8080"},
|
||||
delegate_config=None,
|
||||
proxy_snapshot=ExecutionProxySnapshot(
|
||||
enabled=True,
|
||||
mode="http",
|
||||
url="http://proxy.local:8080",
|
||||
),
|
||||
)
|
||||
|
||||
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) -> ExecutionRuntimeStreamResult:
|
||||
assert getattr(plan, "method") == "GET"
|
||||
assert getattr(plan, "url") == (
|
||||
"https://generativelanguage.googleapis.com/v1beta/files/file-1:download?alt=media"
|
||||
)
|
||||
assert getattr(plan, "headers") == {"x-goog-api-key": "upstream-key"}
|
||||
assert getattr(plan, "proxy").url == "http://proxy.local:8080"
|
||||
return ExecutionRuntimeStreamResult(
|
||||
status_code=200,
|
||||
headers={"content-type": "application/octet-stream", "x-rust-files": "true"},
|
||||
byte_iterator=_iter_chunks([b"file-", b"bytes"]),
|
||||
response_ctx=dummy_ctx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
rust_client_mod.ExecutionRuntimeClient,
|
||||
"execute_stream",
|
||||
_fake_execute_stream,
|
||||
)
|
||||
response = await gemini_files_mod._download_file_response(
|
||||
"file-1",
|
||||
SimpleNamespace(
|
||||
headers={},
|
||||
query_params={"alt": "media"},
|
||||
),
|
||||
)
|
||||
|
||||
assert isinstance(response, StreamingResponse)
|
||||
assert response.headers["x-rust-files"] == "true"
|
||||
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) -> ExecutionRuntimeStreamResult:
|
||||
del self, plan
|
||||
raise rust_client_mod.ExecutionRuntimeClientError("executor unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
rust_client_mod.ExecutionRuntimeClient,
|
||||
"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"
|
||||
@@ -1,133 +0,0 @@
|
||||
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"},
|
||||
}
|
||||
]
|
||||
@@ -1,117 +0,0 @@
|
||||
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}
|
||||
@@ -1,153 +0,0 @@
|
||||
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"},
|
||||
}
|
||||
]
|
||||
@@ -1,130 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -1,130 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _build_provider_fixture() -> SimpleNamespace:
|
||||
endpoint = SimpleNamespace(
|
||||
id="endpoint_1",
|
||||
base_url="https://upstream.test",
|
||||
api_format="openai:chat",
|
||||
is_active=True,
|
||||
)
|
||||
key = SimpleNamespace(
|
||||
id="key_1",
|
||||
is_active=True,
|
||||
api_formats=None,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id="provider_1",
|
||||
name="openai",
|
||||
endpoints=[endpoint],
|
||||
api_keys=[key],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_prefers_rust_executor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.public import system_catalog as mod
|
||||
|
||||
provider = _build_provider_fixture()
|
||||
|
||||
monkeypatch.setattr(mod, "_select_provider", lambda _db, _provider_name: provider)
|
||||
monkeypatch.setattr(mod, "build_test_request_body", lambda *_args, **_kwargs: {"model": "gpt-test"})
|
||||
monkeypatch.setattr(mod, "get_provider_auth", AsyncMock(return_value=None))
|
||||
|
||||
class _DummyBuilder:
|
||||
def build(self, *_args: object, **_kwargs: object) -> tuple[dict[str, str], dict[str, str]]:
|
||||
return {"model": "gpt-test"}, {"authorization": "Bearer test"}
|
||||
|
||||
monkeypatch.setattr(mod, "PassthroughRequestBuilder", lambda: _DummyBuilder())
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"build_provider_url",
|
||||
lambda *_args, **_kwargs: "https://upstream.test/v1/chat/completions",
|
||||
)
|
||||
|
||||
proxy_snapshot = object()
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_build_test_connection_transport_context",
|
||||
AsyncMock(return_value=({"enabled": True}, {"node_id": "node-1", "tunnel": True}, proxy_snapshot)),
|
||||
)
|
||||
|
||||
rust_response = httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", "https://upstream.test/v1/chat/completions"),
|
||||
json={"id": "resp_rust"},
|
||||
)
|
||||
rust_call = AsyncMock(return_value=rust_response)
|
||||
monkeypatch.setattr(mod, "_try_rust_test_connection_response", rust_call)
|
||||
|
||||
result = await mod._test_connection_response(
|
||||
request=SimpleNamespace(query_params={}),
|
||||
db=MagicMock(),
|
||||
provider=None,
|
||||
model="gpt-test",
|
||||
api_format=None,
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["response_id"] == "resp_rust"
|
||||
rust_call.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_returns_503_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.public import system_catalog as mod
|
||||
|
||||
provider = _build_provider_fixture()
|
||||
|
||||
monkeypatch.setattr(mod, "_select_provider", lambda _db, _provider_name: provider)
|
||||
monkeypatch.setattr(mod, "build_test_request_body", lambda *_args, **_kwargs: {"model": "gpt-test"})
|
||||
monkeypatch.setattr(mod, "get_provider_auth", AsyncMock(return_value=None))
|
||||
|
||||
class _DummyBuilder:
|
||||
def build(self, *_args: object, **_kwargs: object) -> tuple[dict[str, str], dict[str, str]]:
|
||||
return {"model": "gpt-test"}, {"authorization": "Bearer test"}
|
||||
|
||||
monkeypatch.setattr(mod, "PassthroughRequestBuilder", lambda: _DummyBuilder())
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"build_provider_url",
|
||||
lambda *_args, **_kwargs: "https://upstream.test/v1/chat/completions",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_build_test_connection_transport_context",
|
||||
AsyncMock(return_value=({"enabled": True}, {"node_id": "node-1", "tunnel": True}, 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,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "System catalog test-connection requires Rust executor"
|
||||
@@ -1,164 +0,0 @@
|
||||
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",
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -1,123 +0,0 @@
|
||||
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",
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -1,505 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.admin.api_keys.routes import (
|
||||
AdminCreateStandaloneKeyAdapter,
|
||||
AdminGetFullKeyAdapter,
|
||||
AdminToggleApiKeyAdapter,
|
||||
AdminUpdateApiKeyAdapter,
|
||||
)
|
||||
from src.api.admin.api_keys.routes import router as admin_api_keys_router
|
||||
from src.api.admin.users.routes import (
|
||||
AdminGetUserKeyFullKeyAdapter,
|
||||
AdminToggleUserKeyLockAdapter,
|
||||
AdminUpdateUserKeyAdapter,
|
||||
)
|
||||
from src.api.admin.users.routes import router as admin_users_router
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.database import get_db
|
||||
from src.models.api import CreateApiKeyRequest
|
||||
|
||||
|
||||
def _patch_get_db_context(monkeypatch: pytest.MonkeyPatch, db: MagicMock) -> None:
|
||||
@contextmanager
|
||||
def _fake_ctx() -> Generator[MagicMock, None, None]:
|
||||
yield db
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes.get_db_context", _fake_ctx)
|
||||
monkeypatch.setattr("src.api.admin.api_keys.routes.get_db_context", _fake_ctx)
|
||||
|
||||
|
||||
def _build_context(db: MagicMock) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
|
||||
def _mock_query_first(db: MagicMock, value: object | None) -> None:
|
||||
db.query.return_value.filter.return_value.first.return_value = value
|
||||
|
||||
|
||||
def _build_admin_users_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(admin_users_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*, adapter: object, http_request: object, db: MagicMock, mode: object
|
||||
) -> object:
|
||||
_ = http_request, mode
|
||||
try:
|
||||
payload = await http_request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
ensure_json_body=lambda: payload,
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
return await adapter.handle(context)
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _build_admin_api_keys_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(admin_api_keys_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*, adapter: object, http_request: object, db: MagicMock, mode: object
|
||||
) -> object:
|
||||
_ = http_request, mode
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
ensure_json_body=lambda: {},
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
return await adapter.handle(context)
|
||||
|
||||
monkeypatch.setattr("src.api.admin.api_keys.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_user_key_lock_adapter_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(id="key-1", user_id="user-1", is_standalone=False, is_locked=False)
|
||||
_mock_query_first(db, api_key)
|
||||
_patch_get_db_context(monkeypatch, db)
|
||||
|
||||
adapter = AdminToggleUserKeyLockAdapter(user_id="user-1", key_id="key-1")
|
||||
result = await adapter.handle(_build_context(db))
|
||||
|
||||
assert result["id"] == "key-1"
|
||||
assert result["is_locked"] is True
|
||||
assert "锁定" in result["message"]
|
||||
db.commit.assert_called_once()
|
||||
db.refresh.assert_called_once_with(api_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_user_key_lock_adapter_not_found_for_standalone_or_wrong_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
_mock_query_first(db, None)
|
||||
_patch_get_db_context(monkeypatch, db)
|
||||
|
||||
adapter = AdminToggleUserKeyLockAdapter(user_id="user-1", key_id="key-standalone")
|
||||
with pytest.raises(NotFoundException):
|
||||
await adapter.handle(_build_context(db))
|
||||
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_key_full_key_adapter_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="key-2",
|
||||
user_id="user-1",
|
||||
is_standalone=False,
|
||||
key_encrypted="encrypted-value",
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", lambda _v: "sk-user-full-key")
|
||||
|
||||
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-2")
|
||||
result = await adapter.handle(_build_context(db))
|
||||
|
||||
assert result == {"key": "sk-user-full-key"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_key_full_key_adapter_requires_encrypted_key() -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="key-3",
|
||||
user_id="user-1",
|
||||
is_standalone=False,
|
||||
key_encrypted=None,
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-3")
|
||||
with pytest.raises(InvalidRequestException):
|
||||
await adapter.handle(_build_context(db))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_key_full_key_adapter_returns_500_on_decrypt_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="key-4",
|
||||
user_id="user-1",
|
||||
is_standalone=False,
|
||||
key_encrypted="encrypted-value",
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
def _raise(_: str) -> str:
|
||||
raise ValueError("decrypt failed")
|
||||
|
||||
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", _raise)
|
||||
|
||||
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-4")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await adapter.handle(_build_context(db))
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_toggle_adapters_reject_normal_user_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
normal_key = SimpleNamespace(
|
||||
id="key-user",
|
||||
user_id="user-1",
|
||||
is_standalone=False,
|
||||
is_active=True,
|
||||
is_locked=False,
|
||||
key_encrypted="encrypted-value",
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
_mock_query_first(db, normal_key)
|
||||
_patch_get_db_context(monkeypatch, db)
|
||||
context = _build_context(db)
|
||||
|
||||
with pytest.raises(InvalidRequestException):
|
||||
await AdminToggleApiKeyAdapter(key_id="key-user").handle(context)
|
||||
|
||||
with pytest.raises(InvalidRequestException):
|
||||
await AdminGetFullKeyAdapter(key_id="key-user").handle(context)
|
||||
|
||||
|
||||
def test_user_key_lock_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(id="key-5", user_id="user-2", is_standalone=False, is_locked=False)
|
||||
_mock_query_first(db, api_key)
|
||||
_patch_get_db_context(monkeypatch, db)
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
|
||||
response = client.patch("/api/admin/users/user-2/api-keys/key-5/lock")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == "key-5"
|
||||
assert response.json()["is_locked"] is True
|
||||
|
||||
|
||||
def test_user_key_full_key_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="key-6",
|
||||
user_id="user-2",
|
||||
is_standalone=False,
|
||||
key_encrypted="enc",
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", lambda _v: "sk-user-route-key")
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
|
||||
response = client.get("/api/admin/users/user-2/api-keys/key-6/full-key")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"key": "sk-user-route-key"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_key_adapter_passes_rate_limit_and_name(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _update_user_key_sync(
|
||||
user_id: str, key_id: str, request: object
|
||||
) -> tuple[dict[str, object], dict[str, object]]:
|
||||
captured["user_id"] = user_id
|
||||
captured["key_id"] = key_id
|
||||
captured["name"] = getattr(request, "name", None)
|
||||
captured["rate_limit"] = getattr(request, "rate_limit", None)
|
||||
return {"id": key_id, "name": captured["name"], "rate_limit": captured["rate_limit"]}, {}
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes._update_user_key_sync", _update_user_key_sync)
|
||||
|
||||
adapter = AdminUpdateUserKeyAdapter(user_id="user-1", key_id="key-7")
|
||||
context = SimpleNamespace(
|
||||
db=MagicMock(),
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
ensure_json_body=lambda: {"name": "Renamed Key", "rate_limit": 12},
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["id"] == "key-7"
|
||||
assert captured == {
|
||||
"user_id": "user-1",
|
||||
"key_id": "key-7",
|
||||
"name": "Renamed Key",
|
||||
"rate_limit": 12,
|
||||
}
|
||||
|
||||
|
||||
def test_update_user_key_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _update_user_key_sync(
|
||||
user_id: str, key_id: str, request: object
|
||||
) -> tuple[dict[str, object], dict[str, object]]:
|
||||
captured["user_id"] = user_id
|
||||
captured["key_id"] = key_id
|
||||
captured["name"] = getattr(request, "name", None)
|
||||
captured["rate_limit"] = getattr(request, "rate_limit", None)
|
||||
return {"id": key_id, "name": captured["name"], "rate_limit": captured["rate_limit"]}, {}
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes._update_user_key_sync", _update_user_key_sync)
|
||||
client = _build_admin_users_app(MagicMock(), monkeypatch)
|
||||
|
||||
response = client.put(
|
||||
"/api/admin/users/user-2/api-keys/key-8",
|
||||
json={"name": "Updated", "rate_limit": 9},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rate_limit"] == 9
|
||||
assert captured == {
|
||||
"user_id": "user-2",
|
||||
"key_id": "key-8",
|
||||
"name": "Updated",
|
||||
"rate_limit": 9,
|
||||
}
|
||||
|
||||
|
||||
def test_standalone_lock_route_removed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _build_admin_api_keys_app(MagicMock(), monkeypatch)
|
||||
response = client.patch("/api/admin/api-keys/key-1/lock")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_standalone_list_route_does_not_expose_is_locked(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
_patch_get_db_context(monkeypatch, db)
|
||||
api_key = SimpleNamespace(
|
||||
id="sa-key-1",
|
||||
user_id="admin-1",
|
||||
name="Standalone Key",
|
||||
get_display_key=lambda: "sk-stand...1234",
|
||||
is_active=True,
|
||||
is_standalone=True,
|
||||
total_requests=0,
|
||||
total_cost_usd=0,
|
||||
rate_limit=None,
|
||||
allowed_providers=None,
|
||||
allowed_api_formats=None,
|
||||
allowed_models=None,
|
||||
last_used_at=None,
|
||||
expires_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=None,
|
||||
auto_delete_on_expiry=False,
|
||||
)
|
||||
query = db.query.return_value.filter.return_value
|
||||
query.count.return_value = 1
|
||||
query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [api_key]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.get_wallet",
|
||||
lambda _db, user_id=None, api_key_id=None, user=None, api_key=None: SimpleNamespace(
|
||||
id="w-1"
|
||||
),
|
||||
)
|
||||
client = _build_admin_api_keys_app(db, monkeypatch)
|
||||
|
||||
response = client.get("/api/admin/api-keys")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload["api_keys"]) == 1
|
||||
assert "is_locked" not in payload["api_keys"][0]
|
||||
|
||||
|
||||
def test_standalone_detail_route_does_not_expose_is_locked(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="sa-key-2",
|
||||
user_id="admin-1",
|
||||
name="Standalone Key 2",
|
||||
get_display_key=lambda: "sk-stand...5678",
|
||||
is_active=True,
|
||||
is_standalone=True,
|
||||
total_requests=0,
|
||||
total_cost_usd=0,
|
||||
rate_limit=None,
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
last_used_at=None,
|
||||
expires_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=None,
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.get_wallet",
|
||||
lambda _db, user_id=None, api_key_id=None, user=None, api_key=None: None,
|
||||
)
|
||||
client = _build_admin_api_keys_app(db, monkeypatch)
|
||||
|
||||
response = client.get("/api/admin/api-keys/sa-key-2")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == "sa-key-2"
|
||||
assert "is_locked" not in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_standalone_key_adapter_preserves_empty_restriction_lists(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
_patch_get_db_context(monkeypatch, db)
|
||||
captured: dict[str, object] = {}
|
||||
created_key = SimpleNamespace(
|
||||
id="sa-key-3",
|
||||
name="Standalone Key 3",
|
||||
get_display_key=lambda: "sk-stand...9012",
|
||||
is_active=True,
|
||||
rate_limit=None,
|
||||
expires_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
)
|
||||
|
||||
def _create_api_key(**kwargs: object) -> tuple[SimpleNamespace, str]:
|
||||
captured.update(kwargs)
|
||||
return created_key, "sk-created"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.ApiKeyService.create_api_key", _create_api_key
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.initialize_api_key_wallet",
|
||||
lambda *_a, **_k: SimpleNamespace(id="wallet-1"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.serialize_wallet_summary",
|
||||
lambda _wallet: {"id": "wallet-1"},
|
||||
)
|
||||
|
||||
adapter = AdminCreateStandaloneKeyAdapter(
|
||||
CreateApiKeyRequest(
|
||||
name="Standalone Key 3",
|
||||
initial_balance_usd=10,
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
)
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["id"] == "sa-key-3"
|
||||
assert captured["allowed_providers"] == []
|
||||
assert captured["allowed_api_formats"] == []
|
||||
assert captured["allowed_models"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_standalone_key_adapter_preserves_empty_restriction_lists(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
_patch_get_db_context(monkeypatch, db)
|
||||
existing_key = SimpleNamespace(id="sa-key-4", is_standalone=True)
|
||||
_mock_query_first(db, existing_key)
|
||||
|
||||
updated_key = SimpleNamespace(
|
||||
id="sa-key-4",
|
||||
name="Standalone Key 4",
|
||||
get_display_key=lambda: "sk-stand...3456",
|
||||
is_active=True,
|
||||
rate_limit=None,
|
||||
expires_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _update_api_key(_db: MagicMock, _key_id: str, **kwargs: object) -> SimpleNamespace:
|
||||
captured.update(kwargs)
|
||||
return updated_key
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.ApiKeyService.update_api_key", _update_api_key
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes._ensure_standalone_wallet",
|
||||
lambda *_a, **_k: SimpleNamespace(id="wallet-2"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.serialize_wallet_summary",
|
||||
lambda _wallet: {"id": "wallet-2"},
|
||||
)
|
||||
|
||||
adapter = AdminUpdateApiKeyAdapter(
|
||||
key_id="sa-key-4",
|
||||
key_data=CreateApiKeyRequest(
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
),
|
||||
)
|
||||
|
||||
result = await adapter.handle(_build_context(db))
|
||||
|
||||
assert result["id"] == "sa-key-4"
|
||||
assert captured["allowed_providers"] == []
|
||||
assert captured["allowed_api_formats"] == []
|
||||
assert captured["allowed_models"] == []
|
||||
@@ -1,88 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.admin.pool.routes import AdminBatchActionKeysAdapter
|
||||
from src.api.admin.pool.schemas import BatchActionRequest
|
||||
|
||||
|
||||
def _build_context(db: MagicMock) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(username="admin-1"),
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
|
||||
def _mock_provider_lookup(db: MagicMock, provider_id: str) -> None:
|
||||
db.query.return_value.filter.return_value.first.return_value = SimpleNamespace(id=provider_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_delete_submits_async_task(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Delete action now submits an async batch-delete task instead of
|
||||
executing SQL synchronously."""
|
||||
db = MagicMock()
|
||||
provider_id = "provider-1"
|
||||
_mock_provider_lookup(db, provider_id)
|
||||
|
||||
mock_submit = AsyncMock(return_value="task-abc-123")
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_keys.batch_delete_task.submit_batch_delete",
|
||||
mock_submit,
|
||||
)
|
||||
|
||||
key_ids = [f"key-{idx}" for idx in range(1200)]
|
||||
adapter = AdminBatchActionKeysAdapter(
|
||||
provider_id=provider_id,
|
||||
body=BatchActionRequest(
|
||||
key_ids=key_ids,
|
||||
action="delete",
|
||||
),
|
||||
)
|
||||
|
||||
result = await adapter.handle(_build_context(db))
|
||||
|
||||
# Async task returns affected=0 and a task_id
|
||||
assert result.affected == 0
|
||||
assert result.task_id == "task-abc-123"
|
||||
assert "1200" in result.message
|
||||
mock_submit.assert_awaited_once_with(provider_id, list(dict.fromkeys(key_ids)))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_delete_deduplicates_key_ids(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Duplicate key IDs should be deduplicated before submission."""
|
||||
db = MagicMock()
|
||||
provider_id = "provider-2"
|
||||
_mock_provider_lookup(db, provider_id)
|
||||
|
||||
mock_submit = AsyncMock(return_value="task-xyz-456")
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_keys.batch_delete_task.submit_batch_delete",
|
||||
mock_submit,
|
||||
)
|
||||
|
||||
adapter = AdminBatchActionKeysAdapter(
|
||||
provider_id=provider_id,
|
||||
body=BatchActionRequest(
|
||||
key_ids=["key-1", "key-2", "key-1", "key-3", "key-2"],
|
||||
action="delete",
|
||||
),
|
||||
)
|
||||
|
||||
result = await adapter.handle(_build_context(db))
|
||||
|
||||
assert result.affected == 0
|
||||
assert result.task_id == "task-xyz-456"
|
||||
# Deduplicated: 3 unique keys
|
||||
submitted_ids = mock_submit.call_args[0][1]
|
||||
assert len(submitted_ids) == 3
|
||||
assert submitted_ids == ["key-1", "key-2", "key-3"]
|
||||
@@ -1,128 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.admin.providers.routes import (
|
||||
AdminDeleteProviderAdapter,
|
||||
AdminProviderDeleteTaskStatusAdapter,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_provider_adapter_submits_async_task_and_deactivates_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
provider = SimpleNamespace(id="provider-1", name="Provider 1", is_active=True)
|
||||
db.query.return_value.filter.return_value.first.return_value = provider
|
||||
|
||||
submit_task_mock = AsyncMock(return_value="task-1")
|
||||
invalidate_models_mock = AsyncMock()
|
||||
invalidate_resolve_mock = AsyncMock()
|
||||
invalidate_provider_cache_mock = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.submit_provider_delete",
|
||||
submit_task_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.invalidate_models_list_cache",
|
||||
invalidate_models_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.ModelCacheService.invalidate_all_resolve_cache",
|
||||
invalidate_resolve_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.ProviderCacheService.invalidate_provider_cache",
|
||||
invalidate_provider_cache_mock,
|
||||
)
|
||||
|
||||
audit_calls: list[dict[str, object]] = []
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
add_audit_metadata=lambda **kwargs: audit_calls.append(kwargs),
|
||||
)
|
||||
|
||||
adapter = AdminDeleteProviderAdapter(provider_id="provider-1")
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result == {
|
||||
"task_id": "task-1",
|
||||
"status": "pending",
|
||||
"message": "删除任务已提交,提供商已进入后台删除队列",
|
||||
}
|
||||
submit_task_mock.assert_awaited_once_with("provider-1")
|
||||
assert provider.is_active is False
|
||||
db.commit.assert_called_once()
|
||||
invalidate_models_mock.assert_awaited_once()
|
||||
invalidate_resolve_mock.assert_awaited_once()
|
||||
invalidate_provider_cache_mock.assert_awaited_once_with("provider-1")
|
||||
assert audit_calls[0]["action"] == "delete_provider"
|
||||
assert audit_calls[1]["task_id"] == "task-1"
|
||||
assert audit_calls[1]["provider_deactivated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_provider_adapter_reuses_task_without_extra_commit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
provider = SimpleNamespace(id="provider-1", name="Provider 1", is_active=False)
|
||||
db.query.return_value.filter.return_value.first.return_value = provider
|
||||
|
||||
submit_task_mock = AsyncMock(return_value="task-1")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.submit_provider_delete",
|
||||
submit_task_mock,
|
||||
)
|
||||
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
add_audit_metadata=lambda **kwargs: None,
|
||||
)
|
||||
|
||||
adapter = AdminDeleteProviderAdapter(provider_id="provider-1")
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["task_id"] == "task-1"
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_provider_task_status_adapter_returns_task_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
task = SimpleNamespace(
|
||||
task_id="task-1",
|
||||
provider_id="provider-1",
|
||||
status="running",
|
||||
stage="deleting_keys",
|
||||
total_keys=100,
|
||||
deleted_keys=25,
|
||||
total_endpoints=8,
|
||||
deleted_endpoints=2,
|
||||
message="deleted key batch 1/2",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.get_provider_delete_task",
|
||||
AsyncMock(return_value=task),
|
||||
)
|
||||
|
||||
context = SimpleNamespace(db=MagicMock(), request=SimpleNamespace(state=SimpleNamespace()))
|
||||
adapter = AdminProviderDeleteTaskStatusAdapter(provider_id="provider-1", task_id="task-1")
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result.task_id == "task-1"
|
||||
assert result.status == "running"
|
||||
assert result.stage == "deleting_keys"
|
||||
assert result.deleted_keys == 25
|
||||
assert result.deleted_endpoints == 2
|
||||
@@ -1,339 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.admin.usage.routes import (
|
||||
AdminUsageDetailAdapter,
|
||||
AdminUsageRecordsAdapter,
|
||||
_resolve_replay_model_name,
|
||||
)
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scalar_result: int | None = None,
|
||||
all_result: list[Any] | None = None,
|
||||
first_result: Any = None,
|
||||
) -> None:
|
||||
self.scalar_result = scalar_result
|
||||
self.all_result = all_result or []
|
||||
self.first_result = first_result
|
||||
self.options_args: tuple[Any, ...] = ()
|
||||
|
||||
def outerjoin(self, *args: Any, **kwargs: Any) -> _FakeQuery:
|
||||
return self
|
||||
|
||||
def join(self, *args: Any, **kwargs: Any) -> _FakeQuery:
|
||||
return self
|
||||
|
||||
def filter(self, *args: Any, **kwargs: Any) -> _FakeQuery:
|
||||
return self
|
||||
|
||||
def options(self, *args: Any) -> _FakeQuery:
|
||||
self.options_args = args
|
||||
return self
|
||||
|
||||
def order_by(self, *args: Any, **kwargs: Any) -> _FakeQuery:
|
||||
return self
|
||||
|
||||
def offset(self, *args: Any, **kwargs: Any) -> _FakeQuery:
|
||||
return self
|
||||
|
||||
def limit(self, *args: Any, **kwargs: Any) -> _FakeQuery:
|
||||
return self
|
||||
|
||||
def scalar(self) -> int | None:
|
||||
return self.scalar_result
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return self.all_result
|
||||
|
||||
def first(self) -> Any:
|
||||
return self.first_result
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self, queries: list[_FakeQuery]) -> None:
|
||||
self._queries = queries
|
||||
self.query_calls: list[tuple[Any, ...]] = []
|
||||
|
||||
def query(self, *args: Any) -> _FakeQuery:
|
||||
self.query_calls.append(args)
|
||||
return self._queries[len(self.query_calls) - 1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_usage_records_returns_model_version_without_request_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("src.utils.cache_decorator.get_redis_client_sync", lambda: None)
|
||||
|
||||
usage = SimpleNamespace(
|
||||
id="usage-1",
|
||||
request_id=None,
|
||||
user_id="user-1",
|
||||
api_key_id=None,
|
||||
provider_name="google",
|
||||
provider_id=None,
|
||||
provider_endpoint_id=None,
|
||||
provider_api_key_id=None,
|
||||
model="gemini-2.5-pro",
|
||||
target_model=None,
|
||||
input_tokens=120,
|
||||
output_tokens=80,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_input_tokens=0,
|
||||
total_tokens=200,
|
||||
total_cost_usd=Decimal("1.25"),
|
||||
actual_total_cost_usd=Decimal("1.25"),
|
||||
rate_multiplier=Decimal("1.0"),
|
||||
response_time_ms=850,
|
||||
first_byte_time_ms=230,
|
||||
created_at=datetime(2026, 3, 9, 8, 30, tzinfo=timezone.utc),
|
||||
is_stream=False,
|
||||
status_code=200,
|
||||
error_message=None,
|
||||
status="completed",
|
||||
api_format="gemini:chat",
|
||||
endpoint_api_format=None,
|
||||
has_format_conversion=False,
|
||||
input_price_per_1m=Decimal("0.10"),
|
||||
output_price_per_1m=Decimal("0.30"),
|
||||
cache_creation_price_per_1m=None,
|
||||
cache_read_price_per_1m=None,
|
||||
)
|
||||
user = SimpleNamespace(id="user-1", email="user@example.com", username="tester")
|
||||
|
||||
count_query = _FakeQuery(scalar_result=1)
|
||||
data_query = _FakeQuery(
|
||||
all_result=[
|
||||
(usage, user, None, None, None, "gemini-2.5-pro-001"),
|
||||
]
|
||||
)
|
||||
db = _FakeDb([count_query, data_query])
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
adapter = AdminUsageRecordsAdapter(
|
||||
time_range=None,
|
||||
search=None,
|
||||
user_id=None,
|
||||
username=None,
|
||||
model=None,
|
||||
provider=None,
|
||||
api_format=None,
|
||||
status=None,
|
||||
limit=100,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert len(db.query_calls) == 2
|
||||
assert len(db.query_calls[1]) == 6
|
||||
assert getattr(db.query_calls[1][-1], "name", None) == "model_version"
|
||||
|
||||
record = result["records"][0]
|
||||
assert record["model_version"] == "gemini-2.5-pro-001"
|
||||
assert "request_metadata" not in record
|
||||
|
||||
usage_load_only = data_query.options_args[0]
|
||||
usage_paths = {str(option.path) for option in usage_load_only.context}
|
||||
assert "ORM Path[Mapper[Usage(usage)] -> Usage.request_metadata]" not in usage_paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_usage_detail_defers_large_body_columns_when_bodies_excluded(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_get_tiered_pricing_info(
|
||||
self: AdminUsageDetailAdapter,
|
||||
db: Any,
|
||||
usage_record: Any,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
AdminUsageDetailAdapter,
|
||||
"_get_tiered_pricing_info",
|
||||
_fake_get_tiered_pricing_info,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AdminUsageDetailAdapter,
|
||||
"_extract_video_billing_info",
|
||||
lambda self, usage_record: None,
|
||||
)
|
||||
|
||||
class _UsageRecord:
|
||||
id = "usage-1"
|
||||
request_id = "req-1"
|
||||
user_id = "user-1"
|
||||
api_key_id = "key-1"
|
||||
provider_name = "openai"
|
||||
api_format = "openai:cli"
|
||||
model = "gpt-5.4"
|
||||
target_model = None
|
||||
input_tokens = 10
|
||||
output_tokens = 20
|
||||
total_tokens = 30
|
||||
cache_creation_input_tokens = 0
|
||||
cache_read_input_tokens = 0
|
||||
cache_creation_input_tokens_5m = 0
|
||||
cache_creation_input_tokens_1h = 0
|
||||
input_cost_usd = Decimal("0.001")
|
||||
output_cost_usd = Decimal("0.002")
|
||||
total_cost_usd = Decimal("0.003")
|
||||
cache_creation_cost_usd = Decimal("0")
|
||||
cache_read_cost_usd = Decimal("0")
|
||||
request_cost_usd = Decimal("0")
|
||||
input_price_per_1m = Decimal("0.1")
|
||||
output_price_per_1m = Decimal("0.2")
|
||||
cache_creation_price_per_1m = None
|
||||
cache_read_price_per_1m = None
|
||||
price_per_request = None
|
||||
request_type = "chat"
|
||||
is_stream = True
|
||||
status_code = 200
|
||||
error_message = None
|
||||
status = "completed"
|
||||
response_time_ms = 1200
|
||||
first_byte_time_ms = 200
|
||||
created_at = datetime(2026, 3, 12, 7, 0, tzinfo=timezone.utc)
|
||||
request_headers = {"x-test": "1"}
|
||||
provider_request_headers = {"authorization": "***"}
|
||||
response_headers = {"content-type": "text/event-stream"}
|
||||
client_response_headers = {"content-type": "text/event-stream"}
|
||||
request_metadata = {"trace_id": "trace-1"}
|
||||
|
||||
def get_request_body(self) -> Any:
|
||||
raise AssertionError("request body should not be loaded")
|
||||
|
||||
def get_provider_request_body(self) -> Any:
|
||||
raise AssertionError("provider request body should not be loaded")
|
||||
|
||||
def get_response_body(self) -> Any:
|
||||
raise AssertionError("response body should not be loaded")
|
||||
|
||||
def get_client_response_body(self) -> Any:
|
||||
raise AssertionError("client response body should not be loaded")
|
||||
|
||||
class _ApiKeyRecord:
|
||||
id = "key-1"
|
||||
name = "Primary"
|
||||
|
||||
def get_display_key(self) -> str:
|
||||
return "sk-test"
|
||||
|
||||
usage_query = _FakeQuery(
|
||||
first_result=(_UsageRecord(), True, True, True, True),
|
||||
)
|
||||
user_query = _FakeQuery(
|
||||
first_result=SimpleNamespace(id="user-1", username="tester", email="u@example.com"),
|
||||
)
|
||||
api_key_query = _FakeQuery(first_result=_ApiKeyRecord())
|
||||
db = _FakeDb([usage_query, user_query, api_key_query])
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
adapter = AdminUsageDetailAdapter(usage_id="usage-1", include_bodies=False)
|
||||
result = await adapter.handle(context) # type: ignore[arg-type]
|
||||
|
||||
assert result["request_body"] is None
|
||||
assert result["provider_request_body"] is None
|
||||
assert result["response_body"] is None
|
||||
assert result["client_response_body"] is None
|
||||
assert result["has_request_body"] is True
|
||||
assert result["has_provider_request_body"] is True
|
||||
assert result["has_response_body"] is True
|
||||
assert result["has_client_response_body"] is True
|
||||
|
||||
deferred_paths = {
|
||||
str(context.path)
|
||||
for option in usage_query.options_args
|
||||
for context in getattr(option, "context", ())
|
||||
}
|
||||
assert "ORM Path[Mapper[Usage(usage)] -> Usage.request_body]" in deferred_paths
|
||||
assert "ORM Path[Mapper[Usage(usage)] -> Usage.provider_request_body]" in deferred_paths
|
||||
assert "ORM Path[Mapper[Usage(usage)] -> Usage.response_body]" in deferred_paths
|
||||
assert "ORM Path[Mapper[Usage(usage)] -> Usage.client_response_body]" in deferred_paths
|
||||
assert "ORM Path[Mapper[Usage(usage)] -> Usage.request_body_compressed]" in deferred_paths
|
||||
assert (
|
||||
"ORM Path[Mapper[Usage(usage)] -> Usage.provider_request_body_compressed]" in deferred_paths
|
||||
)
|
||||
assert "ORM Path[Mapper[Usage(usage)] -> Usage.response_body_compressed]" in deferred_paths
|
||||
assert (
|
||||
"ORM Path[Mapper[Usage(usage)] -> Usage.client_response_body_compressed]" in deferred_paths
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_replay_model_name_falls_back_to_source_model_when_mapping_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FakeMapper:
|
||||
def __init__(self, db: Any) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_mapping(self, source_model: str, provider_id: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("src.services.model.mapper.ModelMapperMiddleware", _FakeMapper)
|
||||
|
||||
resolved_model, mapping_source = await _resolve_replay_model_name(
|
||||
SimpleNamespace(),
|
||||
source_model="gpt-4o-mini",
|
||||
target_provider=SimpleNamespace(id="provider-2", name="OpenAI Compatible"),
|
||||
target_endpoint=SimpleNamespace(id="endpoint-2", api_format="openai:responses"),
|
||||
target_api_key=None,
|
||||
)
|
||||
|
||||
assert resolved_model == "gpt-4o-mini"
|
||||
assert mapping_source == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_replay_model_name_reruns_mapping_for_same_endpoint_replay(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FakeModel:
|
||||
def select_provider_model_name(
|
||||
self, affinity_key: str | None = None, api_format: str | None = None
|
||||
) -> str:
|
||||
assert affinity_key == "key-2"
|
||||
assert api_format == "openai:responses"
|
||||
return "provider-model-for-key-2"
|
||||
|
||||
class _FakeMapper:
|
||||
def __init__(self, db: Any) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_mapping(self, source_model: str, provider_id: str) -> Any:
|
||||
assert source_model == "gpt-4o-mini"
|
||||
assert provider_id == "provider-2"
|
||||
return SimpleNamespace(model=_FakeModel())
|
||||
|
||||
monkeypatch.setattr("src.services.model.mapper.ModelMapperMiddleware", _FakeMapper)
|
||||
|
||||
resolved_model, mapping_source = await _resolve_replay_model_name(
|
||||
SimpleNamespace(),
|
||||
source_model="gpt-4o-mini",
|
||||
target_provider=SimpleNamespace(id="provider-2", name="OpenAI Compatible"),
|
||||
target_endpoint=SimpleNamespace(id="endpoint-2", api_format="openai:responses"),
|
||||
target_api_key=SimpleNamespace(id="key-2"),
|
||||
)
|
||||
|
||||
assert resolved_model == "provider-model-for-key-2"
|
||||
assert mapping_source == "model_mapping"
|
||||
@@ -1,197 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.admin.users.routes import AdminCreateUserAdapter
|
||||
from src.api.admin.users.routes import router as admin_users_router
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_admin_users_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(admin_users_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*, adapter: Any, http_request: object, db: MagicMock, mode: object
|
||||
) -> Any:
|
||||
_ = http_request, mode
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
ensure_json_body=lambda: {},
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
return await adapter.handle(context)
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_list_users_uses_wallet_batch_lookup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
now = datetime.now(timezone.utc)
|
||||
users = [
|
||||
SimpleNamespace(
|
||||
id="user-1",
|
||||
email="u1@example.com",
|
||||
username="user1",
|
||||
role=SimpleNamespace(value="user"),
|
||||
allowed_providers=None,
|
||||
allowed_api_formats=None,
|
||||
allowed_models=None,
|
||||
rate_limit=None,
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
last_login_at=None,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="user-2",
|
||||
email="u2@example.com",
|
||||
username="user2",
|
||||
role=SimpleNamespace(value="admin"),
|
||||
allowed_providers=None,
|
||||
allowed_api_formats=None,
|
||||
allowed_models=None,
|
||||
rate_limit=None,
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=None,
|
||||
last_login_at=None,
|
||||
),
|
||||
]
|
||||
wallets_by_user_id = {
|
||||
"user-1": SimpleNamespace(limit_mode="unlimited"),
|
||||
}
|
||||
batch_getter = MagicMock(return_value=wallets_by_user_id)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes.UserService.list_users", lambda *_a, **_k: users
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes.WalletService.get_wallets_by_user_ids",
|
||||
batch_getter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes.WalletService.get_wallet",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("不应回退到逐个钱包查询")),
|
||||
)
|
||||
|
||||
response = client.get("/api/admin/users")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()[0]["unlimited"] is True
|
||||
assert response.json()[1]["unlimited"] is False
|
||||
batch_getter.assert_called_once()
|
||||
assert batch_getter.call_args.args[1] == ["user-1", "user-2"]
|
||||
|
||||
|
||||
def test_list_user_sessions_route_returns_sessions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
sessions = [
|
||||
{
|
||||
"id": "session-1",
|
||||
"device_label": "Chrome / macOS",
|
||||
"device_type": "desktop",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"is_current": False,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes._list_user_sessions_sync",
|
||||
lambda user_id: (
|
||||
sessions,
|
||||
{"action": "list_user_sessions", "target_user_id": user_id},
|
||||
),
|
||||
)
|
||||
|
||||
response = client.get("/api/admin/users/user-1/sessions")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == sessions
|
||||
|
||||
|
||||
def test_revoke_user_session_route_returns_message(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes._revoke_user_session_sync",
|
||||
lambda user_id, session_id, admin_user_id: (
|
||||
{"message": f"{user_id}:{session_id}:revoked"},
|
||||
{"action": "revoke_user_session", "target_user_id": user_id, "session_id": session_id},
|
||||
),
|
||||
)
|
||||
|
||||
response = client.delete("/api/admin/users/user-1/sessions/session-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "user-1:session-1:revoked"}
|
||||
|
||||
|
||||
def test_revoke_all_user_sessions_route_returns_count(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes._revoke_all_user_sessions_sync",
|
||||
lambda user_id, admin_user_id: (
|
||||
{"message": "done", "revoked_count": 2},
|
||||
{"action": "revoke_all_user_sessions", "target_user_id": user_id, "revoked_count": 2},
|
||||
),
|
||||
)
|
||||
|
||||
response = client.delete("/api/admin/users/user-1/sessions")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "done", "revoked_count": 2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_adapter_preserves_empty_restriction_lists(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake_create_user_sync(request: Any, role: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
captured["request"] = request
|
||||
captured["role"] = role
|
||||
return {"id": "user-3"}, {"action": "create_user", "target_user_id": "user-3"}
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes._create_user_sync", _fake_create_user_sync)
|
||||
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
ensure_json_body=lambda: {
|
||||
"username": "user3",
|
||||
"password": "Abcd12",
|
||||
"email": "u3@example.com",
|
||||
"role": "user",
|
||||
"initial_gift_usd": 10,
|
||||
"allowed_providers": [],
|
||||
"allowed_api_formats": [],
|
||||
"allowed_models": [],
|
||||
},
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
result = await AdminCreateUserAdapter().handle(context)
|
||||
|
||||
assert result == {"id": "user-3"}
|
||||
assert captured["request"].allowed_providers == []
|
||||
assert captured["request"].allowed_api_formats == []
|
||||
assert captured["request"].allowed_models == []
|
||||
@@ -1,220 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.auth.routes import _logout_with_refresh_cookie_fallback
|
||||
from src.api.auth.routes import router as auth_router
|
||||
from src.config import config
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_auth_app(
|
||||
db: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
pipeline_result: Any = None,
|
||||
pipeline_exception: Exception | None = None,
|
||||
) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*, adapter: Any, http_request: object, db: MagicMock, mode: object
|
||||
) -> Any:
|
||||
_ = adapter, http_request, db, mode
|
||||
if pipeline_exception is not None:
|
||||
raise pipeline_exception
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr("src.api.auth.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_login_route_sets_refresh_cookie_and_hides_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_result={
|
||||
"access_token": "access-1",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400,
|
||||
"user_id": "user-1",
|
||||
"username": "tester",
|
||||
"role": "user",
|
||||
"_refresh_token": "refresh-1",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/login", json={"email": "user@example.com", "password": "pw"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_token": "access-1",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400,
|
||||
"user_id": "user-1",
|
||||
"username": "tester",
|
||||
"role": "user",
|
||||
}
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "refresh-1" in set_cookie
|
||||
assert "HttpOnly" in set_cookie
|
||||
|
||||
|
||||
def test_refresh_route_sets_cookie_and_hides_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_result={
|
||||
"access_token": "access-2",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400,
|
||||
"_refresh_token": "refresh-2",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_token": "access-2",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400,
|
||||
}
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "refresh-2" in set_cookie
|
||||
assert "HttpOnly" in set_cookie
|
||||
|
||||
|
||||
def test_refresh_route_clears_cookie_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_exception=HTTPException(status_code=401, detail="登录会话已失效,请重新登录"),
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"]["message"] == "登录会话已失效,请重新登录"
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "Max-Age=0" in set_cookie
|
||||
|
||||
|
||||
def test_logout_route_clears_cookie_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_exception=HTTPException(status_code=401, detail="缺少认证令牌"),
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/logout")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"]["message"] == "缺少认证令牌"
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "Max-Age=0" in set_cookie
|
||||
|
||||
|
||||
def test_logout_route_uses_refresh_cookie_fallback_on_auth_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_exception=HTTPException(status_code=401, detail="Token已过期"),
|
||||
)
|
||||
|
||||
async def _fake_fallback(_request: object, _db: MagicMock) -> dict[str, Any]:
|
||||
return {"message": "登出成功", "success": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes._logout_with_refresh_cookie_fallback",
|
||||
_fake_fallback,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/logout",
|
||||
cookies={config.auth_refresh_cookie_name: "refresh-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "登出成功", "success": True}
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "Max-Age=0" in set_cookie
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout_with_refresh_cookie_fallback_revokes_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
user = SimpleNamespace(id="user-1", email="user@example.com")
|
||||
session = SimpleNamespace(id="session-1", is_revoked=False, is_expired=False)
|
||||
db.query.return_value.filter.return_value.first.return_value = user
|
||||
request = SimpleNamespace(
|
||||
cookies={config.auth_refresh_cookie_name: "refresh-1"},
|
||||
headers={},
|
||||
query_params={},
|
||||
state=SimpleNamespace(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.AuthService.verify_token",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"user_id": "user-1",
|
||||
"session_id": "session-1",
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.SessionService.extract_client_device_id",
|
||||
lambda _request: "device-1",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.SessionService.get_session_for_user",
|
||||
lambda *_args, **_kwargs: session,
|
||||
)
|
||||
assert_session_device_matches = MagicMock()
|
||||
revoke_session = MagicMock()
|
||||
log_event = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.SessionService.assert_session_device_matches",
|
||||
assert_session_device_matches,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.SessionService.revoke_session",
|
||||
revoke_session,
|
||||
)
|
||||
monkeypatch.setattr("src.api.auth.routes.AuditService.log_event", log_event)
|
||||
monkeypatch.setattr("src.api.auth.routes.get_client_ip", lambda _request: "127.0.0.1")
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.get_user_agent",
|
||||
lambda _request: "pytest-agent",
|
||||
)
|
||||
|
||||
result = await _logout_with_refresh_cookie_fallback(request, db)
|
||||
|
||||
assert result == {"message": "登出成功", "success": True}
|
||||
assert_session_device_matches.assert_called_once_with(session, "device-1")
|
||||
revoke_session.assert_called_once()
|
||||
log_event.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
assert request.state.tx_committed_by_route is True
|
||||
@@ -1,146 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.admin.endpoints.health import AdminApiFormatHealthMonitorAdapter
|
||||
from src.api.public.catalog import PublicApiFormatHealthMonitorAdapter
|
||||
|
||||
|
||||
def _build_query(result: object) -> MagicMock:
|
||||
query = MagicMock()
|
||||
query.join.return_value = query
|
||||
query.distinct.return_value = query
|
||||
query.filter.return_value = query
|
||||
query.group_by.return_value = query
|
||||
query.order_by.return_value = query
|
||||
query.limit.return_value = query
|
||||
query.all.return_value = result
|
||||
return query
|
||||
|
||||
|
||||
def _expr_texts(query: MagicMock) -> list[str]:
|
||||
return [str(arg) for arg in query.filter.call_args.args]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_api_format_health_monitor_filters_inactive_sources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
endpoint_query = _build_query([("openai:compact", "ep-active", "provider-active")])
|
||||
key_query = _build_query([("provider-active", ["openai:compact"])])
|
||||
status_query = _build_query([("openai:compact", "success", 3)])
|
||||
rows_query = _build_query([])
|
||||
|
||||
db = MagicMock()
|
||||
db.query.side_effect = [endpoint_query, key_query, status_query, rows_query]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.endpoints.health.EndpointHealthService._generate_timeline_from_usage",
|
||||
lambda **_: {
|
||||
"timeline": ["healthy"] * 100,
|
||||
"time_range_start": None,
|
||||
"time_range_end": None,
|
||||
},
|
||||
)
|
||||
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
add_audit_metadata=lambda **kwargs: None,
|
||||
)
|
||||
|
||||
adapter = AdminApiFormatHealthMonitorAdapter(lookback_hours=6, per_format_limit=20)
|
||||
await adapter.handle(cast(Any, context))
|
||||
|
||||
status_filters = _expr_texts(status_query)
|
||||
rows_filters = _expr_texts(rows_query)
|
||||
|
||||
assert any("provider_endpoints.is_active" in expr for expr in status_filters)
|
||||
assert any("providers.is_active" in expr for expr in status_filters)
|
||||
assert any("provider_endpoints.is_active" in expr for expr in rows_filters)
|
||||
assert any("providers.is_active" in expr for expr in rows_filters)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_api_format_health_monitor_uses_real_counts_not_sampled_events(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
active_formats_query = _build_query([("openai:compact",)])
|
||||
endpoint_rows_query = _build_query([("openai:compact", "ep-active")])
|
||||
status_query = _build_query(
|
||||
[
|
||||
("openai:compact", "success", 7),
|
||||
("openai:compact", "failed", 3),
|
||||
("openai:compact", "skipped", 5),
|
||||
]
|
||||
)
|
||||
rows_query = _build_query(
|
||||
[
|
||||
SimpleNamespace(
|
||||
status="failed",
|
||||
status_code=500,
|
||||
latency_ms=321,
|
||||
error_type="provider_error",
|
||||
finished_at=now,
|
||||
started_at=None,
|
||||
created_at=now,
|
||||
),
|
||||
SimpleNamespace(
|
||||
status="success",
|
||||
status_code=200,
|
||||
latency_ms=123,
|
||||
error_type=None,
|
||||
finished_at=now,
|
||||
started_at=None,
|
||||
created_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
db = MagicMock()
|
||||
db.query.side_effect = [
|
||||
active_formats_query,
|
||||
endpoint_rows_query,
|
||||
status_query,
|
||||
rows_query,
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.public.catalog.EndpointHealthService._generate_timeline_from_usage",
|
||||
lambda **_: {
|
||||
"timeline": ["healthy"] * 100,
|
||||
"time_range_start": None,
|
||||
"time_range_end": now,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.core.api_format.get_local_path_for_endpoint",
|
||||
lambda api_format: f"/{api_format}",
|
||||
)
|
||||
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
)
|
||||
|
||||
adapter = PublicApiFormatHealthMonitorAdapter(lookback_hours=6, per_format_limit=20)
|
||||
result = await adapter.handle(cast(Any, context))
|
||||
|
||||
monitor = result["formats"][0]
|
||||
assert monitor["api_format"] == "openai:compact"
|
||||
assert monitor["total_attempts"] == 15
|
||||
assert monitor["success_count"] == 7
|
||||
assert monitor["failed_count"] == 3
|
||||
assert monitor["skipped_count"] == 5
|
||||
assert monitor["success_rate"] == pytest.approx(0.7)
|
||||
assert len(monitor["events"]) == 2
|
||||
|
||||
rows_filters = _expr_texts(rows_query)
|
||||
assert any("provider_endpoints.is_active" in expr for expr in rows_filters)
|
||||
assert any("providers.is_active" in expr for expr in rows_filters)
|
||||
@@ -1,785 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.api.internal.gateway import (
|
||||
CONTROL_ACTION_HEADER,
|
||||
CONTROL_ACTION_PROXY_PUBLIC,
|
||||
CONTROL_EXECUTED_HEADER,
|
||||
GatewayAuthContext,
|
||||
GatewayExecutionDecisionResponse,
|
||||
GatewayExecuteRequest,
|
||||
GatewayExecutionPlanResponse,
|
||||
GatewayResolveRequest,
|
||||
GatewayStreamReportRequest,
|
||||
GatewaySyncReportRequest,
|
||||
_dispatch_gateway_sync_telemetry,
|
||||
_build_gateway_sync_error_payload,
|
||||
_build_gateway_sync_telemetry_writer,
|
||||
_run_gateway_stream_report_background,
|
||||
_run_gateway_sync_report_background,
|
||||
_stream_executor_requires_python_rewrite,
|
||||
_build_claude_chat_sync_decision,
|
||||
_build_claude_chat_stream_decision,
|
||||
_build_claude_cli_sync_decision,
|
||||
_build_claude_cli_stream_decision,
|
||||
_build_gemini_files_download_stream_decision,
|
||||
_build_gemini_files_proxy_sync_decision,
|
||||
_build_gemini_chat_sync_decision,
|
||||
_build_gemini_chat_stream_decision,
|
||||
_build_gemini_cli_sync_decision,
|
||||
_build_gemini_cli_stream_decision,
|
||||
_build_openai_chat_sync_decision,
|
||||
_build_openai_chat_stream_decision,
|
||||
_build_openai_cli_stream_decision,
|
||||
_build_openai_video_content_stream_decision,
|
||||
_extract_gateway_sync_error_message,
|
||||
_record_gateway_direct_candidate_graph,
|
||||
_resolve_gateway_sync_error_status_code,
|
||||
_build_openai_chat_sync_plan,
|
||||
_build_openai_cli_sync_decision,
|
||||
_build_openai_cli_stream_plan,
|
||||
_build_openai_cli_sync_plan,
|
||||
_is_streaming_sync_payload,
|
||||
_resolve_auth_context,
|
||||
_resolve_gateway_sync_adapter,
|
||||
classify_gateway_route,
|
||||
router,
|
||||
)
|
||||
from src.database import get_db
|
||||
from src.models.database import Base, RequestCandidate
|
||||
from src.services.orchestration.candidate_resolver import CandidateResolver
|
||||
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
||||
from src.services.request.execution_runtime_plan import (
|
||||
ExecutionPlan,
|
||||
ExecutionPlanBody,
|
||||
PreparedExecutionPlan,
|
||||
)
|
||||
|
||||
|
||||
def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval)
|
||||
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:
|
||||
from src.config.settings import config
|
||||
from src.services.usage.telemetry_writer import QueueTelemetryWriter
|
||||
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(
|
||||
"src.services.system.config.SystemConfigService.get_request_record_level",
|
||||
lambda db: SimpleNamespace(value="headers"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.system.config.SystemConfigService.get_sensitive_headers",
|
||||
lambda db: ["authorization"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.system.config.SystemConfigService.get_config",
|
||||
lambda db, key, default=None: default,
|
||||
)
|
||||
|
||||
writer = _build_gateway_sync_telemetry_writer(
|
||||
db=object(),
|
||||
request_id="req-queue-writer-123",
|
||||
user_id="user-queue-writer-123",
|
||||
api_key_id="api-key-queue-writer-123",
|
||||
fallback_telemetry=object(),
|
||||
)
|
||||
|
||||
assert isinstance(writer, QueueTelemetryWriter)
|
||||
assert writer.request_id == "req-queue-writer-123"
|
||||
assert writer.include_headers is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_gateway_sync_telemetry_backgrounds_queue_submission(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.api.internal import gateway as gateway_module
|
||||
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
scheduled: list[asyncio.Task[Any]] = []
|
||||
|
||||
class FakeWriter:
|
||||
request_id = "req-background-dispatch-123"
|
||||
|
||||
def supports_background_submission(self) -> bool:
|
||||
return True
|
||||
|
||||
async def record_success(self, **kwargs: Any) -> None:
|
||||
started.set()
|
||||
await release.wait()
|
||||
|
||||
def fake_safe_create_task(coro: Any) -> asyncio.Task[Any]:
|
||||
task = asyncio.create_task(coro)
|
||||
scheduled.append(task)
|
||||
return task
|
||||
|
||||
monkeypatch.setattr(gateway_module, "safe_create_task", fake_safe_create_task)
|
||||
|
||||
await _dispatch_gateway_sync_telemetry(
|
||||
telemetry_writer=FakeWriter(),
|
||||
operation="record_success",
|
||||
provider="openai",
|
||||
)
|
||||
|
||||
await asyncio.wait_for(started.wait(), timeout=0.2)
|
||||
assert scheduled
|
||||
assert scheduled[0].done() is False
|
||||
|
||||
release.set()
|
||||
await scheduled[0]
|
||||
|
||||
|
||||
def test_classify_openai_chat_route_as_ai_public() -> None:
|
||||
decision = classify_gateway_route("POST", "/v1/chat/completions")
|
||||
|
||||
assert decision.route_class == "ai_public"
|
||||
assert decision.route_family == "openai"
|
||||
assert decision.route_kind == "chat"
|
||||
assert decision.auth_endpoint_signature == "openai:chat"
|
||||
assert decision.executor_candidate is True
|
||||
assert decision.action == "proxy_public"
|
||||
|
||||
|
||||
def test_classify_gemini_files_download_route_as_ai_public() -> None:
|
||||
decision = classify_gateway_route("GET", "/v1beta/files/file-123:download")
|
||||
|
||||
assert decision.route_class == "ai_public"
|
||||
assert decision.route_family == "gemini"
|
||||
assert decision.route_kind == "files"
|
||||
assert decision.auth_endpoint_signature == "gemini:chat"
|
||||
assert decision.executor_candidate is True
|
||||
|
||||
|
||||
def test_classify_gemini_files_nested_metadata_route_as_ai_public() -> None:
|
||||
decision = classify_gateway_route("GET", "/v1beta/files/files/abc-123")
|
||||
|
||||
assert decision.route_class == "ai_public"
|
||||
assert decision.route_family == "gemini"
|
||||
assert decision.route_kind == "files"
|
||||
assert decision.executor_candidate is True
|
||||
|
||||
|
||||
def test_classify_gemini_video_operation_route_as_ai_public() -> None:
|
||||
decision = classify_gateway_route("GET", "/v1beta/models/veo-3/operations/op-123")
|
||||
|
||||
assert decision.route_class == "ai_public"
|
||||
assert decision.route_family == "gemini"
|
||||
assert decision.route_kind == "video"
|
||||
assert decision.auth_endpoint_signature == "gemini:video"
|
||||
assert decision.executor_candidate is True
|
||||
|
||||
|
||||
def test_classify_non_ai_route_as_passthrough() -> None:
|
||||
decision = classify_gateway_route("GET", "/api/admin/system/info")
|
||||
|
||||
assert decision.route_class == "passthrough"
|
||||
assert decision.route_family is None
|
||||
assert decision.route_kind is None
|
||||
assert decision.executor_candidate is False
|
||||
|
||||
|
||||
def test_classify_claude_cli_route_from_bearer_header() -> None:
|
||||
decision = classify_gateway_route(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{"authorization": "Bearer sk-cli"},
|
||||
)
|
||||
|
||||
assert decision.route_class == "ai_public"
|
||||
assert decision.route_family == "claude"
|
||||
assert decision.route_kind == "cli"
|
||||
assert decision.auth_endpoint_signature == "claude:cli"
|
||||
|
||||
|
||||
def test_classify_gemini_cli_route_from_user_agent() -> None:
|
||||
decision = classify_gateway_route(
|
||||
"POST",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
{"user-agent": "GeminiCLI/1.2.3"},
|
||||
)
|
||||
|
||||
assert decision.route_class == "ai_public"
|
||||
assert decision.route_family == "gemini"
|
||||
assert decision.route_kind == "cli"
|
||||
assert decision.auth_endpoint_signature == "gemini:cli"
|
||||
|
||||
|
||||
def test_resolve_sync_adapter_for_openai_chat_route() -> None:
|
||||
decision = classify_gateway_route("POST", "/v1/chat/completions")
|
||||
|
||||
adapter, path_params = _resolve_gateway_sync_adapter(decision, "/v1/chat/completions")
|
||||
|
||||
assert adapter is not None
|
||||
assert adapter.name == "openai.chat"
|
||||
assert path_params == {}
|
||||
|
||||
|
||||
def test_resolve_sync_adapter_for_gemini_route_extracts_model_path_params() -> None:
|
||||
decision = classify_gateway_route("POST", "/v1beta/models/gemini-2.5-pro:generateContent")
|
||||
|
||||
adapter, path_params = _resolve_gateway_sync_adapter(
|
||||
decision,
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
)
|
||||
|
||||
assert adapter is not None
|
||||
assert adapter.name == "gemini.chat"
|
||||
assert path_params == {"model": "gemini-2.5-pro", "stream": False}
|
||||
|
||||
|
||||
def test_resolve_sync_adapter_rejects_non_sync_files_route() -> None:
|
||||
decision = classify_gateway_route("GET", "/v1beta/files/file-123:download")
|
||||
|
||||
adapter, path_params = _resolve_gateway_sync_adapter(
|
||||
decision, "/v1beta/files/file-123:download"
|
||||
)
|
||||
|
||||
assert adapter is None
|
||||
assert path_params == {}
|
||||
|
||||
|
||||
def test_is_streaming_sync_payload_detects_body_and_path_stream_flags() -> None:
|
||||
assert _is_streaming_sync_payload({"stream": True}, {}) is True
|
||||
assert _is_streaming_sync_payload({}, {"stream": True}) is True
|
||||
assert _is_streaming_sync_payload({"stream": False}, {"stream": False}) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_auth_context_from_openai_bearer_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
payload = GatewayResolveRequest(
|
||||
method="POST",
|
||||
path="/v1/chat/completions",
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
decision = classify_gateway_route(payload.method, payload.path, payload.headers)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway.AuthService.authenticate_api_key_threadsafe",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
user=SimpleNamespace(id="user-123"),
|
||||
api_key=SimpleNamespace(id="key-123"),
|
||||
balance_remaining=42.5,
|
||||
access_allowed=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
auth_context = await _resolve_auth_context(payload, decision)
|
||||
|
||||
assert auth_context == {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"balance_remaining": 42.5,
|
||||
"access_allowed": True,
|
||||
}
|
||||
|
||||
|
||||
def test_auth_context_route_returns_openai_bearer_auth_context(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway.AuthService.authenticate_api_key_threadsafe",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
user=SimpleNamespace(id="user-auth-route-123"),
|
||||
api_key=SimpleNamespace(id="api-key-auth-route-123"),
|
||||
balance_remaining=3.5,
|
||||
access_allowed=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/auth-context",
|
||||
json={
|
||||
"trace_id": "trace-auth-route-123",
|
||||
"query_string": "",
|
||||
"headers": {"authorization": "Bearer sk-test"},
|
||||
"auth_endpoint_signature": "openai:chat",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"auth_context": {
|
||||
"user_id": "user-auth-route-123",
|
||||
"api_key_id": "api-key-auth-route-123",
|
||||
"balance_remaining": 3.5,
|
||||
"access_allowed": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_execute_sync_route_returns_controlled_response(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)
|
||||
|
||||
class FakeAdapter:
|
||||
mode = SimpleNamespace(value="standard")
|
||||
allowed_api_formats = ["openai:chat"]
|
||||
|
||||
def authorize(self, context: object) -> None:
|
||||
self.authorized_context = context
|
||||
|
||||
async def handle(self, context: object) -> JSONResponse:
|
||||
assert getattr(context, "path_params", {}) == {}
|
||||
return JSONResponse(
|
||||
status_code=201, content={"ok": True, "request_id": context.request_id}
|
||||
)
|
||||
|
||||
fake_adapter = FakeAdapter()
|
||||
fake_pipeline = SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
||||
lambda decision, path: (fake_adapter, {}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._load_gateway_auth_models",
|
||||
lambda db, auth_context: (
|
||||
SimpleNamespace(id="user-123"),
|
||||
SimpleNamespace(id="key-123"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
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",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"balance_remaining": 12.5,
|
||||
"access_allowed": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers[CONTROL_EXECUTED_HEADER] == "true"
|
||||
assert response.json()["ok"] is True
|
||||
assert response.json()["request_id"] == "trace-sync-123"
|
||||
|
||||
|
||||
def test_execute_sync_route_resolves_auth_context_when_missing(
|
||||
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)
|
||||
|
||||
class FakeAdapter:
|
||||
mode = SimpleNamespace(value="standard")
|
||||
allowed_api_formats = ["openai:chat"]
|
||||
|
||||
def authorize(self, context: object) -> None:
|
||||
self.authorized_context = context
|
||||
|
||||
async def handle(self, context: object) -> JSONResponse:
|
||||
return JSONResponse(status_code=200, content={"request_id": context.request_id})
|
||||
|
||||
fake_pipeline = SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
||||
lambda decision, path: (FakeAdapter(), {}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._resolve_auth_context_signature",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"balance_remaining": 9.5,
|
||||
"access_allowed": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._load_gateway_auth_models",
|
||||
lambda db, auth_context: (
|
||||
SimpleNamespace(id=auth_context.user_id),
|
||||
SimpleNamespace(id=auth_context.api_key_id),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
client = _make_legacy_test_client(app)
|
||||
response = client.post(
|
||||
"/api/internal/gateway/execute-sync",
|
||||
json={
|
||||
"trace_id": "trace-sync-derive-auth",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": {
|
||||
"user-agent": "pytest",
|
||||
"authorization": "Bearer client-key",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": []},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers[CONTROL_EXECUTED_HEADER] == "true"
|
||||
assert response.json() == {"request_id": "trace-sync-derive-auth"}
|
||||
|
||||
|
||||
def test_execute_sync_route_falls_back_for_stream_payload() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
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"},
|
||||
"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"
|
||||
}
|
||||
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)
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
monkeypatch.setattr("src.api.internal.gateway.ensure_loopback", lambda request: None)
|
||||
|
||||
class FakeAdapter:
|
||||
mode = SimpleNamespace(value="standard")
|
||||
allowed_api_formats = ["openai:chat"]
|
||||
|
||||
def authorize(self, context: object) -> None:
|
||||
self.authorized_context = context
|
||||
|
||||
async def handle(self, context: object) -> StreamingResponse:
|
||||
async def _iter() -> object:
|
||||
yield b"data: one\n\n"
|
||||
yield b"data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(_iter(), media_type="text/event-stream")
|
||||
|
||||
fake_pipeline = SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
||||
lambda decision, path: (FakeAdapter(), {}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.internal.gateway._load_gateway_auth_models",
|
||||
lambda db, auth_context: (
|
||||
SimpleNamespace(id="user-123"),
|
||||
SimpleNamespace(id="key-123"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("src.api.internal.gateway.get_pipeline", lambda: fake_pipeline)
|
||||
|
||||
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",
|
||||
"x-aether-control-execute-fallback": "true",
|
||||
},
|
||||
"body_json": {"model": "gpt-5", "messages": [], "stream": True},
|
||||
"auth_context": {
|
||||
"user_id": "user-123",
|
||||
"api_key_id": "key-123",
|
||||
"balance_remaining": 12.5,
|
||||
"access_allowed": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers[CONTROL_EXECUTED_HEADER] == "true"
|
||||
assert response.text == "data: one\n\ndata: [DONE]\n\n"
|
||||
@@ -1,120 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.monitoring.user import UserRateLimitStatusAdapter
|
||||
|
||||
|
||||
def _build_query_with_keys(keys: list[object]) -> MagicMock:
|
||||
query = MagicMock()
|
||||
query.filter.return_value.order_by.return_value.all.return_value = keys
|
||||
return query
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_status_adapter_reports_user_and_key_layers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
now = datetime(2026, 3, 13, 12, 0, tzinfo=timezone.utc)
|
||||
db = MagicMock()
|
||||
user = SimpleNamespace(id="user-1", rate_limit=None)
|
||||
key = SimpleNamespace(id="key-1", name="Primary", is_standalone=False, rate_limit=10)
|
||||
standalone = SimpleNamespace(
|
||||
id="skey-1", name="Standalone", is_standalone=True, rate_limit=None
|
||||
)
|
||||
|
||||
db.query.return_value = _build_query_with_keys([key, standalone])
|
||||
|
||||
limiter = MagicMock()
|
||||
limiter.bucket_seconds = 60
|
||||
limiter.get_reset_at.return_value = now
|
||||
limiter.get_user_rpm_key.return_value = "rpm:user:user-1:bucket"
|
||||
limiter.get_standalone_rpm_key.return_value = "rpm:ukey:skey-1:bucket"
|
||||
limiter.get_key_rpm_key.side_effect = lambda key_id: f"rpm:key:{key_id}:bucket"
|
||||
|
||||
async def _get_scope_count(scope_key: str) -> int:
|
||||
counts = {
|
||||
"rpm:user:user-1:bucket": 55,
|
||||
"rpm:key:key-1:bucket": 7,
|
||||
"rpm:ukey:skey-1:bucket": 12,
|
||||
}
|
||||
return counts[scope_key]
|
||||
|
||||
limiter.get_scope_count = AsyncMock(side_effect=_get_scope_count)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.monitoring.user.get_user_rpm_limiter",
|
||||
AsyncMock(return_value=limiter),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.monitoring.user.SystemConfigService.get_config",
|
||||
lambda *_a, **_k: 60,
|
||||
)
|
||||
|
||||
context = SimpleNamespace(db=db, user=user)
|
||||
result = await UserRateLimitStatusAdapter().handle(context)
|
||||
|
||||
assert result["user_id"] == "user-1"
|
||||
assert result["api_keys"][0] == {
|
||||
"api_key_name": "Primary",
|
||||
"limit": 10,
|
||||
"remaining": 3,
|
||||
"scope": "key",
|
||||
"reset_time": now.isoformat(),
|
||||
"window": "60s",
|
||||
"user_limit": 60,
|
||||
"user_remaining": 5,
|
||||
"key_limit": 10,
|
||||
"key_remaining": 3,
|
||||
}
|
||||
assert result["api_keys"][1] == {
|
||||
"api_key_name": "Standalone",
|
||||
"limit": 60,
|
||||
"remaining": 48,
|
||||
"scope": "user",
|
||||
"reset_time": now.isoformat(),
|
||||
"window": "60s",
|
||||
"user_limit": 60,
|
||||
"user_remaining": 48,
|
||||
"key_limit": None,
|
||||
"key_remaining": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_status_adapter_reports_unlimited_key_without_counts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
user = SimpleNamespace(id="user-1", rate_limit=0)
|
||||
key = SimpleNamespace(id="key-1", name="Unlimited", is_standalone=False, rate_limit=0)
|
||||
|
||||
db.query.return_value = _build_query_with_keys([key])
|
||||
|
||||
limiter = MagicMock()
|
||||
limiter.bucket_seconds = 60
|
||||
limiter.get_reset_at.return_value = datetime.now(timezone.utc)
|
||||
limiter.get_user_rpm_key.return_value = "rpm:user:user-1:bucket"
|
||||
limiter.get_key_rpm_key.return_value = "rpm:key:key-1:bucket"
|
||||
limiter.get_scope_count = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.monitoring.user.get_user_rpm_limiter",
|
||||
AsyncMock(return_value=limiter),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.monitoring.user.SystemConfigService.get_config",
|
||||
lambda *_a, **_k: 60,
|
||||
)
|
||||
|
||||
context = SimpleNamespace(db=db, user=user)
|
||||
result = await UserRateLimitStatusAdapter().handle(context)
|
||||
|
||||
assert result["api_keys"][0]["limit"] is None
|
||||
assert result["api_keys"][0]["remaining"] is None
|
||||
assert result["api_keys"][0]["scope"] is None
|
||||
limiter.get_scope_count.assert_not_awaited()
|
||||
@@ -1,232 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.admin.payments.routes import AdminPaymentOrderCreditAdapter
|
||||
from src.api.payment.routes import router as payment_router
|
||||
from src.config import config
|
||||
from src.database import get_db
|
||||
from src.models.database import PaymentOrder
|
||||
from src.services.payment.gateway import get_payment_gateway
|
||||
|
||||
CALLBACK_SECRET = "test-callback-secret"
|
||||
|
||||
|
||||
def _build_payment_app(db: MagicMock) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(payment_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _sign_payload(payload: dict[str, object]) -> str:
|
||||
gateway = get_payment_gateway("alipay")
|
||||
signature = gateway.build_callback_signature(payload=payload, callback_secret=CALLBACK_SECRET)
|
||||
assert signature is not None
|
||||
return signature
|
||||
|
||||
|
||||
def test_specific_wechat_callback_route_is_not_shadowed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
|
||||
captured_kwargs.update(kwargs)
|
||||
return {
|
||||
"ok": True,
|
||||
"credited": True,
|
||||
"duplicate": False,
|
||||
"payment_method_seen": kwargs["payment_method"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
|
||||
|
||||
callback_payload = {"callback_key": "cb-wechat", "amount_usd": 1.0}
|
||||
response = client.post(
|
||||
"/api/payment/callback/wechat",
|
||||
json=callback_payload,
|
||||
headers={
|
||||
"x-payment-callback-token": CALLBACK_SECRET,
|
||||
"x-payment-callback-signature": _sign_payload(callback_payload),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["payment_method"] == "wechat"
|
||||
assert payload["payment_method_seen"] == "wechat"
|
||||
assert payload["request_path"] == "/api/payment/callback/wechat"
|
||||
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
|
||||
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
|
||||
assert "signature_valid" not in captured_kwargs
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_generic_payment_callback_route_still_handles_custom_methods(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
|
||||
captured_kwargs.update(kwargs)
|
||||
return {
|
||||
"ok": True,
|
||||
"credited": False,
|
||||
"duplicate": False,
|
||||
"payment_method_seen": kwargs["payment_method"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
|
||||
|
||||
callback_payload = {"callback_key": "cb-generic", "amount_usd": 1.0}
|
||||
response = client.post(
|
||||
"/api/payment/callback/mockpay",
|
||||
json=callback_payload,
|
||||
headers={
|
||||
"x-payment-callback-token": CALLBACK_SECRET,
|
||||
"x-payment-callback-signature": _sign_payload(callback_payload),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["payment_method"] == "mockpay"
|
||||
assert payload["payment_method_seen"] == "mockpay"
|
||||
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
|
||||
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
|
||||
assert "signature_valid" not in captured_kwargs
|
||||
|
||||
|
||||
def test_callback_requires_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
response = client.post(
|
||||
"/api/payment/callback/alipay",
|
||||
json={"callback_key": "cb-missing-token", "amount_usd": 1.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_callback_rejects_invalid_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
callback_payload = {"callback_key": "cb-invalid-token", "amount_usd": 1.0}
|
||||
|
||||
response = client.post(
|
||||
"/api/payment/callback/alipay",
|
||||
json=callback_payload,
|
||||
headers={
|
||||
"x-payment-callback-token": "wrong-token",
|
||||
"x-payment-callback-signature": _sign_payload(callback_payload),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_callback_rejects_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
response = client.post(
|
||||
"/api/payment/callback/alipay",
|
||||
json={"callback_key": "cb-missing-signature", "amount_usd": 1.0},
|
||||
headers={"x-payment-callback-token": CALLBACK_SECRET},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_callback_disabled_when_secret_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", "")
|
||||
|
||||
response = client.post(
|
||||
"/api/payment/callback/alipay",
|
||||
json={"callback_key": "cb-secret-missing", "amount_usd": 1.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_payment_credit_adapter_marks_manual_credit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
order = PaymentOrder(
|
||||
id="po-credit",
|
||||
order_no="order-credit",
|
||||
wallet_id="w1",
|
||||
user_id="u1",
|
||||
amount_usd=Decimal("8.00000000"),
|
||||
refunded_amount_usd=Decimal("0"),
|
||||
refundable_amount_usd=Decimal("8.00000000"),
|
||||
payment_method="alipay",
|
||||
status="pending",
|
||||
gateway_response={"existing": True},
|
||||
)
|
||||
adapter = AdminPaymentOrderCreditAdapter(order_id=order.id)
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
raw_body=b"{}",
|
||||
ensure_json_body=lambda: {
|
||||
"pay_amount": 58.0,
|
||||
"pay_currency": "CNY",
|
||||
"exchange_rate": 7.25,
|
||||
},
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.payments.routes.PaymentService.get_order",
|
||||
lambda _db, order_id: order if order_id == "po-credit" else None,
|
||||
)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_credit_order(_db: MagicMock, **kwargs: object) -> tuple[PaymentOrder, bool]:
|
||||
captured.update(kwargs)
|
||||
return order, True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.payments.routes.PaymentService.credit_order",
|
||||
_fake_credit_order,
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["credited"] is True
|
||||
assert result["order"]["id"] == "po-credit"
|
||||
gateway_response = captured["gateway_response"]
|
||||
assert isinstance(gateway_response, dict)
|
||||
assert gateway_response["existing"] is True
|
||||
assert gateway_response["manual_credit"] is True
|
||||
assert gateway_response["credited_by"] == "admin-1"
|
||||
db.commit.assert_called_once()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,230 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import count
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.api.admin import provider_oauth as oauthmod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standard_batch_import_releases_db_connection_before_network_await(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
release_calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_require_oauth_template",
|
||||
lambda _provider_type: SimpleNamespace(
|
||||
oauth=SimpleNamespace(
|
||||
token_url="https://example.com/oauth/token",
|
||||
client_id="client-id",
|
||||
client_secret=None,
|
||||
scopes=[],
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_parse_standard_oauth_import_entries",
|
||||
lambda _raw: [{"refresh_token": "r" * 120}],
|
||||
)
|
||||
monkeypatch.setattr(oauthmod, "_get_provider_api_formats", lambda _provider: [])
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_release_batch_import_db_connection_before_await",
|
||||
lambda _db: release_calls.append("release"),
|
||||
)
|
||||
|
||||
async def _fake_post_oauth_token(**_kwargs: object) -> httpx.Response:
|
||||
raise RuntimeError("upstream unavailable")
|
||||
|
||||
monkeypatch.setattr(oauthmod, "post_oauth_token", _fake_post_oauth_token)
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
result = await oauthmod._batch_import_standard_oauth_internal(
|
||||
provider_id="provider-1",
|
||||
provider_type="codex",
|
||||
provider=SimpleNamespace(endpoints=[]), # type: ignore[arg-type]
|
||||
raw_credentials="ignored",
|
||||
db=db,
|
||||
concurrency=1,
|
||||
)
|
||||
|
||||
assert result.total == 1
|
||||
assert result.success == 0
|
||||
assert result.failed == 1
|
||||
assert release_calls
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standard_batch_import_commits_successes_in_chunks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key_ids = count(1)
|
||||
created_auth_configs: list[dict[str, object]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_PROVIDER_OAUTH_BATCH_IMPORT_COMMIT_BATCH_SIZE",
|
||||
2,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_require_oauth_template",
|
||||
lambda _provider_type: SimpleNamespace(
|
||||
oauth=SimpleNamespace(
|
||||
token_url="https://example.com/oauth/token",
|
||||
client_id="client-id",
|
||||
client_secret=None,
|
||||
scopes=[],
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_parse_standard_oauth_import_entries",
|
||||
lambda _raw: [{"refresh_token": f"r-{idx}" + ("x" * 120)} for idx in range(3)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
oauthmod, "_get_provider_api_formats", lambda _provider: ["responses"]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_release_batch_import_db_connection_before_await",
|
||||
lambda _db: None,
|
||||
)
|
||||
|
||||
async def _fake_post_oauth_token(**_kwargs: object) -> httpx.Response:
|
||||
idx = next(key_ids)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"access_token": f"access-{idx}",
|
||||
"refresh_token": f"refresh-{idx}",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
request=httpx.Request("POST", "https://example.com/oauth/token"),
|
||||
)
|
||||
|
||||
async def _fake_enrich_auth_config(**kwargs: object) -> dict[str, object]:
|
||||
auth_config = dict(kwargs["auth_config"]) # type: ignore[call-overload]
|
||||
auth_config["email"] = f"user-{next(key_ids)}@example.com"
|
||||
auth_config["account_name"] = "Workspace Alpha"
|
||||
return auth_config
|
||||
|
||||
created_ids = count(1)
|
||||
monkeypatch.setattr(oauthmod, "post_oauth_token", _fake_post_oauth_token)
|
||||
monkeypatch.setattr(oauthmod, "enrich_auth_config", _fake_enrich_auth_config)
|
||||
monkeypatch.setattr(
|
||||
oauthmod, "_check_duplicate_oauth_account", lambda *_args, **_kwargs: None
|
||||
)
|
||||
|
||||
def _fake_create_oauth_key(*_args: object, **kwargs: object) -> SimpleNamespace:
|
||||
created_auth_configs.append(dict(kwargs["auth_config"]))
|
||||
return SimpleNamespace(id=f"key-{next(created_ids)}")
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_create_oauth_key",
|
||||
_fake_create_oauth_key,
|
||||
)
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
result = await oauthmod._batch_import_standard_oauth_internal(
|
||||
provider_id="provider-1",
|
||||
provider_type="example",
|
||||
provider=SimpleNamespace(endpoints=[]), # type: ignore[arg-type]
|
||||
raw_credentials="ignored",
|
||||
db=db,
|
||||
concurrency=1,
|
||||
)
|
||||
|
||||
assert result.total == 3
|
||||
assert result.success == 3
|
||||
assert result.failed == 0
|
||||
assert db.commit.call_count == 2
|
||||
assert created_auth_configs[0]["account_name"] == "Workspace Alpha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kiro_batch_import_releases_db_connection_before_refresh(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
release_calls: list[str] = []
|
||||
|
||||
class FakeKiroAuthConfig:
|
||||
def __init__(self, data: dict[str, object]) -> None:
|
||||
self._data = dict(data)
|
||||
self.provider_type = str(data.get("provider_type") or "")
|
||||
self.email = (
|
||||
data.get("email") if isinstance(data.get("email"), str) else None
|
||||
)
|
||||
self.auth_method = (
|
||||
data.get("auth_method")
|
||||
if isinstance(data.get("auth_method"), str)
|
||||
else "social"
|
||||
)
|
||||
self.refresh_token = str(data.get("refresh_token") or "")
|
||||
|
||||
@staticmethod
|
||||
def validate_required_fields(
|
||||
_cred: dict[str, object],
|
||||
) -> tuple[bool, str | None]:
|
||||
return True, None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, object]) -> "FakeKiroAuthConfig":
|
||||
return cls(data)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return dict(self._data)
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_parse_kiro_import_input",
|
||||
lambda _raw: [{"refresh_token": "r" * 120, "auth_method": "social"}],
|
||||
)
|
||||
monkeypatch.setattr(oauthmod, "_get_provider_api_formats", lambda _provider: [])
|
||||
monkeypatch.setattr(
|
||||
oauthmod,
|
||||
"_release_batch_import_db_connection_before_await",
|
||||
lambda _db: release_calls.append("release"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.adapters.kiro.models.credentials.KiroAuthConfig",
|
||||
FakeKiroAuthConfig,
|
||||
)
|
||||
|
||||
async def _fake_refresh_access_token(
|
||||
*_args: object, **_kwargs: object
|
||||
) -> tuple[str, object]:
|
||||
raise RuntimeError("refresh token reused")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.adapters.kiro.token_manager.refresh_access_token",
|
||||
_fake_refresh_access_token,
|
||||
)
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
result = await oauthmod._batch_import_kiro_internal(
|
||||
provider_id="provider-1",
|
||||
provider=SimpleNamespace(endpoints=[]), # type: ignore[arg-type]
|
||||
raw_credentials="ignored",
|
||||
db=db,
|
||||
concurrency=1,
|
||||
)
|
||||
|
||||
assert result.total == 1
|
||||
assert result.success == 0
|
||||
assert result.failed == 1
|
||||
assert release_calls
|
||||
db.commit.assert_not_called()
|
||||
@@ -1,38 +0,0 @@
|
||||
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
|
||||
@@ -1,921 +0,0 @@
|
||||
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/node-1", "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/tunnel/heartbeat", "POST") is False
|
||||
assert _app_matches_http_route("/api/internal/tunnel/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
|
||||
@@ -1,110 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.user_me.routes import UpdateMyApiKeyAdapter
|
||||
from src.api.user_me.routes import router as me_router
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_me_app(db: MagicMock, monkeypatch: Any) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(me_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*, adapter: object, http_request: object, db: MagicMock, mode: object
|
||||
) -> object:
|
||||
_ = http_request, mode
|
||||
try:
|
||||
payload = await http_request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(id="user-1", email="u@example.com"),
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
ensure_json_body=lambda: payload,
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
return await adapter.handle(context)
|
||||
|
||||
monkeypatch.setattr("src.api.user_me.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
async def _fake_update_my_api_key_sync(
|
||||
user_id: str,
|
||||
key_id: str,
|
||||
request: object,
|
||||
captured: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
captured["user_id"] = user_id
|
||||
captured["key_id"] = key_id
|
||||
captured["name"] = getattr(request, "name", None)
|
||||
captured["rate_limit"] = getattr(request, "rate_limit", None)
|
||||
return {"id": key_id, "name": captured["name"], "rate_limit": captured["rate_limit"]}
|
||||
|
||||
|
||||
def test_update_my_api_key_route_path_smoke(monkeypatch: Any) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _sync(user_id: str, key_id: str, request: object) -> dict[str, object]:
|
||||
captured["user_id"] = user_id
|
||||
captured["key_id"] = key_id
|
||||
captured["name"] = getattr(request, "name", None)
|
||||
captured["rate_limit"] = getattr(request, "rate_limit", None)
|
||||
return {"id": key_id, "name": captured["name"], "rate_limit": captured["rate_limit"]}
|
||||
|
||||
monkeypatch.setattr("src.api.user_me.routes._update_my_api_key_sync", _sync)
|
||||
client = _build_me_app(MagicMock(), monkeypatch)
|
||||
|
||||
response = client.put("/api/users/me/api-keys/key-1", json={"name": "Edited", "rate_limit": 6})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rate_limit"] == 6
|
||||
assert captured == {
|
||||
"user_id": "user-1",
|
||||
"key_id": "key-1",
|
||||
"name": "Edited",
|
||||
"rate_limit": 6,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_my_api_key_adapter_passes_rate_limit_and_name(monkeypatch: Any) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _sync(user_id: str, key_id: str, request: object) -> dict[str, object]:
|
||||
captured["user_id"] = user_id
|
||||
captured["key_id"] = key_id
|
||||
captured["name"] = getattr(request, "name", None)
|
||||
captured["rate_limit"] = getattr(request, "rate_limit", None)
|
||||
return {"id": key_id, "name": captured["name"], "rate_limit": captured["rate_limit"]}
|
||||
|
||||
monkeypatch.setattr("src.api.user_me.routes._update_my_api_key_sync", _sync)
|
||||
|
||||
adapter = UpdateMyApiKeyAdapter(key_id="key-2")
|
||||
context = SimpleNamespace(
|
||||
db=MagicMock(),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
ensure_json_body=lambda: {"name": "Edited Again", "rate_limit": 15},
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["id"] == "key-2"
|
||||
assert captured == {
|
||||
"user_id": "user-1",
|
||||
"key_id": "key-2",
|
||||
"name": "Edited Again",
|
||||
"rate_limit": 15,
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.user_me.routes import GetUsageAdapter
|
||||
from src.core.enums import UserRole
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_usage_adapter_uses_coarse_summary_grouping(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
query = MagicMock()
|
||||
count_query = MagicMock()
|
||||
count_query.scalar.return_value = 0
|
||||
query.outerjoin.return_value = query
|
||||
query.filter.return_value = query
|
||||
query.group_by.return_value = query
|
||||
query.with_entities.return_value = count_query
|
||||
query.options.return_value = query
|
||||
query.order_by.return_value = query
|
||||
query.offset.return_value = query
|
||||
query.limit.return_value = query
|
||||
query.all.return_value = []
|
||||
db.query.return_value = query
|
||||
|
||||
summary_getter = MagicMock(
|
||||
return_value=[
|
||||
{
|
||||
"provider": "provider-a",
|
||||
"model": "gpt-4o",
|
||||
"requests": 2,
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
"cache_read_tokens": 5,
|
||||
"total_input_context": 15,
|
||||
"total_cost_usd": 1.5,
|
||||
"actual_total_cost_usd": 1.2,
|
||||
"success_count": 2,
|
||||
"success_response_time_sum_ms": 1000.0,
|
||||
"success_response_time_count": 2,
|
||||
},
|
||||
{
|
||||
"provider": "pending",
|
||||
"model": "gpt-4o",
|
||||
"requests": 99,
|
||||
"input_tokens": 999,
|
||||
"output_tokens": 999,
|
||||
"total_tokens": 1998,
|
||||
"cache_read_tokens": 50,
|
||||
"total_input_context": 1049,
|
||||
"total_cost_usd": 9.9,
|
||||
"actual_total_cost_usd": 9.9,
|
||||
"success_count": 0,
|
||||
"success_response_time_sum_ms": 0.0,
|
||||
"success_response_time_count": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("src.api.user_me.routes.UsageService.get_usage_summary", summary_getter)
|
||||
monkeypatch.setattr("src.api.user_me.routes.WalletService.get_wallet", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.user_me.routes.WalletService.serialize_wallet_summary",
|
||||
lambda _wallet: {"limit_mode": "finite"},
|
||||
)
|
||||
|
||||
adapter = GetUsageAdapter(time_range=None, limit=20, offset=0)
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(id="user-1", role=UserRole.USER),
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["total_requests"] == 2
|
||||
assert result["total_tokens"] == 15
|
||||
assert result["summary_by_model"] == [
|
||||
{
|
||||
"model": "gpt-4o",
|
||||
"requests": 2,
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
"cache_read_tokens": 5,
|
||||
"cache_hit_rate": 33.33,
|
||||
"total_cost_usd": 1.5,
|
||||
}
|
||||
]
|
||||
assert result["summary_by_api_format"] == []
|
||||
assert "total_actual_cost" not in result
|
||||
assert result["summary_by_provider"] == [
|
||||
{
|
||||
"provider": "provider-a",
|
||||
"requests": 2,
|
||||
"total_tokens": 15,
|
||||
"cache_read_tokens": 5,
|
||||
"cache_hit_rate": 33.33,
|
||||
"total_cost_usd": 1.5,
|
||||
"success_rate": 100.0,
|
||||
"avg_response_time_ms": 500.0,
|
||||
}
|
||||
]
|
||||
assert summary_getter.call_args.kwargs["group_by"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_usage_adapter_provider_success_rate_uses_success_count(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
query = MagicMock()
|
||||
count_query = MagicMock()
|
||||
count_query.scalar.return_value = 0
|
||||
query.outerjoin.return_value = query
|
||||
query.filter.return_value = query
|
||||
query.group_by.return_value = query
|
||||
query.with_entities.return_value = count_query
|
||||
query.options.return_value = query
|
||||
query.order_by.return_value = query
|
||||
query.offset.return_value = query
|
||||
query.limit.return_value = query
|
||||
query.all.return_value = []
|
||||
db.query.return_value = query
|
||||
|
||||
summary_getter = MagicMock(
|
||||
return_value=[
|
||||
{
|
||||
"provider": "provider-a",
|
||||
"model": "gpt-4o",
|
||||
"requests": 3,
|
||||
"input_tokens": 30,
|
||||
"output_tokens": 15,
|
||||
"total_tokens": 45,
|
||||
"cache_read_tokens": 15,
|
||||
"total_input_context": 45,
|
||||
"total_cost_usd": 4.5,
|
||||
"actual_total_cost_usd": 4.5,
|
||||
"success_count": 2,
|
||||
"success_response_time_sum_ms": 600.0,
|
||||
"success_response_time_count": 2,
|
||||
},
|
||||
{
|
||||
"provider": "provider-a",
|
||||
"model": "gpt-4.1",
|
||||
"requests": 1,
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
"cache_read_tokens": 5,
|
||||
"total_input_context": 15,
|
||||
"total_cost_usd": 1.5,
|
||||
"actual_total_cost_usd": 1.5,
|
||||
"success_count": 0,
|
||||
"success_response_time_sum_ms": 0.0,
|
||||
"success_response_time_count": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("src.api.user_me.routes.UsageService.get_usage_summary", summary_getter)
|
||||
monkeypatch.setattr("src.api.user_me.routes.WalletService.get_wallet", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(
|
||||
"src.api.user_me.routes.WalletService.serialize_wallet_summary",
|
||||
lambda _wallet: {"limit_mode": "finite"},
|
||||
)
|
||||
|
||||
adapter = GetUsageAdapter(time_range=None, limit=20, offset=0)
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(id="user-1", role=UserRole.USER),
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["summary_by_provider"] == [
|
||||
{
|
||||
"provider": "provider-a",
|
||||
"requests": 4,
|
||||
"total_tokens": 60,
|
||||
"cache_read_tokens": 20,
|
||||
"cache_hit_rate": 33.33,
|
||||
"total_cost_usd": 6.0,
|
||||
"success_rate": 50.0,
|
||||
"avg_response_time_ms": 300.0,
|
||||
}
|
||||
]
|
||||
@@ -1,202 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.wallet.routes import router as wallet_router
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_wallet_app(
|
||||
db: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
payload: dict[str, object],
|
||||
user_id: str = "user-1",
|
||||
) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(wallet_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: MagicMock, mode: object) -> object:
|
||||
_ = http_request, mode
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(id=user_id),
|
||||
ensure_json_body=lambda: payload,
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
return await adapter.handle(context)
|
||||
|
||||
monkeypatch.setattr("src.api.wallet.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_create_refund_route_maps_uncredited_order_to_400(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
payload = {"amount_usd": 2.0, "payment_order_id": "order-1"}
|
||||
client = _build_wallet_app(db, monkeypatch, payload=payload)
|
||||
wallet = SimpleNamespace(id="wallet-1")
|
||||
payment_order = SimpleNamespace(id="order-1", wallet_id="wallet-1", payment_method="alipay")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.wallet.routes.WalletService.get_or_create_wallet",
|
||||
lambda _db, user: wallet,
|
||||
)
|
||||
db.query.return_value.filter.return_value.first.return_value = payment_order
|
||||
|
||||
def _raise(*args: object, **kwargs: object) -> object:
|
||||
raise ValueError("payment order is not refundable")
|
||||
|
||||
monkeypatch.setattr("src.api.wallet.routes.WalletService.create_refund_request", _raise)
|
||||
|
||||
response = client.post("/api/wallet/refunds", json=payload)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "not refundable" in response.json()["detail"]
|
||||
db.rollback.assert_called_once()
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_create_refund_route_maps_reserved_wallet_amount_to_400(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
payload = {"amount_usd": 2.0}
|
||||
client = _build_wallet_app(db, monkeypatch, payload=payload)
|
||||
wallet = SimpleNamespace(id="wallet-1")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.wallet.routes.WalletService.get_or_create_wallet",
|
||||
lambda _db, user: wallet,
|
||||
)
|
||||
|
||||
def _raise(*args: object, **kwargs: object) -> object:
|
||||
raise ValueError("refund amount exceeds available refundable recharge balance")
|
||||
|
||||
monkeypatch.setattr("src.api.wallet.routes.WalletService.create_refund_request", _raise)
|
||||
|
||||
response = client.post("/api/wallet/refunds", json=payload)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "available refundable recharge balance" in response.json()["detail"]
|
||||
db.rollback.assert_called_once()
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_create_refund_route_passes_default_order_refund_mode_and_commits(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
payload = {"amount_usd": 2.0, "payment_order_id": "order-1", "reason": "test"}
|
||||
client = _build_wallet_app(db, monkeypatch, payload=payload)
|
||||
wallet = SimpleNamespace(id="wallet-1")
|
||||
payment_order = SimpleNamespace(id="order-1", wallet_id="wallet-1", payment_method="alipay")
|
||||
refund = SimpleNamespace(
|
||||
id="refund-1",
|
||||
refund_no="rf-1",
|
||||
payment_order_id="order-1",
|
||||
source_type="payment_order",
|
||||
source_id="order-1",
|
||||
refund_mode="original_channel",
|
||||
amount_usd=Decimal("2.00000000"),
|
||||
status="pending_approval",
|
||||
reason="test",
|
||||
failure_reason=None,
|
||||
gateway_refund_id=None,
|
||||
payout_method=None,
|
||||
payout_reference=None,
|
||||
payout_proof=None,
|
||||
created_at="2026-03-07T00:00:00Z",
|
||||
updated_at="2026-03-07T00:00:00Z",
|
||||
processed_at=None,
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.wallet.routes.WalletService.get_or_create_wallet",
|
||||
lambda _db, user: wallet,
|
||||
)
|
||||
db.query.return_value.filter.return_value.first.return_value = payment_order
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _create_refund_request(_db: MagicMock, **kwargs: object) -> object:
|
||||
captured.update(kwargs)
|
||||
return refund
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.wallet.routes.WalletService.create_refund_request",
|
||||
_create_refund_request,
|
||||
)
|
||||
|
||||
response = client.post("/api/wallet/refunds", json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["id"] == "refund-1"
|
||||
assert body["status"] == "pending_approval"
|
||||
assert captured["refund_mode"] == "original_channel"
|
||||
assert captured["source_type"] == "payment_order"
|
||||
assert captured["source_id"] == "order-1"
|
||||
assert captured["payment_order"] is payment_order
|
||||
db.commit.assert_called_once()
|
||||
db.refresh.assert_called_once_with(refund)
|
||||
db.rollback.assert_not_called()
|
||||
|
||||
|
||||
def test_create_refund_route_uses_offline_payout_for_manual_recharge(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
payload = {"amount_usd": 2.0, "payment_order_id": "order-2"}
|
||||
client = _build_wallet_app(db, monkeypatch, payload=payload)
|
||||
wallet = SimpleNamespace(id="wallet-1")
|
||||
payment_order = SimpleNamespace(id="order-2", wallet_id="wallet-1", payment_method="admin_manual")
|
||||
refund = SimpleNamespace(
|
||||
id="refund-2",
|
||||
refund_no="rf-2",
|
||||
payment_order_id="order-2",
|
||||
source_type="payment_order",
|
||||
source_id="order-2",
|
||||
refund_mode="offline_payout",
|
||||
amount_usd=Decimal("2.00000000"),
|
||||
status="pending_approval",
|
||||
reason=None,
|
||||
failure_reason=None,
|
||||
gateway_refund_id=None,
|
||||
payout_method=None,
|
||||
payout_reference=None,
|
||||
payout_proof=None,
|
||||
created_at="2026-03-07T00:00:00Z",
|
||||
updated_at="2026-03-07T00:00:00Z",
|
||||
processed_at=None,
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.wallet.routes.WalletService.get_or_create_wallet",
|
||||
lambda _db, user: wallet,
|
||||
)
|
||||
db.query.return_value.filter.return_value.first.return_value = payment_order
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _create_refund_request(_db: MagicMock, **kwargs: object) -> object:
|
||||
captured.update(kwargs)
|
||||
return refund
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.wallet.routes.WalletService.create_refund_request",
|
||||
_create_refund_request,
|
||||
)
|
||||
|
||||
response = client.post("/api/wallet/refunds", json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["refund_mode"] == "offline_payout"
|
||||
@@ -1,91 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from src.clients import redis_client as redis_client_module
|
||||
from src.clients.redis_client import RedisClientManager, RedisState
|
||||
|
||||
|
||||
def _seed_open_circuit(manager: RedisClientManager) -> None:
|
||||
manager._circuit_open_until = 9999999999.0
|
||||
manager._consecutive_failures = 5
|
||||
manager._last_error = "boom"
|
||||
|
||||
|
||||
def test_reset_redis_circuit_breaker_resets_both_clients() -> None:
|
||||
old_global = redis_client_module._redis_manager
|
||||
old_usage = redis_client_module._usage_queue_redis_manager
|
||||
try:
|
||||
global_manager = RedisClientManager(client_name="global")
|
||||
usage_manager = RedisClientManager(client_name="usage")
|
||||
_seed_open_circuit(global_manager)
|
||||
_seed_open_circuit(usage_manager)
|
||||
|
||||
redis_client_module._redis_manager = global_manager
|
||||
redis_client_module._usage_queue_redis_manager = usage_manager
|
||||
|
||||
assert redis_client_module.reset_redis_circuit_breaker() is True
|
||||
assert global_manager.get_state() == RedisState.NOT_INITIALIZED
|
||||
assert usage_manager.get_state() == RedisState.NOT_INITIALIZED
|
||||
finally:
|
||||
redis_client_module._redis_manager = old_global
|
||||
redis_client_module._usage_queue_redis_manager = old_usage
|
||||
|
||||
|
||||
def test_reset_redis_circuit_breaker_returns_false_when_uninitialized() -> None:
|
||||
old_global = redis_client_module._redis_manager
|
||||
old_usage = redis_client_module._usage_queue_redis_manager
|
||||
try:
|
||||
redis_client_module._redis_manager = None
|
||||
redis_client_module._usage_queue_redis_manager = None
|
||||
|
||||
assert redis_client_module.reset_redis_circuit_breaker() is False
|
||||
finally:
|
||||
redis_client_module._redis_manager = old_global
|
||||
redis_client_module._usage_queue_redis_manager = old_usage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_redis_client_isolated_per_event_loop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
old_global = redis_client_module._redis_manager
|
||||
try:
|
||||
redis_client_module._redis_manager = RedisClientManager(client_name="global")
|
||||
|
||||
class DummyRedis:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.closed = False
|
||||
|
||||
async def ping(self) -> bool:
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
created_clients: list[DummyRedis] = []
|
||||
|
||||
async def fake_from_url(*args: object, **kwargs: object) -> DummyRedis:
|
||||
client = DummyRedis(f"client-{len(created_clients)}")
|
||||
created_clients.append(client)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(redis_client_module.aioredis, "from_url", fake_from_url)
|
||||
|
||||
main_client = await redis_client_module.get_redis_client()
|
||||
|
||||
def _get_client_from_thread() -> object:
|
||||
return asyncio.run(redis_client_module.get_redis_client())
|
||||
|
||||
thread_client = await asyncio.to_thread(_get_client_from_thread)
|
||||
|
||||
assert main_client is created_clients[0]
|
||||
assert thread_client is created_clients[1]
|
||||
assert thread_client is not main_client
|
||||
assert redis_client_module.get_redis_client_sync() is main_client
|
||||
|
||||
await redis_client_module.close_redis_client()
|
||||
|
||||
assert created_clients[0].closed is True
|
||||
assert created_clients[1].closed is True
|
||||
finally:
|
||||
redis_client_module._redis_manager = old_global
|
||||
@@ -1,7 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# 测试运行在容器里时默认会被识别为 production,这里提供稳定的测试密钥。
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-key-for-pytest-1234567890")
|
||||
os.environ.setdefault("ENCRYPTION_KEY", "test-encryption-key-for-pytest-1234567890")
|
||||
@@ -1,40 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
def _build_contract_app() -> FastAPI:
|
||||
from src.api.public.claude import router as claude_router
|
||||
from src.api.public.gemini import router as gemini_router
|
||||
from src.api.public.openai import router as openai_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(claude_router)
|
||||
app.include_router(openai_router)
|
||||
app.include_router(gemini_router)
|
||||
return app
|
||||
|
||||
|
||||
def test_public_api_routes_contract_paths_and_tags() -> None:
|
||||
app = _build_contract_app()
|
||||
schema = app.openapi()
|
||||
|
||||
paths = schema.get("paths") or {}
|
||||
|
||||
expected = [
|
||||
("/v1/messages", "post", "Claude API"),
|
||||
("/v1/messages/count_tokens", "post", "Claude API"),
|
||||
("/v1/chat/completions", "post", "OpenAI API"),
|
||||
("/v1/responses", "post", "OpenAI API"),
|
||||
("/v1beta/models/{model}:generateContent", "post", "Gemini API"),
|
||||
("/v1beta/models/{model}:streamGenerateContent", "post", "Gemini API"),
|
||||
("/v1/models/{model}:generateContent", "post", "Gemini API"),
|
||||
("/v1/models/{model}:streamGenerateContent", "post", "Gemini API"),
|
||||
]
|
||||
|
||||
for path, method, expected_tag in expected:
|
||||
assert path in paths, f"missing path {path}"
|
||||
operations = paths.get(path) or {}
|
||||
assert method in operations, f"missing {method.upper()} {path}"
|
||||
tags = operations.get(method, {}).get("tags") or []
|
||||
assert expected_tag in tags, f"{method.upper()} {path} missing tag {expected_tag}"
|
||||
@@ -1,170 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.services.scheduling.aware_scheduler import CacheAwareScheduler, ProviderCandidate
|
||||
|
||||
|
||||
def _make_candidate(
|
||||
*,
|
||||
key_id: str,
|
||||
global_priority: int,
|
||||
needs_conversion: bool,
|
||||
provider_keep_priority_on_conversion: bool,
|
||||
is_skipped: bool = False,
|
||||
) -> ProviderCandidate:
|
||||
provider = SimpleNamespace(
|
||||
id=f"p_{key_id}",
|
||||
name=f"prov_{key_id}",
|
||||
provider_priority=1,
|
||||
keep_priority_on_conversion=provider_keep_priority_on_conversion,
|
||||
)
|
||||
endpoint = SimpleNamespace(id=f"e_{key_id}")
|
||||
key = SimpleNamespace(
|
||||
id=key_id,
|
||||
internal_priority=1,
|
||||
api_key="sk-test-1234567890",
|
||||
global_priority_by_format={"openai:chat": global_priority},
|
||||
)
|
||||
|
||||
return ProviderCandidate(
|
||||
provider=cast(Provider, provider),
|
||||
endpoint=cast(ProviderEndpoint, endpoint),
|
||||
key=cast(ProviderAPIKey, key),
|
||||
is_cached=False,
|
||||
is_skipped=is_skipped,
|
||||
skip_reason="unhealthy" if is_skipped else None,
|
||||
needs_conversion=needs_conversion,
|
||||
provider_api_format="openai:chat",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_affinity_hit_healthy_candidate_is_always_promoted_to_front() -> None:
|
||||
"""契约:缓存亲和性命中且候选健康时,无条件置顶(覆盖降级分组/优先级)。"""
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler.scheduling_mode = CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY
|
||||
scheduler.priority_mode = CacheAwareScheduler.PRIORITY_MODE_GLOBAL_KEY
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
keep_1 = _make_candidate(
|
||||
key_id="k_keep_1",
|
||||
global_priority=1,
|
||||
needs_conversion=False,
|
||||
provider_keep_priority_on_conversion=False,
|
||||
)
|
||||
keep_2 = _make_candidate(
|
||||
key_id="k_keep_2",
|
||||
global_priority=2,
|
||||
needs_conversion=False,
|
||||
provider_keep_priority_on_conversion=False,
|
||||
)
|
||||
matched_demote = _make_candidate(
|
||||
key_id="k_cached",
|
||||
global_priority=0,
|
||||
needs_conversion=True,
|
||||
provider_keep_priority_on_conversion=False,
|
||||
)
|
||||
|
||||
affinity = SimpleNamespace(
|
||||
provider_id=matched_demote.provider.id,
|
||||
endpoint_id=matched_demote.endpoint.id,
|
||||
key_id=matched_demote.key.id,
|
||||
request_count=7,
|
||||
)
|
||||
|
||||
scheduler._affinity_manager = SimpleNamespace(get_affinity=AsyncMock(return_value=affinity))
|
||||
|
||||
with patch(
|
||||
"src.services.system.config.SystemConfigService.is_keep_priority_on_conversion",
|
||||
return_value=False,
|
||||
):
|
||||
result = await scheduler.reorder_candidates(
|
||||
candidates=[keep_1, matched_demote, keep_2],
|
||||
db=db,
|
||||
affinity_key="a1",
|
||||
api_format="openai:chat",
|
||||
global_model_id="gm1",
|
||||
)
|
||||
|
||||
assert [c.key.id for c in result] == ["k_cached", "k_keep_1", "k_keep_2"]
|
||||
assert result[0] is matched_demote
|
||||
assert result[0].is_cached is True
|
||||
assert all(not c.is_cached for c in result[1:])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_affinity_hit_skipped_candidate_is_promoted_within_its_group() -> None:
|
||||
"""契约:缓存亲和性命中但候选被跳过时,只提升到其所属类别内最前面。"""
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler.scheduling_mode = CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY
|
||||
scheduler.priority_mode = CacheAwareScheduler.PRIORITY_MODE_GLOBAL_KEY
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
keep_1 = _make_candidate(
|
||||
key_id="k_keep_1",
|
||||
global_priority=1,
|
||||
needs_conversion=False,
|
||||
provider_keep_priority_on_conversion=False,
|
||||
)
|
||||
keep_2 = _make_candidate(
|
||||
key_id="k_keep_2",
|
||||
global_priority=2,
|
||||
needs_conversion=False,
|
||||
provider_keep_priority_on_conversion=False,
|
||||
)
|
||||
|
||||
demote_other = _make_candidate(
|
||||
key_id="k_demote_other",
|
||||
global_priority=0,
|
||||
needs_conversion=True,
|
||||
provider_keep_priority_on_conversion=False,
|
||||
)
|
||||
matched_demote_skipped = _make_candidate(
|
||||
key_id="k_cached",
|
||||
global_priority=10,
|
||||
needs_conversion=True,
|
||||
provider_keep_priority_on_conversion=False,
|
||||
is_skipped=True,
|
||||
)
|
||||
|
||||
affinity = SimpleNamespace(
|
||||
provider_id=matched_demote_skipped.provider.id,
|
||||
endpoint_id=matched_demote_skipped.endpoint.id,
|
||||
key_id=matched_demote_skipped.key.id,
|
||||
request_count=3,
|
||||
)
|
||||
|
||||
scheduler._affinity_manager = SimpleNamespace(get_affinity=AsyncMock(return_value=affinity))
|
||||
|
||||
with patch(
|
||||
"src.services.system.config.SystemConfigService.is_keep_priority_on_conversion",
|
||||
return_value=False,
|
||||
):
|
||||
result = await scheduler.reorder_candidates(
|
||||
candidates=[keep_1, demote_other, keep_2, matched_demote_skipped],
|
||||
db=db,
|
||||
affinity_key="a1",
|
||||
api_format="openai:chat",
|
||||
global_model_id="gm1",
|
||||
)
|
||||
|
||||
# keep 组(exact)整体在前;matched 在 demote 组内置顶(即使 global_priority 更差)
|
||||
assert [c.key.id for c in result] == [
|
||||
"k_keep_1",
|
||||
"k_keep_2",
|
||||
"k_cached",
|
||||
"k_demote_other",
|
||||
]
|
||||
assert result[2] is matched_demote_skipped
|
||||
assert result[2].is_cached is True
|
||||
assert all(not c.is_cached for i, c in enumerate(result) if i != 2)
|
||||
@@ -1,139 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.scheduling.aware_scheduler import CacheAwareScheduler
|
||||
|
||||
|
||||
def _make_db() -> MagicMock:
|
||||
db = MagicMock()
|
||||
db.new = []
|
||||
db.dirty = []
|
||||
db.deleted = []
|
||||
db.in_transaction.return_value = False
|
||||
return db
|
||||
|
||||
|
||||
def _make_global_model(*, gid: str, name: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=gid,
|
||||
name=name,
|
||||
is_active=True,
|
||||
config={},
|
||||
supported_capabilities=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_candidates_returns_provider_batch_count_even_when_candidates_empty() -> (
|
||||
None
|
||||
):
|
||||
"""契约:候选为空不代表 Provider 页为空(用于分页继续拉取下一页)。"""
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler.scheduling_mode = CacheAwareScheduler.SCHEDULING_MODE_FIXED_ORDER
|
||||
|
||||
db = _make_db()
|
||||
|
||||
providers = [
|
||||
SimpleNamespace(
|
||||
id="p1",
|
||||
name="p1",
|
||||
is_active=True,
|
||||
endpoints=[],
|
||||
models=[],
|
||||
provider_priority=1,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="p2",
|
||||
name="p2",
|
||||
is_active=True,
|
||||
endpoints=[],
|
||||
models=[],
|
||||
provider_priority=2,
|
||||
),
|
||||
]
|
||||
|
||||
# allowed_providers 会把本页的 provider 全过滤掉,导致 candidates 为空;但 provider_batch_count 应保留过滤前数量。
|
||||
user_api_key = SimpleNamespace(
|
||||
id="ak1",
|
||||
allowed_providers=["not-matching"],
|
||||
allowed_models=None,
|
||||
allowed_api_formats=None,
|
||||
user=None,
|
||||
)
|
||||
|
||||
global_model = _make_global_model(gid="gm1", name="gpt-4o")
|
||||
|
||||
with patch.object(scheduler, "_ensure_initialized", new=AsyncMock(return_value=None)):
|
||||
with patch.object(
|
||||
scheduler._candidate_builder,
|
||||
"_query_provider_refs",
|
||||
return_value=[("p1", "p1"), ("p2", "p2")],
|
||||
):
|
||||
with patch.object(scheduler._candidate_builder, "_query_providers") as query_providers:
|
||||
with patch(
|
||||
"src.services.scheduling.aware_scheduler.ModelCacheService.get_global_model_by_name",
|
||||
new=AsyncMock(return_value=global_model),
|
||||
):
|
||||
with patch(
|
||||
"src.services.scheduling.aware_scheduler.SystemConfigService.is_format_conversion_enabled",
|
||||
return_value=True,
|
||||
):
|
||||
candidates, global_model_id, provider_batch_count = (
|
||||
await scheduler.list_all_candidates(
|
||||
db=db,
|
||||
api_format="openai:chat",
|
||||
model_name="gpt-4o",
|
||||
affinity_key=None,
|
||||
user_api_key=user_api_key, # type: ignore[arg-type]
|
||||
provider_offset=0,
|
||||
provider_limit=20,
|
||||
)
|
||||
)
|
||||
|
||||
query_providers.assert_not_called()
|
||||
|
||||
assert candidates == []
|
||||
assert global_model_id == "gm1"
|
||||
assert provider_batch_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_candidates_returns_zero_provider_batch_count_when_provider_page_empty() -> (
|
||||
None
|
||||
):
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler.scheduling_mode = CacheAwareScheduler.SCHEDULING_MODE_FIXED_ORDER
|
||||
|
||||
db = _make_db()
|
||||
global_model = _make_global_model(gid="gm1", name="gpt-4o")
|
||||
|
||||
with patch.object(scheduler, "_ensure_initialized", new=AsyncMock(return_value=None)):
|
||||
with patch.object(scheduler._candidate_builder, "_query_providers", return_value=[]):
|
||||
with patch(
|
||||
"src.services.scheduling.aware_scheduler.ModelCacheService.get_global_model_by_name",
|
||||
new=AsyncMock(return_value=global_model),
|
||||
):
|
||||
with patch(
|
||||
"src.services.scheduling.aware_scheduler.SystemConfigService.is_format_conversion_enabled",
|
||||
return_value=True,
|
||||
):
|
||||
candidates, global_model_id, provider_batch_count = (
|
||||
await scheduler.list_all_candidates(
|
||||
db=db,
|
||||
api_format="openai:chat",
|
||||
model_name="gpt-4o",
|
||||
affinity_key=None,
|
||||
user_api_key=None,
|
||||
provider_offset=0,
|
||||
provider_limit=20,
|
||||
)
|
||||
)
|
||||
|
||||
assert candidates == []
|
||||
assert global_model_id == "gm1"
|
||||
assert provider_batch_count == 0
|
||||
@@ -1,93 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.scheduling.aware_scheduler import CacheAwareScheduler
|
||||
|
||||
|
||||
def _make_db() -> MagicMock:
|
||||
db = MagicMock()
|
||||
db.new = []
|
||||
db.dirty = []
|
||||
db.deleted = []
|
||||
db.in_transaction.return_value = False
|
||||
return db
|
||||
|
||||
|
||||
def _make_global_model() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="gm1",
|
||||
name="gpt-4o",
|
||||
is_active=True,
|
||||
config={},
|
||||
supported_capabilities=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_candidates_prefilters_provider_graph_by_allowed_providers() -> None:
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler.scheduling_mode = CacheAwareScheduler.SCHEDULING_MODE_FIXED_ORDER
|
||||
|
||||
db = _make_db()
|
||||
global_model = _make_global_model()
|
||||
user_api_key = SimpleNamespace(
|
||||
id="ak1",
|
||||
allowed_providers=["provider-b"],
|
||||
allowed_models=None,
|
||||
allowed_api_formats=None,
|
||||
user=None,
|
||||
)
|
||||
|
||||
filtered_provider = SimpleNamespace(
|
||||
id="provider-b",
|
||||
name="provider-b",
|
||||
endpoints=[],
|
||||
models=[],
|
||||
provider_priority=2,
|
||||
)
|
||||
|
||||
with patch.object(scheduler, "_ensure_initialized", new=AsyncMock(return_value=None)):
|
||||
with patch.object(
|
||||
scheduler._candidate_builder,
|
||||
"_query_provider_refs",
|
||||
return_value=[("provider-a", "provider-a"), ("provider-b", "provider-b")],
|
||||
) as refs_mock:
|
||||
with patch.object(
|
||||
scheduler._candidate_builder,
|
||||
"_query_providers",
|
||||
return_value=[filtered_provider],
|
||||
) as providers_mock:
|
||||
with patch(
|
||||
"src.services.scheduling.aware_scheduler.ModelCacheService.get_global_model_by_name",
|
||||
new=AsyncMock(return_value=global_model),
|
||||
):
|
||||
with patch(
|
||||
"src.services.scheduling.aware_scheduler.SystemConfigService.is_format_conversion_enabled",
|
||||
return_value=True,
|
||||
):
|
||||
with patch.object(
|
||||
scheduler._candidate_builder,
|
||||
"_build_candidates",
|
||||
new=AsyncMock(return_value=[]),
|
||||
):
|
||||
candidates, global_model_id, provider_batch_count = (
|
||||
await scheduler.list_all_candidates(
|
||||
db=db,
|
||||
api_format="openai:chat",
|
||||
model_name="gpt-4o",
|
||||
affinity_key=None,
|
||||
user_api_key=user_api_key, # type: ignore[arg-type]
|
||||
provider_offset=0,
|
||||
provider_limit=20,
|
||||
)
|
||||
)
|
||||
|
||||
assert candidates == []
|
||||
assert global_model_id == "gm1"
|
||||
assert provider_batch_count == 2
|
||||
refs_mock.assert_called_once()
|
||||
providers_mock.assert_called_once_with(db=db, provider_ids=["provider-b"])
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
Format conversion test fixtures.
|
||||
|
||||
Provides golden internal representations, format-specific fixtures,
|
||||
stream fixtures, error fixtures, and assertion helpers.
|
||||
"""
|
||||
@@ -1,361 +0,0 @@
|
||||
"""
|
||||
Assertion helpers for format conversion tests.
|
||||
|
||||
Provides semantic comparison functions that check meaningful equivalence
|
||||
while tolerating format-specific differences (extra fields, id regeneration, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from src.core.api_format.conversion.internal import (
|
||||
ContentBlock,
|
||||
ImageBlock,
|
||||
InternalMessage,
|
||||
InternalRequest,
|
||||
InternalResponse,
|
||||
StopReason,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolDefinition,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UnknownBlock,
|
||||
)
|
||||
from src.core.api_format.conversion.stream_events import (
|
||||
ContentBlockStartEvent,
|
||||
ContentDeltaEvent,
|
||||
InternalStreamEvent,
|
||||
MessageStopEvent,
|
||||
ToolCallDeltaEvent,
|
||||
)
|
||||
|
||||
|
||||
def assert_internal_request_matches(
|
||||
actual: InternalRequest,
|
||||
expected: InternalRequest,
|
||||
required_fields: set[str],
|
||||
) -> None:
|
||||
"""Verify that actual InternalRequest matches expected on required fields."""
|
||||
if "model" in required_fields:
|
||||
assert (
|
||||
actual.model == expected.model
|
||||
), f"model mismatch: {actual.model!r} != {expected.model!r}"
|
||||
|
||||
if "messages" in required_fields:
|
||||
# Merge consecutive same-role messages before comparison,
|
||||
# since normalizers may merge/split them during conversion.
|
||||
actual_msgs = _merge_consecutive_same_role(actual.messages)
|
||||
expected_msgs = _merge_consecutive_same_role(expected.messages)
|
||||
assert len(actual_msgs) == len(
|
||||
expected_msgs
|
||||
), f"message count mismatch: {len(actual_msgs)} != {len(expected_msgs)}"
|
||||
for i, (a, e) in enumerate(zip(actual_msgs, expected_msgs)):
|
||||
assert a.role == e.role, f"message[{i}] role mismatch: {a.role} != {e.role}"
|
||||
assert_content_blocks_match_unordered(a.content, e.content, context=f"message[{i}]")
|
||||
|
||||
if "system" in required_fields:
|
||||
# Allow either system or instructions to carry the system prompt
|
||||
actual_sys = actual.system or _join_instructions(actual.instructions)
|
||||
expected_sys = expected.system or _join_instructions(expected.instructions)
|
||||
assert actual_sys == expected_sys, f"system mismatch: {actual_sys!r} != {expected_sys!r}"
|
||||
|
||||
if "max_tokens" in required_fields:
|
||||
assert (
|
||||
actual.max_tokens == expected.max_tokens
|
||||
), f"max_tokens mismatch: {actual.max_tokens} != {expected.max_tokens}"
|
||||
|
||||
if "stream" in required_fields:
|
||||
assert actual.stream == expected.stream
|
||||
|
||||
if "tools" in required_fields:
|
||||
assert_tools_match(actual.tools, expected.tools)
|
||||
|
||||
if "tool_choice" in required_fields:
|
||||
if expected.tool_choice is not None:
|
||||
assert actual.tool_choice is not None, "tool_choice is None but expected non-None"
|
||||
assert (
|
||||
actual.tool_choice.type == expected.tool_choice.type
|
||||
), f"tool_choice.type mismatch: {actual.tool_choice.type} != {expected.tool_choice.type}"
|
||||
|
||||
|
||||
def assert_internal_response_matches(
|
||||
actual: InternalResponse,
|
||||
expected: InternalResponse,
|
||||
required_fields: set[str],
|
||||
) -> None:
|
||||
"""Verify that actual InternalResponse matches expected on required fields."""
|
||||
if "content" in required_fields:
|
||||
assert_content_blocks_match(actual.content, expected.content, context="response")
|
||||
|
||||
if "stop_reason" in required_fields:
|
||||
assert (
|
||||
actual.stop_reason == expected.stop_reason
|
||||
), f"stop_reason mismatch: {actual.stop_reason} != {expected.stop_reason}"
|
||||
|
||||
if "usage" in required_fields and expected.usage is not None:
|
||||
assert actual.usage is not None, "usage is None but expected non-None"
|
||||
assert actual.usage.input_tokens == expected.usage.input_tokens
|
||||
assert actual.usage.output_tokens == expected.usage.output_tokens
|
||||
|
||||
|
||||
def assert_content_blocks_match(
|
||||
actual_blocks: list[ContentBlock],
|
||||
expected_blocks: list[ContentBlock],
|
||||
*,
|
||||
context: str = "",
|
||||
) -> None:
|
||||
"""Verify content block lists are semantically equivalent (ignoring extra)."""
|
||||
# Filter out UnknownBlock (allowed to be lost)
|
||||
actual_meaningful = [b for b in actual_blocks if not isinstance(b, UnknownBlock)]
|
||||
expected_meaningful = [b for b in expected_blocks if not isinstance(b, UnknownBlock)]
|
||||
|
||||
assert len(actual_meaningful) == len(expected_meaningful), (
|
||||
f"{context} block count mismatch: {len(actual_meaningful)} != {len(expected_meaningful)}"
|
||||
f"\n actual types: {[type(b).__name__ for b in actual_meaningful]}"
|
||||
f"\n expected types: {[type(b).__name__ for b in expected_meaningful]}"
|
||||
)
|
||||
|
||||
for i, (a, e) in enumerate(zip(actual_meaningful, expected_meaningful)):
|
||||
prefix = f"{context}.block[{i}]" if context else f"block[{i}]"
|
||||
assert type(a) is type(
|
||||
e
|
||||
), f"{prefix} type mismatch: {type(a).__name__} != {type(e).__name__}"
|
||||
|
||||
if isinstance(a, TextBlock) and isinstance(e, TextBlock):
|
||||
assert a.text == e.text, f"{prefix} text mismatch: {a.text!r} != {e.text!r}"
|
||||
|
||||
elif isinstance(a, ToolUseBlock) and isinstance(e, ToolUseBlock):
|
||||
assert (
|
||||
a.tool_name == e.tool_name
|
||||
), f"{prefix} tool_name mismatch: {a.tool_name!r} != {e.tool_name!r}"
|
||||
assert (
|
||||
a.tool_input == e.tool_input
|
||||
), f"{prefix} tool_input mismatch: {a.tool_input} != {e.tool_input}"
|
||||
# tool_id may be regenerated, just verify non-empty
|
||||
assert bool(a.tool_id), f"{prefix} tool_id is empty"
|
||||
|
||||
elif isinstance(a, ToolResultBlock) and isinstance(e, ToolResultBlock):
|
||||
assert bool(a.tool_use_id), f"{prefix} tool_use_id is empty"
|
||||
# content_text or output should be semantically equivalent
|
||||
# Normalizers may parse JSON strings into dicts, so compare semantically
|
||||
a_val = _normalize_tool_output(a)
|
||||
e_val = _normalize_tool_output(e)
|
||||
assert a_val == e_val, f"{prefix} tool result mismatch: {a_val!r} != {e_val!r}"
|
||||
|
||||
elif isinstance(a, ThinkingBlock) and isinstance(e, ThinkingBlock):
|
||||
assert (
|
||||
a.thinking == e.thinking
|
||||
), f"{prefix} thinking mismatch: {a.thinking!r} != {e.thinking!r}"
|
||||
|
||||
elif isinstance(a, ImageBlock) and isinstance(e, ImageBlock):
|
||||
if e.url:
|
||||
assert a.url == e.url, f"{prefix} image url mismatch"
|
||||
if e.data:
|
||||
assert a.data == e.data, f"{prefix} image data mismatch"
|
||||
if e.media_type:
|
||||
assert a.media_type == e.media_type, f"{prefix} media_type mismatch"
|
||||
|
||||
|
||||
def assert_tools_match(
|
||||
actual: list[ToolDefinition] | None,
|
||||
expected: list[ToolDefinition] | None,
|
||||
) -> None:
|
||||
"""Verify tool definitions match."""
|
||||
if expected is None:
|
||||
return
|
||||
assert actual is not None, "tools is None but expected non-None"
|
||||
assert len(actual) == len(expected), f"tools count mismatch: {len(actual)} != {len(expected)}"
|
||||
for i, (a, e) in enumerate(zip(actual, expected)):
|
||||
assert a.name == e.name, f"tool[{i}].name mismatch: {a.name!r} != {e.name!r}"
|
||||
if e.description is not None:
|
||||
assert a.description == e.description, f"tool[{i}].description mismatch"
|
||||
if e.parameters is not None:
|
||||
assert a.parameters == e.parameters, f"tool[{i}].parameters mismatch"
|
||||
|
||||
|
||||
def assert_content_blocks_match_unordered(
|
||||
actual_blocks: list[ContentBlock],
|
||||
expected_blocks: list[ContentBlock],
|
||||
*,
|
||||
context: str = "",
|
||||
) -> None:
|
||||
"""Verify content blocks are semantically equivalent regardless of order.
|
||||
|
||||
Groups blocks by type and compares within each group. This tolerates
|
||||
reordering that normalizers may introduce during roundtrip (e.g. placing
|
||||
tool_result before or after text within the same message).
|
||||
"""
|
||||
actual_meaningful = [b for b in actual_blocks if not isinstance(b, UnknownBlock)]
|
||||
expected_meaningful = [b for b in expected_blocks if not isinstance(b, UnknownBlock)]
|
||||
|
||||
assert len(actual_meaningful) == len(expected_meaningful), (
|
||||
f"{context} block count mismatch: {len(actual_meaningful)} != {len(expected_meaningful)}"
|
||||
f"\n actual types: {[type(b).__name__ for b in actual_meaningful]}"
|
||||
f"\n expected types: {[type(b).__name__ for b in expected_meaningful]}"
|
||||
)
|
||||
|
||||
def _group_by_type(blocks: Sequence[ContentBlock]) -> dict[type, list[ContentBlock]]:
|
||||
groups: dict[type, list[ContentBlock]] = {}
|
||||
for b in blocks:
|
||||
groups.setdefault(type(b), []).append(b)
|
||||
return groups
|
||||
|
||||
actual_groups = _group_by_type(actual_meaningful)
|
||||
expected_groups = _group_by_type(expected_meaningful)
|
||||
|
||||
assert set(actual_groups.keys()) == set(expected_groups.keys()), (
|
||||
f"{context} block type sets differ: "
|
||||
f"{[t.__name__ for t in actual_groups]} != {[t.__name__ for t in expected_groups]}"
|
||||
)
|
||||
|
||||
for btype in expected_groups:
|
||||
a_list = actual_groups[btype]
|
||||
e_list = expected_groups[btype]
|
||||
assert len(a_list) == len(
|
||||
e_list
|
||||
), f"{context} {btype.__name__} count mismatch: {len(a_list)} != {len(e_list)}"
|
||||
for i, (a, e) in enumerate(zip(a_list, e_list)):
|
||||
prefix = f"{context}.{btype.__name__}[{i}]" if context else f"{btype.__name__}[{i}]"
|
||||
if isinstance(a, TextBlock) and isinstance(e, TextBlock):
|
||||
assert a.text == e.text, f"{prefix} text mismatch: {a.text!r} != {e.text!r}"
|
||||
elif isinstance(a, ToolUseBlock) and isinstance(e, ToolUseBlock):
|
||||
assert a.tool_name == e.tool_name, f"{prefix} tool_name mismatch"
|
||||
assert a.tool_input == e.tool_input, f"{prefix} tool_input mismatch"
|
||||
elif isinstance(a, ToolResultBlock) and isinstance(e, ToolResultBlock):
|
||||
a_val = _normalize_tool_output(a)
|
||||
e_val = _normalize_tool_output(e)
|
||||
assert a_val == e_val, f"{prefix} tool result mismatch: {a_val!r} != {e_val!r}"
|
||||
elif isinstance(a, ThinkingBlock) and isinstance(e, ThinkingBlock):
|
||||
assert a.thinking == e.thinking, f"{prefix} thinking mismatch"
|
||||
elif isinstance(a, ImageBlock) and isinstance(e, ImageBlock):
|
||||
if e.url:
|
||||
assert a.url == e.url, f"{prefix} image url mismatch"
|
||||
if e.data:
|
||||
assert a.data == e.data, f"{prefix} image data mismatch"
|
||||
|
||||
|
||||
def _merge_consecutive_same_role(
|
||||
messages: list[InternalMessage],
|
||||
) -> list[InternalMessage]:
|
||||
"""Merge consecutive messages with the same role into one (for semantic comparison)."""
|
||||
if not messages:
|
||||
return []
|
||||
merged: list[InternalMessage] = []
|
||||
for msg in messages:
|
||||
if merged and merged[-1].role == msg.role:
|
||||
merged[-1] = InternalMessage(
|
||||
role=msg.role,
|
||||
content=list(merged[-1].content) + list(msg.content),
|
||||
)
|
||||
else:
|
||||
merged.append(InternalMessage(role=msg.role, content=list(msg.content)))
|
||||
return merged
|
||||
|
||||
|
||||
def assert_internal_requests_equivalent(
|
||||
a: InternalRequest,
|
||||
b: InternalRequest,
|
||||
lossy_fields: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Verify two InternalRequests are semantically equivalent after roundtrip."""
|
||||
lossy = lossy_fields or set()
|
||||
|
||||
assert a.model == b.model
|
||||
if "messages" not in lossy:
|
||||
# Merge consecutive same-role messages before comparison,
|
||||
# since normalizers may merge/split them during roundtrip.
|
||||
a_msgs = _merge_consecutive_same_role(a.messages)
|
||||
b_msgs = _merge_consecutive_same_role(b.messages)
|
||||
assert len(a_msgs) == len(
|
||||
b_msgs
|
||||
), f"message count mismatch after merge: {len(a_msgs)} != {len(b_msgs)}"
|
||||
for i, (am, bm) in enumerate(zip(a_msgs, b_msgs)):
|
||||
assert am.role == bm.role, f"message[{i}] role mismatch after roundtrip"
|
||||
# Use order-insensitive comparison: normalizers may reorder blocks
|
||||
# within a message during roundtrip (e.g. tool_result before/after text).
|
||||
assert_content_blocks_match_unordered(
|
||||
am.content, bm.content, context=f"roundtrip.message[{i}]"
|
||||
)
|
||||
if "system" not in lossy:
|
||||
a_sys = a.system or _join_instructions(a.instructions)
|
||||
b_sys = b.system or _join_instructions(b.instructions)
|
||||
assert a_sys == b_sys
|
||||
if "max_tokens" not in lossy:
|
||||
assert a.max_tokens == b.max_tokens
|
||||
if "tools" not in lossy:
|
||||
assert_tools_match(a.tools, b.tools)
|
||||
|
||||
|
||||
def assert_stream_text_matches(
|
||||
events: list[InternalStreamEvent],
|
||||
expected_text: str,
|
||||
) -> None:
|
||||
"""Verify that stream events produce the expected text when concatenated."""
|
||||
parts: list[str] = []
|
||||
for evt in events:
|
||||
if isinstance(evt, ContentDeltaEvent) and evt.text_delta:
|
||||
parts.append(evt.text_delta)
|
||||
actual = "".join(parts)
|
||||
assert actual == expected_text, f"stream text mismatch: {actual!r} != {expected_text!r}"
|
||||
|
||||
|
||||
def assert_stream_stop_reason_matches(
|
||||
events: list[InternalStreamEvent],
|
||||
expected: StopReason,
|
||||
) -> None:
|
||||
"""Verify that the stream ends with the expected stop reason."""
|
||||
stop_events = [e for e in events if isinstance(e, MessageStopEvent)]
|
||||
assert stop_events, "no MessageStopEvent found in stream events"
|
||||
last = stop_events[-1]
|
||||
assert (
|
||||
last.stop_reason == expected
|
||||
), f"stream stop_reason mismatch: {last.stop_reason} != {expected}"
|
||||
|
||||
|
||||
def _join_instructions(instructions: list) -> str | None:
|
||||
if not instructions:
|
||||
return None
|
||||
parts = [seg.text for seg in instructions if seg.text]
|
||||
return "\n\n".join(parts) or None
|
||||
|
||||
|
||||
def _normalize_tool_output(block: ToolResultBlock) -> object:
|
||||
"""Normalize tool output for comparison (parse JSON strings to dicts)."""
|
||||
import json
|
||||
|
||||
val = block.content_text if block.content_text is not None else block.output
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return val
|
||||
return val
|
||||
|
||||
|
||||
def assert_stream_has_tool_call(
|
||||
events: list[InternalStreamEvent],
|
||||
expected_tool_name: str,
|
||||
) -> None:
|
||||
"""Verify that stream events contain a tool call with the expected name."""
|
||||
from src.core.api_format.conversion.internal import ContentType
|
||||
|
||||
tool_starts = [
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, ContentBlockStartEvent) and e.block_type == ContentType.TOOL_USE
|
||||
]
|
||||
assert tool_starts, "no tool call ContentBlockStartEvent found in stream events"
|
||||
names = [e.tool_name for e in tool_starts]
|
||||
assert (
|
||||
expected_tool_name in names
|
||||
), f"tool name {expected_tool_name!r} not found in stream tool starts: {names}"
|
||||
|
||||
# Verify there are ToolCallDeltaEvents with non-empty input
|
||||
tool_deltas = [e for e in events if isinstance(e, ToolCallDeltaEvent)]
|
||||
assert tool_deltas, "no ToolCallDeltaEvent found in stream events"
|
||||
combined = "".join(d.input_delta for d in tool_deltas)
|
||||
assert combined, "tool call input_delta is empty after concatenation"
|
||||
@@ -1,258 +0,0 @@
|
||||
"""
|
||||
Error fixtures for each format.
|
||||
|
||||
Each fixture defines a format-specific error response and the expected
|
||||
InternalError it should produce.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from src.core.api_format.conversion.internal import ErrorType
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorFixture:
|
||||
"""A format-specific error fixture."""
|
||||
|
||||
error_response: dict[str, Any]
|
||||
expected_type: ErrorType
|
||||
expected_message: str
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Claude error responses
|
||||
# ===================================================================
|
||||
|
||||
_CLAUDE_ERRORS: dict[str, ErrorFixture] = {
|
||||
"invalid_request": ErrorFixture(
|
||||
error_response={
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "max_tokens must be a positive integer",
|
||||
},
|
||||
},
|
||||
expected_type=ErrorType.INVALID_REQUEST,
|
||||
expected_message="max_tokens must be a positive integer",
|
||||
),
|
||||
"rate_limit": ErrorFixture(
|
||||
error_response={
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "rate_limit_error",
|
||||
"message": "Rate limit exceeded",
|
||||
},
|
||||
},
|
||||
expected_type=ErrorType.RATE_LIMIT,
|
||||
expected_message="Rate limit exceeded",
|
||||
),
|
||||
"auth_error": ErrorFixture(
|
||||
error_response={
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "authentication_error",
|
||||
"message": "Invalid API key",
|
||||
},
|
||||
},
|
||||
expected_type=ErrorType.AUTHENTICATION,
|
||||
expected_message="Invalid API key",
|
||||
),
|
||||
"overloaded": ErrorFixture(
|
||||
error_response={
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "overloaded_error",
|
||||
"message": "Overloaded",
|
||||
},
|
||||
},
|
||||
expected_type=ErrorType.OVERLOADED,
|
||||
expected_message="Overloaded",
|
||||
),
|
||||
"server_error": ErrorFixture(
|
||||
error_response={
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "api_error",
|
||||
"message": "Internal server error",
|
||||
},
|
||||
},
|
||||
expected_type=ErrorType.SERVER_ERROR,
|
||||
expected_message="Internal server error",
|
||||
),
|
||||
"not_found": ErrorFixture(
|
||||
error_response={
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "not_found_error",
|
||||
"message": "Model not found",
|
||||
},
|
||||
},
|
||||
expected_type=ErrorType.NOT_FOUND,
|
||||
expected_message="Model not found",
|
||||
),
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# OpenAI Chat error responses
|
||||
# ===================================================================
|
||||
|
||||
_OPENAI_CHAT_ERRORS: dict[str, ErrorFixture] = {
|
||||
"invalid_request": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"message": "Invalid value for max_tokens",
|
||||
"type": "invalid_request_error",
|
||||
"param": "max_tokens",
|
||||
"code": None,
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.INVALID_REQUEST,
|
||||
expected_message="Invalid value for max_tokens",
|
||||
),
|
||||
"rate_limit": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"message": "Rate limit reached",
|
||||
"type": "rate_limit_exceeded",
|
||||
"param": None,
|
||||
"code": "rate_limit_exceeded",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.RATE_LIMIT,
|
||||
expected_message="Rate limit reached",
|
||||
),
|
||||
"auth_error": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"message": "Incorrect API key provided",
|
||||
"type": "invalid_api_key",
|
||||
"param": None,
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.AUTHENTICATION,
|
||||
expected_message="Incorrect API key provided",
|
||||
),
|
||||
"server_error": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"message": "The server had an error",
|
||||
"type": "server_error",
|
||||
"param": None,
|
||||
"code": "server_error",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.SERVER_ERROR,
|
||||
expected_message="The server had an error",
|
||||
),
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# OpenAI CLI (Responses API) error responses
|
||||
# ===================================================================
|
||||
|
||||
_OPENAI_CLI_ERRORS: dict[str, ErrorFixture] = {
|
||||
"invalid_request": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"message": "Invalid input",
|
||||
"type": "invalid_request_error",
|
||||
"code": None,
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.INVALID_REQUEST,
|
||||
expected_message="Invalid input",
|
||||
),
|
||||
"rate_limit": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"message": "Rate limit reached",
|
||||
"type": "rate_limit_exceeded",
|
||||
"code": "rate_limit_exceeded",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.RATE_LIMIT,
|
||||
expected_message="Rate limit reached",
|
||||
),
|
||||
"server_error": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"message": "Internal server error",
|
||||
"type": "server_error",
|
||||
"code": "server_error",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.SERVER_ERROR,
|
||||
expected_message="Internal server error",
|
||||
),
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# Gemini error responses
|
||||
# ===================================================================
|
||||
|
||||
_GEMINI_ERRORS: dict[str, ErrorFixture] = {
|
||||
"invalid_request": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": "Invalid value for field",
|
||||
"status": "INVALID_ARGUMENT",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.INVALID_REQUEST,
|
||||
expected_message="Invalid value for field",
|
||||
),
|
||||
"rate_limit": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "Resource exhausted",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.RATE_LIMIT,
|
||||
expected_message="Resource exhausted",
|
||||
),
|
||||
"auth_error": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "API key not valid",
|
||||
"status": "UNAUTHENTICATED",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.AUTHENTICATION,
|
||||
expected_message="API key not valid",
|
||||
),
|
||||
"server_error": ErrorFixture(
|
||||
error_response={
|
||||
"error": {
|
||||
"code": 500,
|
||||
"message": "Internal error encountered",
|
||||
"status": "INTERNAL",
|
||||
}
|
||||
},
|
||||
expected_type=ErrorType.SERVER_ERROR,
|
||||
expected_message="Internal error encountered",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Registry
|
||||
# ===================================================================
|
||||
|
||||
ERROR_FIXTURES: dict[str, dict[str, ErrorFixture]] = {
|
||||
"claude:chat": _CLAUDE_ERRORS,
|
||||
"claude:cli": _CLAUDE_ERRORS,
|
||||
"openai:chat": _OPENAI_CHAT_ERRORS,
|
||||
"openai:cli": _OPENAI_CLI_ERRORS,
|
||||
"gemini:chat": _GEMINI_ERRORS,
|
||||
"gemini:cli": _GEMINI_ERRORS,
|
||||
}
|
||||
|
||||
ERROR_ALL_FORMATS = list(ERROR_FIXTURES.keys())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,425 +0,0 @@
|
||||
"""
|
||||
Internal golden fixtures.
|
||||
|
||||
Each fixture defines the canonical InternalRequest / InternalResponse
|
||||
that all normalizers must produce (or consume) for a given scenario.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.core.api_format.conversion.internal import (
|
||||
ImageBlock,
|
||||
InstructionSegment,
|
||||
InternalMessage,
|
||||
InternalRequest,
|
||||
InternalResponse,
|
||||
Role,
|
||||
StopReason,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolChoice,
|
||||
ToolChoiceType,
|
||||
ToolDefinition,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UsageInfo,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoldenFixture:
|
||||
"""A golden internal fixture for a specific scenario."""
|
||||
|
||||
fixture_id: str
|
||||
description: str
|
||||
internal_request: InternalRequest
|
||||
internal_response: InternalResponse
|
||||
# Fields that MUST be correctly converted by every normalizer
|
||||
required_fields: set[str] = field(default_factory=set)
|
||||
# Fields that may be lost during conversion (format-specific extras)
|
||||
lossy_fields: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared constants
|
||||
# ---------------------------------------------------------------------------
|
||||
_MODEL = "test-model"
|
||||
_SYSTEM = "You are a helpful assistant."
|
||||
_TOOL_DEF = ToolDefinition(
|
||||
name="get_weather",
|
||||
description="Get the current weather for a location.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string", "description": "City name"},
|
||||
},
|
||||
"required": ["location"],
|
||||
},
|
||||
)
|
||||
|
||||
_TOOL_ID = "tool_call_001"
|
||||
|
||||
_REQUIRED_REQUEST = {"model", "messages", "system"}
|
||||
_REQUIRED_RESPONSE = {"content", "stop_reason"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# simple_text: single-turn text conversation
|
||||
# ---------------------------------------------------------------------------
|
||||
SIMPLE_TEXT = GoldenFixture(
|
||||
fixture_id="simple_text",
|
||||
description="Single-turn text conversation with system prompt",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(role=Role.USER, content=[TextBlock(text="Hello, how are you?")]),
|
||||
],
|
||||
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
|
||||
system=_SYSTEM,
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_001",
|
||||
model=_MODEL,
|
||||
content=[TextBlock(text="I'm doing well, thank you!")],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=10, output_tokens=8, total_tokens=18),
|
||||
),
|
||||
required_fields={"model", "messages", "system", "max_tokens", "content", "stop_reason"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# multi_turn: multi-turn conversation
|
||||
# ---------------------------------------------------------------------------
|
||||
MULTI_TURN = GoldenFixture(
|
||||
fixture_id="multi_turn",
|
||||
description="Multi-turn conversation with user/assistant alternation",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(role=Role.USER, content=[TextBlock(text="What is 2+2?")]),
|
||||
InternalMessage(role=Role.ASSISTANT, content=[TextBlock(text="4")]),
|
||||
InternalMessage(role=Role.USER, content=[TextBlock(text="And 3+3?")]),
|
||||
],
|
||||
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
|
||||
system=_SYSTEM,
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_002",
|
||||
model=_MODEL,
|
||||
content=[TextBlock(text="6")],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=20, output_tokens=1, total_tokens=21),
|
||||
),
|
||||
required_fields={"model", "messages", "system", "content", "stop_reason"},
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tool_use: tool call + tool result
|
||||
# ---------------------------------------------------------------------------
|
||||
TOOL_USE = GoldenFixture(
|
||||
fixture_id="tool_use",
|
||||
description="Single tool call with result in conversation history",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(
|
||||
role=Role.USER, content=[TextBlock(text="What is the weather in Tokyo?")]
|
||||
),
|
||||
InternalMessage(
|
||||
role=Role.ASSISTANT,
|
||||
content=[
|
||||
TextBlock(text="Let me check the weather for you."),
|
||||
ToolUseBlock(
|
||||
tool_id=_TOOL_ID,
|
||||
tool_name="get_weather",
|
||||
tool_input={"location": "Tokyo"},
|
||||
),
|
||||
],
|
||||
),
|
||||
InternalMessage(
|
||||
role=Role.USER,
|
||||
content=[
|
||||
ToolResultBlock(
|
||||
tool_use_id=_TOOL_ID,
|
||||
content_text='{"temperature": 22, "condition": "sunny"}',
|
||||
),
|
||||
],
|
||||
),
|
||||
InternalMessage(role=Role.USER, content=[TextBlock(text="Thanks!")]),
|
||||
],
|
||||
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
|
||||
system=_SYSTEM,
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
tools=[_TOOL_DEF],
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_003",
|
||||
model=_MODEL,
|
||||
content=[TextBlock(text="The weather in Tokyo is 22C and sunny.")],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=50, output_tokens=12, total_tokens=62),
|
||||
),
|
||||
required_fields={"model", "messages", "system", "tools", "content", "stop_reason"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tool_use_response: response that contains a tool call (not end_turn)
|
||||
# ---------------------------------------------------------------------------
|
||||
TOOL_USE_RESPONSE = GoldenFixture(
|
||||
fixture_id="tool_use_response",
|
||||
description="Response that is a tool call (stop_reason=tool_use)",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(
|
||||
role=Role.USER, content=[TextBlock(text="What is the weather in Tokyo?")]
|
||||
),
|
||||
],
|
||||
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
|
||||
system=_SYSTEM,
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
tools=[_TOOL_DEF],
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_004",
|
||||
model=_MODEL,
|
||||
content=[
|
||||
ToolUseBlock(
|
||||
tool_id=_TOOL_ID,
|
||||
tool_name="get_weather",
|
||||
tool_input={"location": "Tokyo"},
|
||||
),
|
||||
],
|
||||
stop_reason=StopReason.TOOL_USE,
|
||||
usage=UsageInfo(input_tokens=30, output_tokens=15, total_tokens=45),
|
||||
),
|
||||
required_fields={"model", "messages", "tools", "content", "stop_reason"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# thinking: response with thinking block
|
||||
# ---------------------------------------------------------------------------
|
||||
THINKING = GoldenFixture(
|
||||
fixture_id="thinking",
|
||||
description="Response with thinking/reasoning content",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(role=Role.USER, content=[TextBlock(text="Solve: 15 * 23")]),
|
||||
],
|
||||
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
|
||||
system=_SYSTEM,
|
||||
max_tokens=2048,
|
||||
stream=False,
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_005",
|
||||
model=_MODEL,
|
||||
content=[
|
||||
ThinkingBlock(thinking="15 * 23 = 15 * 20 + 15 * 3 = 300 + 45 = 345"),
|
||||
TextBlock(text="345"),
|
||||
],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=15, output_tokens=20, total_tokens=35),
|
||||
),
|
||||
required_fields={"model", "messages", "content", "stop_reason"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# image_url: image input via URL
|
||||
# ---------------------------------------------------------------------------
|
||||
IMAGE_URL = GoldenFixture(
|
||||
fixture_id="image_url",
|
||||
description="Image input via URL",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(
|
||||
role=Role.USER,
|
||||
content=[
|
||||
ImageBlock(url="https://example.com/image.png", media_type="image/png"),
|
||||
TextBlock(text="What is in this image?"),
|
||||
],
|
||||
),
|
||||
],
|
||||
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
|
||||
system=_SYSTEM,
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_006",
|
||||
model=_MODEL,
|
||||
content=[TextBlock(text="I see a cat.")],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=100, output_tokens=5, total_tokens=105),
|
||||
),
|
||||
required_fields={"model", "messages", "content", "stop_reason"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# image_base64: image input via base64
|
||||
# ---------------------------------------------------------------------------
|
||||
IMAGE_BASE64 = GoldenFixture(
|
||||
fixture_id="image_base64",
|
||||
description="Image input via base64 data",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(
|
||||
role=Role.USER,
|
||||
content=[
|
||||
ImageBlock(data="iVBORw0KGgo=", media_type="image/png"),
|
||||
TextBlock(text="Describe this image."),
|
||||
],
|
||||
),
|
||||
],
|
||||
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
|
||||
system=_SYSTEM,
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_007",
|
||||
model=_MODEL,
|
||||
content=[TextBlock(text="A small icon.")],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=80, output_tokens=3, total_tokens=83),
|
||||
),
|
||||
required_fields={"model", "messages", "content", "stop_reason"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# empty_response: response with no content
|
||||
# ---------------------------------------------------------------------------
|
||||
EMPTY_RESPONSE = GoldenFixture(
|
||||
fixture_id="empty_response",
|
||||
description="Empty response (no content blocks)",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(role=Role.USER, content=[TextBlock(text="Say nothing.")]),
|
||||
],
|
||||
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
|
||||
system=_SYSTEM,
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_008",
|
||||
model=_MODEL,
|
||||
content=[], # Normalizers typically drop empty text blocks
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=10, output_tokens=0, total_tokens=10),
|
||||
),
|
||||
required_fields={"model", "stop_reason"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tool_choice_auto: tool_choice=auto
|
||||
# ---------------------------------------------------------------------------
|
||||
TOOL_CHOICE_AUTO = GoldenFixture(
|
||||
fixture_id="tool_choice_auto",
|
||||
description="Request with tool_choice=auto",
|
||||
internal_request=InternalRequest(
|
||||
model=_MODEL,
|
||||
messages=[
|
||||
InternalMessage(role=Role.USER, content=[TextBlock(text="Help me.")]),
|
||||
],
|
||||
system=_SYSTEM,
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
tools=[_TOOL_DEF],
|
||||
tool_choice=ToolChoice(type=ToolChoiceType.AUTO),
|
||||
),
|
||||
internal_response=InternalResponse(
|
||||
id="resp_009",
|
||||
model=_MODEL,
|
||||
content=[TextBlock(text="Sure!")],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
),
|
||||
required_fields={"model", "messages", "tools", "tool_choice"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry of all golden fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
ALL_GOLDEN_FIXTURES: dict[str, GoldenFixture] = {
|
||||
f.fixture_id: f
|
||||
for f in [
|
||||
SIMPLE_TEXT,
|
||||
MULTI_TURN,
|
||||
TOOL_USE,
|
||||
TOOL_USE_RESPONSE,
|
||||
THINKING,
|
||||
IMAGE_URL,
|
||||
IMAGE_BASE64,
|
||||
EMPTY_RESPONSE,
|
||||
TOOL_CHOICE_AUTO,
|
||||
]
|
||||
}
|
||||
|
||||
# Fixture IDs that all formats must support (core scenarios)
|
||||
CORE_FIXTURE_IDS = ["simple_text", "multi_turn", "tool_use", "empty_response"]
|
||||
|
||||
# Fixture IDs for extended scenarios (some formats may not support)
|
||||
EXTENDED_FIXTURE_IDS = [
|
||||
"tool_use_response",
|
||||
"thinking",
|
||||
"image_url",
|
||||
"image_base64",
|
||||
"tool_choice_auto",
|
||||
]
|
||||
|
||||
ALL_FIXTURE_IDS = CORE_FIXTURE_IDS + EXTENDED_FIXTURE_IDS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Known normalizer limitations for extended fixtures.
|
||||
#
|
||||
# Maps (format_id, fixture_id, test_layer) -> reason string.
|
||||
# test_layer: "to_internal", "from_internal", "roundtrip", "cross_request", "cross_response"
|
||||
#
|
||||
# These are documented limitations of the current normalizer implementations,
|
||||
# NOT bugs to fix. Tests will skip these combinations.
|
||||
# ---------------------------------------------------------------------------
|
||||
KNOWN_LIMITATIONS: dict[tuple[str, str, str], str] = {}
|
||||
|
||||
# Formats where response_to_internal loses ThinkingBlock (source limitation)
|
||||
_THINKING_RESPONSE_LOSSY_SOURCES = {"openai:cli"}
|
||||
|
||||
# Fixtures where the response's thinking block is lost when target format
|
||||
# doesn't support ThinkingBlock in non-streaming responses.
|
||||
_THINKING_RESPONSE_LOSSY_TARGETS = {"openai:cli"}
|
||||
|
||||
|
||||
def is_cross_format_limited(
|
||||
source: str,
|
||||
target: str,
|
||||
fixture_id: str,
|
||||
layer: str,
|
||||
) -> str | None:
|
||||
"""Return a reason string if this cross-format combo is a known limitation, else None."""
|
||||
# thinking response: openai:cli doesn't support ThinkingBlock in non-streaming
|
||||
if fixture_id == "thinking" and layer == "cross_response":
|
||||
if source in _THINKING_RESPONSE_LOSSY_SOURCES:
|
||||
return f"{source} does not parse thinking content into ThinkingBlock"
|
||||
if target in _THINKING_RESPONSE_LOSSY_TARGETS:
|
||||
return f"{target} does not preserve ThinkingBlock in non-streaming responses"
|
||||
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,504 +0,0 @@
|
||||
"""
|
||||
Stream fixtures for each format.
|
||||
|
||||
Each fixture defines a sequence of format-specific SSE chunks and the
|
||||
expected internal stream events / final text they should produce.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from src.core.api_format.conversion.internal import StopReason
|
||||
|
||||
from .golden_internal import _MODEL
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamFixture:
|
||||
"""A stream fixture for a specific format and scenario."""
|
||||
|
||||
chunks: list[dict[str, Any]]
|
||||
expected_text: str
|
||||
expected_stop_reason: StopReason
|
||||
# Fields that may differ across formats
|
||||
lossy_fields: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Claude Chat / CLI stream chunks
|
||||
# ===================================================================
|
||||
|
||||
_CLAUDE_STREAM_TEXT_CHUNKS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_stream_001",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": _MODEL,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"usage": {"input_tokens": 10, "output_tokens": 0},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
# PLACEHOLDER_DELTAS
|
||||
]
|
||||
|
||||
# Add text deltas
|
||||
_CLAUDE_STREAM_TEXT_CHUNKS.extend(
|
||||
[
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hello, "},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "world!"},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn"},
|
||||
"usage": {"output_tokens": 5},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
_CLAUDE_STREAM_TEXT = StreamFixture(
|
||||
chunks=_CLAUDE_STREAM_TEXT_CHUNKS,
|
||||
expected_text="Hello, world!",
|
||||
expected_stop_reason=StopReason.END_TURN,
|
||||
)
|
||||
|
||||
|
||||
# Claude stream tool call
|
||||
_CLAUDE_STREAM_TOOL_CALL = StreamFixture(
|
||||
chunks=[
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_stream_tc_001",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": _MODEL,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"usage": {"input_tokens": 20, "output_tokens": 0},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "tool_use", "id": "tool_call_s01", "name": "get_weather"},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"location":'},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": ' "Tokyo"}'},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use"},
|
||||
"usage": {"output_tokens": 10},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
],
|
||||
expected_text="",
|
||||
expected_stop_reason=StopReason.TOOL_USE,
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# OpenAI Chat stream chunks
|
||||
# ===================================================================
|
||||
|
||||
_OPENAI_CHAT_STREAM_TEXT = StreamFixture(
|
||||
chunks=[
|
||||
{
|
||||
"id": "chatcmpl-stream-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": _MODEL,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": ""},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-stream-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": _MODEL,
|
||||
"choices": [{"index": 0, "delta": {"content": "Hello, "}, "finish_reason": None}],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-stream-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": _MODEL,
|
||||
"choices": [{"index": 0, "delta": {"content": "world!"}, "finish_reason": None}],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-stream-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": _MODEL,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
},
|
||||
],
|
||||
expected_text="Hello, world!",
|
||||
expected_stop_reason=StopReason.END_TURN,
|
||||
)
|
||||
|
||||
|
||||
# OpenAI Chat stream tool call
|
||||
_OPENAI_CHAT_STREAM_TOOL_CALL = StreamFixture(
|
||||
chunks=[
|
||||
{
|
||||
"id": "chatcmpl-stream-tc-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": _MODEL,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call_tc_001",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": ""},
|
||||
}
|
||||
],
|
||||
},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-stream-tc-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": _MODEL,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{"index": 0, "function": {"arguments": '{"location":'}}]
|
||||
},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-stream-tc-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": _MODEL,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"tool_calls": [{"index": 0, "function": {"arguments": ' "Tokyo"}'}}]},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-stream-tc-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": _MODEL,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
|
||||
},
|
||||
],
|
||||
expected_text="",
|
||||
expected_stop_reason=StopReason.TOOL_USE,
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# OpenAI CLI (Responses API) stream chunks
|
||||
# ===================================================================
|
||||
|
||||
_OPENAI_CLI_STREAM_TEXT = StreamFixture(
|
||||
chunks=[
|
||||
{
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_stream_001",
|
||||
"object": "response",
|
||||
"model": _MODEL,
|
||||
"status": "in_progress",
|
||||
"output": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "message",
|
||||
"id": "msg_stream_001",
|
||||
"role": "assistant",
|
||||
"status": "in_progress",
|
||||
"content": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.content_part.added",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"part": {"type": "output_text", "text": ""},
|
||||
},
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": "Hello, ",
|
||||
},
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": "world!",
|
||||
},
|
||||
{
|
||||
"type": "response.output_text.done",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"text": "Hello, world!",
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "message",
|
||||
"id": "msg_stream_001",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "Hello, world!"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_stream_001",
|
||||
"object": "response",
|
||||
"model": _MODEL,
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_stream_001",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "Hello, world!"}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
||||
},
|
||||
},
|
||||
],
|
||||
expected_text="Hello, world!",
|
||||
expected_stop_reason=StopReason.END_TURN,
|
||||
)
|
||||
|
||||
|
||||
# OpenAI CLI stream tool call
|
||||
_OPENAI_CLI_STREAM_TOOL_CALL = StreamFixture(
|
||||
chunks=[
|
||||
{
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_stream_tc_001",
|
||||
"object": "response",
|
||||
"model": _MODEL,
|
||||
"status": "in_progress",
|
||||
"output": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": "fc_001",
|
||||
"id": "fc_001",
|
||||
"name": "get_weather",
|
||||
"status": "in_progress",
|
||||
"arguments": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"output_index": 0,
|
||||
"item_id": "fc_001",
|
||||
"delta": '{"location":',
|
||||
},
|
||||
{
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"output_index": 0,
|
||||
"item_id": "fc_001",
|
||||
"delta": ' "Tokyo"}',
|
||||
},
|
||||
{
|
||||
"type": "response.function_call_arguments.done",
|
||||
"output_index": 0,
|
||||
"item_id": "fc_001",
|
||||
"arguments": '{"location": "Tokyo"}',
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": "fc_001",
|
||||
"id": "fc_001",
|
||||
"name": "get_weather",
|
||||
"status": "completed",
|
||||
"arguments": '{"location": "Tokyo"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_stream_tc_001",
|
||||
"object": "response",
|
||||
"model": _MODEL,
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "fc_001",
|
||||
"id": "fc_001",
|
||||
"name": "get_weather",
|
||||
"status": "completed",
|
||||
"arguments": '{"location": "Tokyo"}',
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30},
|
||||
},
|
||||
},
|
||||
],
|
||||
expected_text="",
|
||||
expected_stop_reason=StopReason.TOOL_USE,
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Gemini Chat / CLI stream chunks
|
||||
# ===================================================================
|
||||
|
||||
_GEMINI_STREAM_TEXT = StreamFixture(
|
||||
chunks=[
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"role": "model", "parts": [{"text": "Hello, "}]},
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"modelVersion": _MODEL,
|
||||
},
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"role": "model", "parts": [{"text": "world!"}]},
|
||||
"index": 0,
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5,
|
||||
"totalTokenCount": 15,
|
||||
},
|
||||
"modelVersion": _MODEL,
|
||||
},
|
||||
],
|
||||
expected_text="Hello, world!",
|
||||
expected_stop_reason=StopReason.END_TURN,
|
||||
)
|
||||
|
||||
|
||||
# Gemini stream tool call (Gemini emits complete tool calls atomically)
|
||||
_GEMINI_STREAM_TOOL_CALL = StreamFixture(
|
||||
chunks=[
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "get_weather",
|
||||
"args": {"location": "Tokyo"},
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
"index": 0,
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 20,
|
||||
"candidatesTokenCount": 10,
|
||||
"totalTokenCount": 30,
|
||||
},
|
||||
"modelVersion": _MODEL,
|
||||
},
|
||||
],
|
||||
expected_text="",
|
||||
expected_stop_reason=StopReason.END_TURN,
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Registry
|
||||
# ===================================================================
|
||||
|
||||
STREAM_FIXTURES: dict[str, dict[str, StreamFixture]] = {
|
||||
"claude:chat": {
|
||||
"stream_text": _CLAUDE_STREAM_TEXT,
|
||||
"stream_tool_call": _CLAUDE_STREAM_TOOL_CALL,
|
||||
},
|
||||
"claude:cli": {
|
||||
"stream_text": _CLAUDE_STREAM_TEXT,
|
||||
"stream_tool_call": _CLAUDE_STREAM_TOOL_CALL,
|
||||
},
|
||||
"openai:chat": {
|
||||
"stream_text": _OPENAI_CHAT_STREAM_TEXT,
|
||||
"stream_tool_call": _OPENAI_CHAT_STREAM_TOOL_CALL,
|
||||
},
|
||||
"openai:cli": {
|
||||
"stream_text": _OPENAI_CLI_STREAM_TEXT,
|
||||
"stream_tool_call": _OPENAI_CLI_STREAM_TOOL_CALL,
|
||||
},
|
||||
"gemini:chat": {
|
||||
"stream_text": _GEMINI_STREAM_TEXT,
|
||||
"stream_tool_call": _GEMINI_STREAM_TOOL_CALL,
|
||||
},
|
||||
"gemini:cli": {
|
||||
"stream_text": _GEMINI_STREAM_TEXT,
|
||||
"stream_tool_call": _GEMINI_STREAM_TOOL_CALL,
|
||||
},
|
||||
}
|
||||
|
||||
STREAM_FIXTURE_IDS = ["stream_text", "stream_tool_call"]
|
||||
STREAM_ALL_FORMATS = list(STREAM_FIXTURES.keys())
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"contents": [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "hi"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"generation_config": {
|
||||
"max_output_tokens": 12,
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"top_p": 1.0
|
||||
},
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"system_instruction": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "sys\n\ndev"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tool_config": {
|
||||
"function_calling_config": {
|
||||
"mode": "AUTO"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "sys\n\ndev",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"stop": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.2,
|
||||
"tool_choice": "auto",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "gemini-1.5-flash",
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"system": "sys\n\ndev",
|
||||
"temperature": 0.2,
|
||||
"tool_choice": {
|
||||
"type": "auto"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_weather"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "sys\n\ndev",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "gemini-1.5-flash",
|
||||
"stop": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"tool_choice": "auto",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"stream": true,
|
||||
"system": "sys\n\ndev",
|
||||
"temperature": 0.2,
|
||||
"tool_choice": {
|
||||
"type": "auto"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_weather"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"contents": [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "hi"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"generation_config": {
|
||||
"max_output_tokens": 12,
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"top_p": 1.0
|
||||
},
|
||||
"model": "gpt-4o-mini",
|
||||
"system_instruction": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "sys\n\ndev"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tool_config": {
|
||||
"function_calling_config": {
|
||||
"mode": "AUTO"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"modelVersion": "claude-3-5-sonnet-latest",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 7,
|
||||
"promptTokenCount": 5,
|
||||
"totalTokenCount": 12
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "hello",
|
||||
"role": "assistant"
|
||||
}
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"object": "chat.completion",
|
||||
"usage": {
|
||||
"completion_tokens": 7,
|
||||
"prompt_tokens": 5,
|
||||
"total_tokens": 12
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "hello",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "msg_unknown",
|
||||
"model": "gemini-1.5-flash",
|
||||
"role": "assistant",
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"type": "message",
|
||||
"usage": {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 7
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "hello",
|
||||
"role": "assistant"
|
||||
}
|
||||
}
|
||||
],
|
||||
"id": "chatcmpl-unknown",
|
||||
"model": "gemini-1.5-flash",
|
||||
"object": "chat.completion",
|
||||
"usage": {
|
||||
"completion_tokens": 7,
|
||||
"prompt_tokens": 5,
|
||||
"total_tokens": 12
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "hello",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "msg_chatcmpl_1",
|
||||
"model": "gpt-4o-mini",
|
||||
"role": "assistant",
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"type": "message",
|
||||
"usage": {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 7
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "chatcmpl_1",
|
||||
"modelVersion": "gpt-4o-mini",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 7,
|
||||
"promptTokenCount": 5,
|
||||
"totalTokenCount": 12
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
[
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "claude-3-5-sonnet-latest"
|
||||
},
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "claude-3-5-sonnet-latest",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 7,
|
||||
"promptTokenCount": 5,
|
||||
"totalTokenCount": 12
|
||||
}
|
||||
}
|
||||
]
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
[
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"object": "chat.completion.chunk"
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "hello"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"object": "chat.completion.chunk"
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user