feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层

- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate
- aether-data 扩展 repository 层:announcements、auth_modules、billing、
  candidate_selection、gemini_file_mappings、global_models、management_tokens、
  oauth_providers、proxy_nodes、quota、users、wallet 等模块
- aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/
  video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块
- 重构 executor decision 和 gateway state 为模块目录结构
- 新增 gateway router、frontdoor 路由层及对应测试
- Python 侧 API 路由重构,新增 compat/support 模块
- 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
fawney19
2026-03-31 19:19:04 +08:00
parent b5a0070023
commit ddf18fed9a
690 changed files with 235087 additions and 16301 deletions

View File

@@ -6,6 +6,7 @@ from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from src.services.auth.oauth.models import OAuthFlowError
from src.services.auth.oauth.providers.linuxdo import LinuxDoOAuthProvider
@@ -33,6 +34,28 @@ def test_linuxdo_authorization_url_omits_empty_scope() -> None:
assert params["state"] == ["state-1"]
@pytest.mark.asyncio
async def test_linuxdo_exchange_code_requires_rust_executor() -> None:
provider = LinuxDoOAuthProvider()
with pytest.raises(OAuthFlowError) as exc_info:
await provider.exchange_code(_make_config(), "code-0")
assert exc_info.value.error_code == "provider_unavailable"
assert exc_info.value.detail == "OAuth 仅支持 Rust executor"
@pytest.mark.asyncio
async def test_linuxdo_get_user_info_requires_rust_executor() -> None:
provider = LinuxDoOAuthProvider()
with pytest.raises(OAuthFlowError) as exc_info:
await provider.get_user_info(_make_config(), "access-token")
assert exc_info.value.error_code == "provider_unavailable"
assert exc_info.value.detail == "OAuth 仅支持 Rust executor"
@pytest.mark.asyncio
async def test_linuxdo_exchange_code_uses_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None:
provider = LinuxDoOAuthProvider()

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import sessionmaker
from src.core.enums import AuthSource, UserRole
from src.models.database import Base, OAuthProvider, User, UserOAuthLink
from src.services.auth.oauth.models import OAuthFlowError
from src.services.auth.oauth.service import OAuthService
from src.services.auth.oauth.state import OAuthStateData
@@ -133,6 +134,127 @@ async def test_handle_callback_allows_bind_state_without_device_id(
assert parse_qs(parsed.query)["oauth_bound"] == ["GitHub"]
@pytest.mark.asyncio
async def test_handle_callback_redirects_when_provider_http_is_rust_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
provider = SimpleNamespace(
exchange_code=AsyncMock(
side_effect=OAuthFlowError("provider_unavailable", "OAuth 仅支持 Rust executor")
),
get_user_info=AsyncMock(),
)
config = SimpleNamespace(
frontend_callback_url="https://app.example.com/auth/callback",
display_name="GitHub",
is_enabled=True,
)
monkeypatch.setattr(
"src.services.auth.oauth.service.OAuthService._require_module_active",
lambda _db: None,
)
monkeypatch.setattr(
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
lambda _provider_type: provider,
)
monkeypatch.setattr(
"src.services.auth.oauth.service.OAuthService._get_provider_config",
lambda _db, _provider_type: config,
)
monkeypatch.setattr(
"src.services.auth.oauth.service.get_redis_client",
AsyncMock(return_value=object()),
)
monkeypatch.setattr(
"src.services.auth.oauth.service.consume_oauth_state",
AsyncMock(
return_value=OAuthStateData(
nonce="state-1",
provider_type="github",
action="login",
user_id=None,
client_device_id="device-1",
created_at=123,
)
),
)
result = await OAuthService.handle_callback(
db=db,
provider_type="github",
state="state-1",
code="code-1",
error=None,
error_description=None,
client_ip=None,
user_agent="pytest-agent",
headers={},
)
parsed = urlparse(result.redirect_url)
params = parse_qs(parsed.query)
assert params["error_code"] == ["provider_unavailable"]
assert params["error_detail"] == ["OAuth 仅支持 Rust executor"]
provider.get_user_info.assert_not_called()
@pytest.mark.asyncio
async def test_test_provider_config_returns_rust_only_failure_without_network(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
monkeypatch.setattr(
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
lambda _provider_type: SimpleNamespace(),
)
monkeypatch.setattr(
"src.services.auth.oauth.service.OAuthService._get_provider_config",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("Python OAuth config probe should not read DB config")
),
)
result = await OAuthService.test_provider_config(db, "github")
assert result == {
"authorization_url_reachable": False,
"token_url_reachable": False,
"secret_status": "unsupported",
"details": "OAuth 配置测试仅支持 Rust executor",
}
@pytest.mark.asyncio
async def test_test_provider_config_with_data_returns_rust_only_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
provider = SimpleNamespace(
authorization_url="https://provider.example/authorize",
token_url="https://provider.example/token",
)
monkeypatch.setattr(
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
lambda _provider_type: provider,
)
result = await OAuthService.test_provider_config_with_data(
provider_type="github",
client_id="client-id",
client_secret="secret",
authorization_url_override=None,
token_url_override=None,
redirect_uri="https://api.example.com/api/oauth/github/callback",
)
assert result == {
"authorization_url_reachable": False,
"token_url_reachable": False,
"secret_status": "unsupported",
"details": "OAuth 配置测试仅支持 Rust executor",
}
def test_handle_login_sync_returns_user_snapshot_outside_db_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View File

@@ -173,6 +173,49 @@ def test_persist_refreshed_token_preserves_true_account_block(
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] Google requires verification"
def test_mark_oauth_token_expired_preserves_existing_account_block() -> None:
key = SimpleNamespace(
id="key-1",
oauth_invalid_at="old-invalid-at",
oauth_invalid_reason="[ACCOUNT_BLOCK] Google requires verification",
)
module._mark_oauth_token_expired(key, expires_at=1_900_000_000)
assert key.oauth_invalid_at == "old-invalid-at"
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] Google requires verification"
def test_mark_oauth_token_expired_marks_detached_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
key = SimpleNamespace(id="key-1", oauth_invalid_at=None, oauth_invalid_reason=None)
row = SimpleNamespace(id="key-1", oauth_invalid_at=None, oauth_invalid_reason=None)
fake_db = _FakeDB(row)
monkeypatch.setattr(
module, "object_session", lambda _key: (_ for _ in ()).throw(RuntimeError())
)
_install_module(
monkeypatch,
"src.database",
{"create_session": lambda: _FakeSessionCtx(fake_db)},
)
_install_module(
monkeypatch,
"src.models.database",
{"ProviderAPIKey": type("ProviderAPIKey", (), {"id": "id"})},
)
module._mark_oauth_token_expired(key, expires_at=1_900_000_000)
assert fake_db.committed is True
assert key.oauth_invalid_at is not None
assert row.oauth_invalid_at is not None
assert str(key.oauth_invalid_reason).startswith("[OAUTH_EXPIRED] Token 已过期且续期失败")
assert "expired_at=1900000000" in str(row.oauth_invalid_reason)
@pytest.mark.asyncio
async def test_refresh_generic_oauth_token_persists_enriched_account_name(
monkeypatch: pytest.MonkeyPatch,

View File

@@ -252,6 +252,54 @@ def test_clear_oauth_invalid_response_invalidates_caches(
assert cache_calls == [("key", "key-1"), ("models", None)]
def test_clear_oauth_invalid_response_noops_without_invalid_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cache_calls: list[tuple[str, str | None]] = []
async def _fake_invalidate_key_cache(key_id: str) -> None:
cache_calls.append(("key", key_id))
async def _fake_invalidate_models_cache() -> None:
cache_calls.append(("models", None))
fake_provider_cache_module = types.ModuleType("src.services.cache.provider_cache")
class _FakeProviderCacheService:
@staticmethod
async def invalidate_provider_api_key_cache(key_id: str) -> None:
await _fake_invalidate_key_cache(key_id)
setattr(fake_provider_cache_module, "ProviderCacheService", _FakeProviderCacheService)
monkeypatch.setitem(
sys.modules, "src.services.cache.provider_cache", fake_provider_cache_module
)
fake_models_service_module = types.ModuleType("src.services.cache.model_list_cache")
setattr(
fake_models_service_module, "invalidate_models_list_cache", _fake_invalidate_models_cache
)
monkeypatch.setitem(
sys.modules, "src.services.cache.model_list_cache", fake_models_service_module
)
key = SimpleNamespace(
oauth_invalid_at=None,
oauth_invalid_reason=None,
is_active=False,
)
db = _FakeClearOAuthDB(key=key)
result = command_module.clear_oauth_invalid_response(cast(Any, db), key_id="key-1")
assert result["message"] == "该 Key 当前无失效标记,无需清除"
assert key.oauth_invalid_at is None
assert key.oauth_invalid_reason is None
assert key.is_active is False
assert db.commit_count == 0
assert cache_calls == []
@pytest.mark.asyncio
async def test_run_delete_key_side_effects_skip_disassociate(
monkeypatch: pytest.MonkeyPatch,

View File

@@ -89,6 +89,18 @@ def _install_module(monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str,
monkeypatch.setitem(sys.modules, name, module)
def _patch_rust_codex_response(
monkeypatch: pytest.MonkeyPatch,
module: Any,
response: Any | None,
) -> None:
monkeypatch.setattr(
module,
"_try_rust_codex_quota_response",
AsyncMock(return_value=response),
)
@pytest.mark.asyncio
async def test_codex_refresher_endpoint_missing_returns_error() -> None:
key = SimpleNamespace(id="k1", name="K1")
@@ -128,15 +140,12 @@ async def test_codex_refresher_http_non_200_returns_error(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
response = _FakeResponse(status_code=503, payload={"x": 1})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -241,15 +250,12 @@ async def test_codex_refresher_http_401_marks_auth_invalid_without_disabling_key
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
response = _FakeResponse(status_code=401, payload={"error": {"message": "token expired"}})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -295,7 +301,6 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -309,9 +314,7 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
),
)
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -362,7 +365,6 @@ async def test_codex_refresher_success_preserves_refresh_failed_marker(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -379,9 +381,7 @@ async def test_codex_refresher_success_preserves_refresh_failed_marker(
module, "parse_codex_wham_usage_response", lambda _data: {"used_percent": 10.0}
)
response = _FakeResponse(status_code=200, payload={"ok": True})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -430,7 +430,6 @@ async def test_codex_refresher_quota_exhausted_preserves_refresh_failed_marker(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -444,9 +443,7 @@ async def test_codex_refresher_quota_exhausted_preserves_refresh_failed_marker(
),
)
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -490,7 +487,6 @@ async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -499,9 +495,7 @@ async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
status_code=403,
payload={"error": {"message": "Authentication token has been invalidated."}},
)
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -541,7 +535,6 @@ async def test_codex_refresher_http_403_generic_marks_soft_request_failed(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -550,9 +543,7 @@ async def test_codex_refresher_http_403_generic_marks_soft_request_failed(
status_code=403,
payload={"error": {"message": "Access forbidden for this account."}},
)
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -599,7 +590,6 @@ async def test_codex_refresher_success_updates_metadata(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -608,9 +598,7 @@ async def test_codex_refresher_success_updates_metadata(
module, "parse_codex_wham_usage_response", lambda _data: {"used_percent": 10.0}
)
response = _FakeResponse(status_code=200, payload={"ok": True})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -647,7 +635,6 @@ async def test_codex_refresher_parse_error_is_diagnostic(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -660,9 +647,7 @@ async def test_codex_refresher_parse_error_is_diagnostic(
),
)
response = _FakeResponse(status_code=200, payload={"ok": True})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -706,7 +691,6 @@ async def test_codex_refresher_oauth_missing_plan_type_adds_account_header(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -717,12 +701,11 @@ async def test_codex_refresher_oauth_missing_plan_type_adds_account_header(
module.crypto_service, "decrypt", lambda _v: json.dumps({"account_id": "acc-1"})
)
def _client_factory(**kwargs: Any) -> _FakeAsyncClient:
client = _FakeAsyncClient(response, **kwargs)
client_ref["client"] = client
return client
async def _fake_rust_response(**kwargs: Any) -> Any:
client_ref["client"] = SimpleNamespace(last_headers=dict(kwargs["headers"]))
return response
monkeypatch.setattr(module.httpx, "AsyncClient", _client_factory)
monkeypatch.setattr(module, "_try_rust_codex_quota_response", _fake_rust_response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -766,7 +749,6 @@ async def test_codex_refresher_oauth_uppercase_free_does_not_add_account_header(
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -779,12 +761,11 @@ async def test_codex_refresher_oauth_uppercase_free_does_not_add_account_header(
lambda _v: json.dumps({"account_id": "acc-1", "plan_type": "FREE"}),
)
def _client_factory(**kwargs: Any) -> _FakeAsyncClient:
client = _FakeAsyncClient(response, **kwargs)
client_ref["client"] = client
return client
async def _fake_rust_response(**kwargs: Any) -> Any:
client_ref["client"] = SimpleNamespace(last_headers=dict(kwargs["headers"]))
return response
monkeypatch.setattr(module.httpx, "AsyncClient", _client_factory)
monkeypatch.setattr(module, "_try_rust_codex_quota_response", _fake_rust_response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
@@ -1216,7 +1197,6 @@ async def test_codex_refresher_http_402_workspace_deactivated_marks_account_bloc
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
@@ -1230,9 +1210,7 @@ async def test_codex_refresher_http_402_workspace_deactivated_marks_account_bloc
),
)
response = _FakeResponse(status_code=402, payload={"detail": {"code": "deactivated_workspace"}})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
_patch_rust_codex_response(monkeypatch, module, response)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),

View File

@@ -1,12 +1,19 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from src.config import config as runtime_config
from src.services.provider_ops.service import ProviderOpsService
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
from src.services.provider_ops.types import (
ActionStatus,
ConnectorAuthType,
ProviderActionType,
ProviderOpsConfig,
)
from src.services.request.rust_executor_client import RustExecutorSyncResult
@@ -53,6 +60,14 @@ class _FakeRegistry:
return self._architecture
class _ExplodingConnector:
async def is_authenticated(self) -> bool:
raise AssertionError("Python connector auth check should not run")
def get_client(self) -> Any:
raise AssertionError("Python connector client should not be created")
class _SuccessResult:
def __init__(self) -> None:
self.success = True
@@ -165,3 +180,106 @@ async def test_verify_auth_prefers_rust_executor(
assert captured["plan"].proxy.mode == "tunnel"
assert captured["plan"].proxy.node_id == "node-1"
cache_balance.assert_awaited_once_with("provider-1", 2.0, {"window": "day"})
@pytest.mark.asyncio
async def test_verify_auth_returns_explicit_failure_when_rust_verifier_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = ProviderOpsService(_FakeDB())
architecture = _SuccessArchitecture()
monkeypatch.setattr(
"src.services.provider_ops.service.get_registry",
lambda: _FakeRegistry(architecture),
)
monkeypatch.setattr(
service,
"_try_rust_verify_response",
AsyncMock(return_value=None),
)
monkeypatch.setattr(
"src.services.proxy_node.resolver.resolve_ops_proxy_config_async",
AsyncMock(return_value=(None, None)),
)
result = await service.verify_auth(
base_url="https://example.com",
architecture_id="sub2api",
auth_type=ConnectorAuthType.SESSION_LOGIN,
config={},
credentials={"access_token": "token"},
)
assert result == {"success": False, "message": "认证验证仅支持 Rust executor"}
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["python", "rust"])
async def test_connect_returns_explicit_failure_without_python_connector(
monkeypatch: pytest.MonkeyPatch,
backend: str,
) -> None:
service = ProviderOpsService(_FakeDB())
service._connectors["provider-1"] = object() # type: ignore[assignment]
monkeypatch.setattr(runtime_config, "executor_backend", backend)
monkeypatch.setattr(
service,
"_get_provider",
lambda _provider_id: SimpleNamespace(base_url="https://example.com"),
)
monkeypatch.setattr(
service,
"get_config",
lambda _provider_id: ProviderOpsConfig(
architecture_id="sub2api",
base_url="https://example.com",
connector_auth_type=ConnectorAuthType.API_KEY,
),
)
monkeypatch.setattr(
"src.services.provider_ops.service.get_registry",
lambda: (_ for _ in ()).throw(
AssertionError("Python provider connector registry should not be used")
),
)
success, message = await service.connect("provider-1", {"api_key": "token"})
assert success is False
assert message == "Provider 连接仅支持 Rust executor"
assert "provider-1" not in service._connectors
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["python", "rust"])
async def test_execute_action_returns_not_supported_without_python_connector(
monkeypatch: pytest.MonkeyPatch,
backend: str,
) -> None:
service = ProviderOpsService(_FakeDB())
service._connectors["provider-1"] = _ExplodingConnector() # type: ignore[assignment]
monkeypatch.setattr(runtime_config, "executor_backend", backend)
monkeypatch.setattr(
service,
"get_config",
lambda _provider_id: ProviderOpsConfig(
architecture_id="sub2api",
base_url="https://example.com",
connector_auth_type=ConnectorAuthType.API_KEY,
),
)
monkeypatch.setattr(
"src.services.provider_ops.service.get_registry",
lambda: (_ for _ in ()).throw(
AssertionError("Python provider action architecture should not be used")
),
)
result = await service.execute_action("provider-1", ProviderActionType.QUERY_BALANCE)
assert result.status == ActionStatus.NOT_SUPPORTED
assert result.action_type == ProviderActionType.QUERY_BALANCE
assert result.message == "Provider 操作仅支持 Rust executor"

View File

@@ -79,6 +79,22 @@ def test_codex_openai_cli_does_not_duplicate_responses_suffix() -> None:
assert url == "https://chatgpt.com/backend-api/codex/responses"
def test_codex_openai_cli_supports_backendapi_variant() -> None:
endpoint = _DummyEndpoint(
base_url="https://chatgpt.com/backendapi/codex",
api_format="openai:cli",
provider=SimpleNamespace(provider_type="codex"),
)
url = build_provider_url(
endpoint, # type: ignore[arg-type]
path_params={"model": "ignored"},
is_stream=True,
)
assert url == "https://chatgpt.com/backendapi/codex/responses"
def test_codex_openai_cli_uses_compact_suffix_when_context_marked_compact() -> None:
endpoint = _DummyEndpoint(
base_url="https://chatgpt.com/backend-api/codex",

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -49,6 +49,34 @@ async def test_maintenance_scheduler_start_skips_startup_task_when_disabled(
assert created is False
@pytest.mark.asyncio
async def test_maintenance_scheduler_start_skips_startup_task_when_no_python_owner(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", True)
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
scheduler = MaintenanceScheduler()
safe_create_task = MagicMock()
monkeypatch.setattr("src.utils.async_utils.safe_create_task", safe_create_task)
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
monkeypatch.setattr(
maintenance_scheduler_module,
"get_scheduler",
lambda: SimpleNamespace(
add_cron_job=lambda *args, **kwargs: None,
add_interval_job=lambda *args, **kwargs: None,
),
)
await scheduler.start()
safe_create_task.assert_not_called()
assert scheduler._startup_task is None
@pytest.mark.asyncio
async def test_maintenance_scheduler_stop_cancels_startup_task_and_removes_jobs(
monkeypatch: pytest.MonkeyPatch,
@@ -89,6 +117,518 @@ async def test_maintenance_scheduler_stop_cancels_startup_task_and_removes_jobs(
assert scheduler._registered_job_ids == []
@pytest.mark.asyncio
async def test_maintenance_scheduler_start_skips_rust_owned_maintenance_jobs_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", False)
monkeypatch.setattr(config, "audit_cleanup_python_enabled", False)
monkeypatch.setattr(config, "db_maintenance_python_enabled", False)
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", False)
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", False)
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
monkeypatch.setattr(config, "pool_monitor_python_enabled", False)
monkeypatch.setattr(config, "provider_checkin_python_enabled", False)
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", False)
monkeypatch.setattr(config, "stats_aggregation_python_enabled", False)
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", False)
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
monkeypatch.setattr(config, "usage_cleanup_python_enabled", False)
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", False)
scheduler = MaintenanceScheduler()
cron_job_ids: list[str] = []
interval_job_ids: list[str] = []
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
monkeypatch.setattr(
maintenance_scheduler_module,
"get_scheduler",
lambda: SimpleNamespace(
add_cron_job=lambda *, job_id, **kwargs: cron_job_ids.append(job_id),
add_interval_job=lambda *, job_id, **kwargs: interval_job_ids.append(job_id),
),
)
await scheduler.start()
assert "stats_aggregation" not in cron_job_ids
assert "audit_cleanup" not in cron_job_ids
assert "candidate_cleanup" not in cron_job_ids
assert "db_maintenance" not in cron_job_ids
assert "stats_hourly_aggregation" not in cron_job_ids
assert "pool_monitor" not in interval_job_ids
assert "http_client_idle_cleanup" not in interval_job_ids
assert "usage_cleanup" not in cron_job_ids
assert "wallet_daily_usage_aggregation" not in cron_job_ids
assert scheduler.CHECKIN_JOB_ID not in cron_job_ids
assert "antigravity_ua_refresh" not in interval_job_ids
assert "gemini_file_mapping_cleanup" not in interval_job_ids
assert "pending_cleanup" not in interval_job_ids
assert "stats_aggregation" not in scheduler._registered_job_ids
assert "audit_cleanup" not in scheduler._registered_job_ids
assert "candidate_cleanup" not in scheduler._registered_job_ids
assert "db_maintenance" not in scheduler._registered_job_ids
assert "stats_hourly_aggregation" not in scheduler._registered_job_ids
assert "pool_monitor" not in scheduler._registered_job_ids
assert "http_client_idle_cleanup" not in scheduler._registered_job_ids
assert "usage_cleanup" not in scheduler._registered_job_ids
assert "wallet_daily_usage_aggregation" not in scheduler._registered_job_ids
assert scheduler.CHECKIN_JOB_ID not in scheduler._registered_job_ids
assert "antigravity_ua_refresh" not in scheduler._registered_job_ids
assert "gemini_file_mapping_cleanup" not in scheduler._registered_job_ids
assert "pending_cleanup" not in scheduler._registered_job_ids
@pytest.mark.asyncio
async def test_maintenance_scheduler_start_registers_python_owned_maintenance_jobs_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", False)
monkeypatch.setattr(config, "audit_cleanup_python_enabled", True)
monkeypatch.setattr(config, "db_maintenance_python_enabled", True)
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", True)
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", True)
monkeypatch.setattr(config, "pending_cleanup_python_enabled", True)
monkeypatch.setattr(config, "pool_monitor_python_enabled", True)
monkeypatch.setattr(config, "provider_checkin_python_enabled", True)
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", True)
monkeypatch.setattr(config, "stats_aggregation_python_enabled", True)
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", True)
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", True)
monkeypatch.setattr(config, "usage_cleanup_python_enabled", True)
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", True)
scheduler = MaintenanceScheduler()
cron_job_ids: list[str] = []
interval_job_ids: list[str] = []
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
monkeypatch.setattr(
maintenance_scheduler_module,
"get_scheduler",
lambda: SimpleNamespace(
add_cron_job=lambda *, job_id, **kwargs: cron_job_ids.append(job_id),
add_interval_job=lambda *, job_id, **kwargs: interval_job_ids.append(job_id),
),
)
await scheduler.start()
assert "stats_aggregation" in cron_job_ids
assert "audit_cleanup" in cron_job_ids
assert "candidate_cleanup" in cron_job_ids
assert "db_maintenance" in cron_job_ids
assert "stats_hourly_aggregation" in cron_job_ids
assert "pool_monitor" in interval_job_ids
assert "http_client_idle_cleanup" in interval_job_ids
assert "usage_cleanup" in cron_job_ids
assert "wallet_daily_usage_aggregation" in cron_job_ids
assert scheduler.CHECKIN_JOB_ID in cron_job_ids
assert "antigravity_ua_refresh" in interval_job_ids
assert "gemini_file_mapping_cleanup" in interval_job_ids
assert "pending_cleanup" in interval_job_ids
assert "stats_aggregation" in scheduler._registered_job_ids
assert "audit_cleanup" in scheduler._registered_job_ids
assert "candidate_cleanup" in scheduler._registered_job_ids
assert "db_maintenance" in scheduler._registered_job_ids
assert "stats_hourly_aggregation" in scheduler._registered_job_ids
assert "pool_monitor" in scheduler._registered_job_ids
assert "http_client_idle_cleanup" in scheduler._registered_job_ids
assert "usage_cleanup" in scheduler._registered_job_ids
assert "wallet_daily_usage_aggregation" in scheduler._registered_job_ids
assert scheduler.CHECKIN_JOB_ID in scheduler._registered_job_ids
assert "antigravity_ua_refresh" in scheduler._registered_job_ids
assert "gemini_file_mapping_cleanup" in scheduler._registered_job_ids
assert "pending_cleanup" in scheduler._registered_job_ids
@pytest.mark.asyncio
async def test_maintenance_scheduler_startup_tasks_skip_python_pending_cleanup_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scheduler = MaintenanceScheduler()
refresh_user_agent = AsyncMock()
perform_pending_cleanup = AsyncMock()
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
monkeypatch.setattr(maintenance_scheduler_module.asyncio, "sleep", AsyncMock())
monkeypatch.setattr(
"src.services.provider.adapters.antigravity.client.refresh_user_agent",
refresh_user_agent,
)
monkeypatch.setattr(scheduler, "_perform_pending_cleanup", perform_pending_cleanup)
await scheduler._run_startup_tasks()
refresh_user_agent.assert_not_awaited()
perform_pending_cleanup.assert_not_awaited()
@pytest.mark.asyncio
async def test_maintenance_scheduler_startup_tasks_run_python_pending_cleanup_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scheduler = MaintenanceScheduler()
refresh_user_agent = AsyncMock()
perform_pending_cleanup = AsyncMock()
monkeypatch.setattr(config, "pending_cleanup_python_enabled", True)
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", True)
monkeypatch.setattr(maintenance_scheduler_module.asyncio, "sleep", AsyncMock())
monkeypatch.setattr(
"src.services.provider.adapters.antigravity.client.refresh_user_agent",
refresh_user_agent,
)
monkeypatch.setattr(scheduler, "_perform_pending_cleanup", perform_pending_cleanup)
await scheduler._run_startup_tasks()
refresh_user_agent.assert_awaited_once()
perform_pending_cleanup.assert_awaited_once()
@pytest.mark.asyncio
async def test_start_background_services_skips_python_maintenance_scheduler_when_unused(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, _quota_scheduler, _task_poller, _model_fetch_scheduler = (
_patch_background_services_dependencies(monkeypatch)
)
state = main_module.LifecycleState()
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
monkeypatch.setattr(config, "audit_cleanup_python_enabled", False)
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
monkeypatch.setattr(config, "db_maintenance_python_enabled", False)
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", False)
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", False)
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
monkeypatch.setattr(config, "pool_monitor_python_enabled", False)
monkeypatch.setattr(config, "provider_checkin_python_enabled", False)
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", False)
monkeypatch.setattr(config, "stats_aggregation_python_enabled", False)
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", False)
monkeypatch.setattr(config, "usage_cleanup_python_enabled", False)
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", False)
await main_module._start_background_services(state)
assert "maintenance_scheduler" not in coordinator.acquire_calls
assert state.maintenance_scheduler is None
@pytest.mark.asyncio
async def test_start_background_services_starts_python_maintenance_scheduler_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, _quota_scheduler, _task_poller, _model_fetch_scheduler = (
_patch_background_services_dependencies(
monkeypatch,
acquire_results={"maintenance_scheduler": True},
)
)
state = main_module.LifecycleState()
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", True)
await main_module._start_background_services(state)
assert "maintenance_scheduler" in coordinator.acquire_calls
assert "maintenance_scheduler" in coordinator.registered_callbacks
assert state.maintenance_scheduler is not None
def _patch_core_infrastructure_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "log_startup_warnings", lambda: None)
monkeypatch.setattr(main_module, "init_db", lambda: None)
monkeypatch.setattr(main_module, "initialize_providers", AsyncMock())
monkeypatch.setattr(
"src.clients.redis_client.get_redis_client",
AsyncMock(return_value=None),
)
monkeypatch.setattr(
"src.services.rate_limit.concurrency_manager.get_concurrency_manager",
AsyncMock(return_value=None),
)
monkeypatch.setattr(
"src.services.rate_limit.user_rpm_limiter.get_user_rpm_limiter",
AsyncMock(return_value=None),
)
monkeypatch.setattr("src.core.batch_committer.init_batch_committer", AsyncMock())
monkeypatch.setattr(
"src.services.provider_keys.codex_quota_sync_dispatcher.init_codex_quota_sync_dispatcher",
AsyncMock(),
)
def _patch_python_host_shutdown_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"src.services.provider_keys.codex_quota_sync_dispatcher.shutdown_codex_quota_sync_dispatcher",
AsyncMock(),
)
monkeypatch.setattr("src.core.batch_committer.shutdown_batch_committer", AsyncMock())
monkeypatch.setattr("src.clients.redis_client.close_redis_client", AsyncMock())
monkeypatch.setattr(main_module, "close_http_clients", AsyncMock())
class _FakeStartupTaskCoordinator:
def __init__(self, acquire_results: dict[str, bool] | None = None) -> None:
self.acquire_results = acquire_results or {}
self.acquire_calls: list[str] = []
self.registered_callbacks: list[str] = []
self.released: list[str] = []
async def acquire(self, name: str) -> bool:
self.acquire_calls.append(name)
return self.acquire_results.get(name, False)
async def release(self, name: str) -> None:
self.released.append(name)
def register_lock_lost_callback(self, name: str, _callback: Any) -> None:
self.registered_callbacks.append(name)
def _patch_background_services_dependencies(
monkeypatch: pytest.MonkeyPatch,
*,
acquire_results: dict[str, bool] | None = None,
) -> tuple[_FakeStartupTaskCoordinator, Any, Any, Any]:
coordinator = _FakeStartupTaskCoordinator(acquire_results)
quota_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
maintenance_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
model_fetch_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
pool_quota_probe_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
task_poller = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
task_scheduler = SimpleNamespace(start=MagicMock())
monkeypatch.setattr(
"src.utils.task_coordinator.StartupTaskCoordinator",
lambda _redis: coordinator,
)
monkeypatch.setattr(
"src.services.usage.quota_scheduler.get_quota_scheduler",
lambda: quota_scheduler,
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.get_maintenance_scheduler",
lambda: maintenance_scheduler,
)
monkeypatch.setattr(
"src.services.model.fetch_scheduler.get_model_fetch_scheduler",
lambda: model_fetch_scheduler,
)
monkeypatch.setattr(
"src.services.provider_keys.pool_quota_probe_scheduler.get_pool_quota_probe_scheduler",
lambda: pool_quota_probe_scheduler,
)
monkeypatch.setattr(
"src.services.task.polling.task_poller.get_task_poller",
lambda: task_poller,
)
monkeypatch.setattr(
"src.services.system.scheduler.get_scheduler",
lambda: task_scheduler,
)
return coordinator, quota_scheduler, task_poller, model_fetch_scheduler
@pytest.mark.asyncio
async def test_initialize_core_infrastructure_skips_python_usage_consumer_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_core_infrastructure_dependencies(monkeypatch)
started = AsyncMock()
monkeypatch.setattr(config, "require_redis", False)
monkeypatch.setattr(config, "usage_queue_enabled", True)
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", False)
monkeypatch.setattr("src.services.usage.consumer_streams.start_usage_queue_consumer", started)
await main_module._initialize_core_infrastructure(main_module.LifecycleState())
started.assert_not_awaited()
@pytest.mark.asyncio
async def test_initialize_core_infrastructure_starts_python_usage_consumer_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_core_infrastructure_dependencies(monkeypatch)
started = AsyncMock()
monkeypatch.setattr(config, "require_redis", False)
monkeypatch.setattr(config, "usage_queue_enabled", True)
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", True)
monkeypatch.setattr("src.services.usage.consumer_streams.start_usage_queue_consumer", started)
await main_module._initialize_core_infrastructure(main_module.LifecycleState())
started.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_python_host_shutdown_skips_python_usage_consumer_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_python_host_shutdown_dependencies(monkeypatch)
stopped = AsyncMock()
monkeypatch.setattr(config, "usage_queue_enabled", True)
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", False)
monkeypatch.setattr("src.services.usage.consumer_streams.stop_usage_queue_consumer", stopped)
await main_module._run_python_host_shutdown(main_module.LifecycleState())
stopped.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_python_host_shutdown_stops_python_usage_consumer_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_python_host_shutdown_dependencies(monkeypatch)
stopped = AsyncMock()
monkeypatch.setattr(config, "usage_queue_enabled", True)
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", True)
monkeypatch.setattr("src.services.usage.consumer_streams.stop_usage_queue_consumer", stopped)
await main_module._run_python_host_shutdown(main_module.LifecycleState())
stopped.assert_awaited_once()
@pytest.mark.asyncio
async def test_start_background_services_skips_python_video_poller_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, _quota_scheduler, task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
monkeypatch
)
state = main_module.LifecycleState()
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
await main_module._start_background_services(state)
assert "task_poller:video" not in coordinator.acquire_calls
task_poller.start.assert_not_awaited()
assert state.task_poller is None
@pytest.mark.asyncio
async def test_start_background_services_starts_python_video_poller_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, _quota_scheduler, task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
monkeypatch,
acquire_results={"task_poller:video": True},
)
state = main_module.LifecycleState()
monkeypatch.setattr(config, "video_task_python_poller_enabled", True)
await main_module._start_background_services(state)
assert "task_poller:video" in coordinator.acquire_calls
assert "task_poller:video" in coordinator.registered_callbacks
task_poller.start.assert_awaited_once()
assert state.task_poller is task_poller
@pytest.mark.asyncio
async def test_start_background_services_skips_python_quota_scheduler_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, quota_scheduler, _task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
monkeypatch
)
state = main_module.LifecycleState()
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
await main_module._start_background_services(state)
assert "quota_scheduler" not in coordinator.acquire_calls
quota_scheduler.start.assert_not_awaited()
assert state.quota_scheduler is None
@pytest.mark.asyncio
async def test_start_background_services_starts_python_quota_scheduler_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, quota_scheduler, _task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
monkeypatch,
acquire_results={"quota_scheduler": True},
)
state = main_module.LifecycleState()
monkeypatch.setattr(config, "quota_scheduler_python_enabled", True)
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
await main_module._start_background_services(state)
assert "quota_scheduler" in coordinator.acquire_calls
assert "quota_scheduler" in coordinator.registered_callbacks
quota_scheduler.start.assert_awaited_once()
assert state.quota_scheduler is quota_scheduler
@pytest.mark.asyncio
async def test_start_background_services_skips_python_model_fetch_scheduler_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, _quota_scheduler, _task_poller, model_fetch_scheduler = (
_patch_background_services_dependencies(monkeypatch)
)
state = main_module.LifecycleState()
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
await main_module._start_background_services(state)
assert "model_fetch_scheduler" not in coordinator.acquire_calls
model_fetch_scheduler.start.assert_not_awaited()
assert state.model_fetch_scheduler is None
@pytest.mark.asyncio
async def test_start_background_services_starts_python_model_fetch_scheduler_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, _quota_scheduler, _task_poller, model_fetch_scheduler = (
_patch_background_services_dependencies(
monkeypatch,
acquire_results={"model_fetch_scheduler": True},
)
)
state = main_module.LifecycleState()
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", True)
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
await main_module._start_background_services(state)
assert "model_fetch_scheduler" in coordinator.acquire_calls
assert "model_fetch_scheduler" in coordinator.registered_callbacks
model_fetch_scheduler.start.assert_awaited_once()
assert state.model_fetch_scheduler is model_fetch_scheduler
@pytest.mark.asyncio
async def test_stop_service_on_lock_lost_keeps_state_when_stop_fails() -> None:
state = main_module.LifecycleState()

View File

@@ -191,7 +191,7 @@ async def test_fetch_models_from_endpoints_uses_rust_for_gemini(
@pytest.mark.asyncio
async def test_fetch_models_from_endpoints_falls_back_to_python_when_rust_unavailable(
async def test_fetch_models_from_endpoints_returns_rust_only_error_when_rust_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(fetcher_mod.config, "executor_backend", "rust")
@@ -224,7 +224,7 @@ async def test_fetch_models_from_endpoints_falls_back_to_python_when_rust_unavai
timeout=1.0,
)
assert ok is True
assert errors == []
assert models == [{"id": "fallback-model", "api_format": "openai:chat"}]
python_fetch.assert_awaited_once()
assert ok is False
assert models == []
assert errors == ["openai:chat: rust executor unavailable"]
python_fetch.assert_not_awaited()

View File

@@ -23,7 +23,7 @@ class _TimeoutAsyncClient:
@pytest.mark.asyncio
async def test_vertex_auth_timeout_error_includes_readable_message(
async def test_vertex_auth_is_rust_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = VertexAuthService(
@@ -39,5 +39,5 @@ async def test_vertex_auth_timeout_error_includes_readable_message(
monkeypatch.setattr(service, "_create_jwt", lambda: "signed-jwt")
monkeypatch.setattr("src.core.vertex_auth.httpx.AsyncClient", _TimeoutAsyncClient)
with pytest.raises(VertexAuthError, match=r"request timed out after 30s"):
with pytest.raises(VertexAuthError, match=r"仅支持 Rust executor"):
await service.get_access_token(httpx_client_kwargs={"timeout": 30})

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import src.services.task.video.cancel as cancel_mod
@@ -171,3 +172,122 @@ async def test_video_cancel_service_uses_rust_for_gemini_cancel(
plan = execute_sync.await_args.args[0]
assert plan.method == "POST"
assert plan.body.json_body == {}
@pytest.mark.asyncio
async def test_video_cancel_service_returns_503_when_rust_backend_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
endpoint = SimpleNamespace(
id="ep-1",
provider_id="prov-1",
api_family="openai",
endpoint_kind="video",
base_url="https://api.openai.com",
)
key = SimpleNamespace(id="key-1", api_key="encrypted")
db = _FakeDB(endpoint, key)
service = VideoTaskCancelService(db)
task = SimpleNamespace(
id="task-1",
status=VideoStatus.PROCESSING.value,
external_task_id="ext-1",
endpoint_id="ep-1",
key_id="key-1",
request_id="req-1",
model="sora-2",
completed_at=None,
updated_at=None,
)
monkeypatch.setattr(cancel_mod.config, "executor_backend", "python")
monkeypatch.setattr(
"src.core.crypto.crypto_service.decrypt",
lambda value: "upstream-key",
)
monkeypatch.setattr(
"src.core.api_format.build_upstream_headers_for_endpoint",
lambda *args, **kwargs: {"authorization": "Bearer upstream-key"},
)
monkeypatch.setattr(
"src.services.provider.transport.build_provider_url",
lambda endpoint, is_stream=False, key=None: "https://api.openai.com/v1/videos",
)
monkeypatch.setattr(
"src.clients.http_client.HTTPClientPool.get_default_client_async",
AsyncMock(side_effect=AssertionError("python fallback should not run")),
)
response = await service.cancel_task(
task=task,
task_id="task-1",
original_headers={"x-test": "1"},
)
assert isinstance(response, httpx.Response)
assert response.status_code == 503
assert response.json() == {"error": {"message": "Video 取消仅支持 Rust executor"}}
assert db.committed is False
assert task.status == VideoStatus.PROCESSING.value
@pytest.mark.asyncio
async def test_video_cancel_service_returns_503_when_rust_executor_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
endpoint = SimpleNamespace(
id="ep-1",
provider_id="prov-1",
api_family="gemini",
endpoint_kind="video",
base_url="https://generativelanguage.googleapis.com",
)
key = SimpleNamespace(id="key-1", api_key="encrypted")
db = _FakeDB(endpoint, key)
service = VideoTaskCancelService(db)
task = SimpleNamespace(
id="task-1",
status=VideoStatus.PROCESSING.value,
external_task_id="operations/ext-1",
endpoint_id="ep-1",
key_id="key-1",
request_id="req-1",
model="veo-3",
completed_at=None,
updated_at=None,
)
monkeypatch.setattr(cancel_mod.config, "executor_backend", "rust")
monkeypatch.setattr(
rust_client_mod.RustExecutorClient,
"execute_sync_json",
AsyncMock(side_effect=rust_client_mod.RustExecutorClientError("executor down")),
)
monkeypatch.setattr(
"src.core.crypto.crypto_service.decrypt",
lambda value: "upstream-key",
)
monkeypatch.setattr(
"src.core.api_format.build_upstream_headers_for_endpoint",
lambda *args, **kwargs: {"x-goog-api-key": "upstream-key"},
)
monkeypatch.setattr(
"src.services.provider.auth.get_provider_auth",
AsyncMock(return_value=None),
)
monkeypatch.setattr(
"src.clients.http_client.HTTPClientPool.get_default_client_async",
AsyncMock(side_effect=AssertionError("python fallback should not run")),
)
response = await service.cancel_task(
task=task,
task_id="task-1",
original_headers={"x-test": "1"},
)
assert isinstance(response, httpx.Response)
assert response.status_code == 503
assert response.json() == {"error": {"message": "执行器暂时不可用,请稍后重试"}}
assert db.committed is False
assert task.status == VideoStatus.PROCESSING.value

View File

@@ -25,30 +25,26 @@ async def test_poll_task_status_routes_gemini_video_to_gemini(
external_task_id="operations/123",
),
)
endpoint = SimpleNamespace(id="e1", base_url="https://example.com", api_format="gemini:video")
key = SimpleNamespace(id="k1", api_key="enc")
monkeypatch.setattr(adapter, "_get_endpoint", lambda _db, _id: endpoint)
monkeypatch.setattr(adapter, "_get_key", lambda _db, _id: key)
monkeypatch.setattr(
"src.services.task.video.poller_adapter.crypto_service.decrypt", lambda _v: "decrypted"
prepared_ctx = VideoPollContext(
task_id="task-1",
external_task_id="operations/123",
provider_api_format="gemini:video",
base_url="https://example.com",
upstream_key="decrypted",
headers={"authorization": "Bearer x"},
poll_count=0,
retry_count=0,
poll_interval_seconds=15,
max_poll_count=10,
current_status=VideoStatus.PROCESSING.value,
)
auth_info = SimpleNamespace(auth_header="authorization", auth_value="Bearer x")
monkeypatch.setattr(
"src.services.task.video.poller_adapter.get_provider_auth",
AsyncMock(return_value=auth_info),
)
poll_gemini = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
poll_openai = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
monkeypatch.setattr(adapter, "_poll_gemini", poll_gemini)
monkeypatch.setattr(adapter, "_poll_openai", poll_openai)
poll_http = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
monkeypatch.setattr(adapter, "prepare_poll_context", AsyncMock(return_value=prepared_ctx))
monkeypatch.setattr(adapter, "poll_task_http", poll_http)
result = await adapter._poll_task_status(MagicMock(), task)
assert result.status == VideoStatus.PROCESSING
assert poll_gemini.await_count == 1
assert poll_openai.await_count == 0
poll_http.assert_awaited_once_with(prepared_ctx)
@pytest.mark.asyncio
@@ -129,8 +125,6 @@ async def test_video_poller_try_rust_payload_passes_proxy_snapshot(
async def test_video_poller_openai_poll_prefers_rust_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.services.task.video import poller_adapter as mod
adapter = VideoTaskPollerAdapter()
rust_poll = AsyncMock(return_value={"id": "vid_1", "status": "processing"})
@@ -140,9 +134,6 @@ async def test_video_poller_openai_poll_prefers_rust_payload(
normalizer = MagicMock(return_value=normalized)
monkeypatch.setattr(adapter._openai_normalizer, "video_poll_to_internal", normalizer)
get_upstream_client = AsyncMock(side_effect=AssertionError("python upstream client should not be used"))
monkeypatch.setattr(mod.HTTPClientPool, "get_upstream_client", get_upstream_client)
ctx = VideoPollContext(
task_id="task-1",
external_task_id="vid_1",
@@ -161,34 +152,17 @@ async def test_video_poller_openai_poll_prefers_rust_payload(
assert result is normalized
normalizer.assert_called_once_with({"id": "vid_1", "status": "processing"})
get_upstream_client.assert_not_awaited()
rust_poll.assert_awaited_once()
@pytest.mark.asyncio
async def test_video_poller_openai_poll_fallback_uses_transport_aware_client(
async def test_video_poller_openai_poll_requires_rust_executor_when_payload_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.services.task.video import poller_adapter as mod
adapter = VideoTaskPollerAdapter()
monkeypatch.setattr(adapter, "_try_rust_poll_payload", AsyncMock(return_value=None))
response = MagicMock()
response.status_code = 200
response.json.return_value = {"id": "vid_2", "status": "processing"}
client = MagicMock()
client.get = AsyncMock(return_value=response)
get_upstream_client = AsyncMock(return_value=client)
monkeypatch.setattr(mod.HTTPClientPool, "get_upstream_client", get_upstream_client)
normalized = InternalVideoPollResult(status=VideoStatus.PROCESSING, progress_percent=7)
normalizer = MagicMock(return_value=normalized)
monkeypatch.setattr(adapter._openai_normalizer, "video_poll_to_internal", normalizer)
ctx = VideoPollContext(
task_id="task-2",
external_task_id="vid_2",
@@ -205,14 +179,7 @@ async def test_video_poller_openai_poll_fallback_uses_transport_aware_client(
delegate_config={"node_id": "node-1", "tunnel": True},
)
result = await adapter._poll_openai_with_context(ctx)
with pytest.raises(Exception) as exc_info:
await adapter._poll_openai_with_context(ctx)
assert result is normalized
get_upstream_client.assert_awaited_once_with(
{"node_id": "node-1", "tunnel": True},
proxy_config={"enabled": True, "url": "http://proxy.test:8080"},
)
client.get.assert_awaited_once_with(
"https://api.openai.com/v1/videos/vid_2",
headers={"authorization": "Bearer test"},
)
assert "Rust executor" in str(exc_info.value)