mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(oauth): 账号封禁前置 OAuth 验证、抽取 provider_context、完善账号状态分类
- 新增 verify_oauth_before_account_block:在标记账号封禁前先尝试刷新 token, 区分 OAuth 过期与真正的账号级封禁,避免误标 - 抽取 provider_context.py 统一解析 provider_type,解决 ORM detached 访问问题 - account_state 新增 workspace_deactivated 分类和 auto-removable 状态集合, 补充中文验证关键词匹配 - OAuth refresh 成功后仅清除可恢复的 token 错误,不再自动清除账号级 block - deploy.sh 依赖指纹改用纯 shell 实现,移除对 Python tomllib 的依赖 - 前端 Pool 管理页面新增筛选和批量操作优化 - 补充对应测试用例
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.orchestration.error_handler import ErrorHandlerService
|
||||
|
||||
|
||||
@@ -31,11 +35,16 @@ def _build_key() -> SimpleNamespace:
|
||||
)
|
||||
|
||||
|
||||
def test_mark_oauth_key_blocked_auto_remove_enabled(monkeypatch: Any) -> None:
|
||||
def test_mark_oauth_key_blocked_auto_remove_enabled_skips_verification_state(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
service = ErrorHandlerService(db=cast(Any, db))
|
||||
key = _build_key()
|
||||
provider = SimpleNamespace(config={"pool_advanced": {"auto_remove_banned_keys": True}})
|
||||
provider = SimpleNamespace(
|
||||
provider_type="codex",
|
||||
config={"pool_advanced": {"auto_remove_banned_keys": True}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ErrorHandlerService,
|
||||
@@ -46,8 +55,8 @@ def test_mark_oauth_key_blocked_auto_remove_enabled(monkeypatch: Any) -> None:
|
||||
service._mark_oauth_key_blocked(cast(Any, key), "req-1", provider=cast(Any, provider))
|
||||
|
||||
assert db.commit_count == 1
|
||||
assert db.deleted == [key]
|
||||
assert key.is_active is False
|
||||
assert db.deleted == []
|
||||
assert key.is_active is True
|
||||
assert str(key.oauth_invalid_reason).startswith("[ACCOUNT_BLOCK] ")
|
||||
|
||||
|
||||
@@ -61,5 +70,62 @@ def test_mark_oauth_key_blocked_auto_remove_disabled() -> None:
|
||||
|
||||
assert db.commit_count == 1
|
||||
assert db.deleted == []
|
||||
assert key.is_active is False
|
||||
assert key.is_active is True
|
||||
assert str(key.oauth_invalid_reason).startswith("[ACCOUNT_BLOCK] ")
|
||||
|
||||
|
||||
def test_mark_oauth_key_blocked_auto_remove_enabled_for_deactivated_account(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
service = ErrorHandlerService(db=cast(Any, db))
|
||||
key = _build_key()
|
||||
provider = SimpleNamespace(
|
||||
provider_type="codex",
|
||||
config={"pool_advanced": {"auto_remove_banned_keys": True}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ErrorHandlerService,
|
||||
"_schedule_auto_cleanup_after_delete",
|
||||
staticmethod(lambda **kwargs: None),
|
||||
)
|
||||
|
||||
service._mark_oauth_key_blocked(
|
||||
cast(Any, key),
|
||||
"req-1",
|
||||
reason="account has been deactivated",
|
||||
provider=cast(Any, provider),
|
||||
)
|
||||
|
||||
assert db.commit_count == 1
|
||||
assert db.deleted == [key]
|
||||
assert key.is_active is True
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] account has been deactivated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_oauth_before_account_block_skips_when_refresh_marks_token_expired(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
service = ErrorHandlerService(db=cast(Any, db))
|
||||
key = _build_key()
|
||||
endpoint = SimpleNamespace()
|
||||
|
||||
fake_module = types.ModuleType("src.services.provider.auth")
|
||||
|
||||
async def _fake_get_provider_auth(*_args: Any, **_kwargs: Any) -> None:
|
||||
key.oauth_invalid_reason = "[OAUTH_EXPIRED] token expired"
|
||||
|
||||
fake_module.get_provider_auth = _fake_get_provider_auth
|
||||
monkeypatch.setitem(sys.modules, "src.services.provider.auth", fake_module)
|
||||
|
||||
should_mark = await service._verify_oauth_before_account_block(
|
||||
endpoint=cast(Any, endpoint),
|
||||
key=cast(Any, key),
|
||||
request_id="req-1",
|
||||
candidate_reason="Google 要求验证账号",
|
||||
)
|
||||
|
||||
assert should_mark is False
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.services.provider.pool.account_state import resolve_pool_account_state
|
||||
from src.services.provider.pool.account_state import (
|
||||
resolve_pool_account_state,
|
||||
should_auto_remove_account_state,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_from_kiro_banned_metadata() -> None:
|
||||
@@ -41,6 +44,18 @@ def test_resolve_from_structured_oauth_reason_verification() -> None:
|
||||
assert state.reason == "Google requires verification"
|
||||
|
||||
|
||||
def test_resolve_from_structured_oauth_reason_verification_chinese() -> None:
|
||||
state = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] Google 要求验证账号",
|
||||
)
|
||||
assert state.blocked is True
|
||||
assert state.code == "account_verification"
|
||||
assert state.label == "需要验证"
|
||||
assert state.reason == "Google 要求验证账号"
|
||||
|
||||
|
||||
def test_resolve_from_structured_oauth_reason_suspended() -> None:
|
||||
state = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
@@ -155,3 +170,25 @@ def test_request_failed_prefix_does_not_block() -> None:
|
||||
oauth_invalid_reason="[REQUEST_FAILED] Codex 账户访问受限 (403)",
|
||||
)
|
||||
assert state.blocked is False
|
||||
|
||||
|
||||
def test_auto_remove_state_excludes_token_expired_and_verification() -> None:
|
||||
expired = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[OAUTH_EXPIRED] token invalidated",
|
||||
)
|
||||
verification = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] Google 要求验证账号",
|
||||
)
|
||||
disabled = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] account has been deactivated",
|
||||
)
|
||||
|
||||
assert should_auto_remove_account_state(expired) is False
|
||||
assert should_auto_remove_account_state(verification) is False
|
||||
assert should_auto_remove_account_state(disabled) is True
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_select_probe_key_ids_selects_silent_keys_only() -> None:
|
||||
|
||||
keys = [
|
||||
_key("k1"), # never used, should be probed
|
||||
_key("k2", last_used_at=now - timedelta(minutes=2)), # recently used, skip
|
||||
_key("k2", last_used_at=now - timedelta(minutes=2)), # recently used,仍可定期探测
|
||||
_key(
|
||||
"k3",
|
||||
upstream_metadata={"codex": {"updated_at": now_ts - (20 * 60)}},
|
||||
@@ -40,10 +40,10 @@ def test_select_probe_key_ids_selects_silent_keys_only() -> None:
|
||||
last_probe_timestamps={},
|
||||
limit=0,
|
||||
)
|
||||
assert selected == ["k1", "k3"]
|
||||
assert selected == ["k1", "k2", "k3"]
|
||||
|
||||
|
||||
def test_select_probe_key_ids_resets_probe_window_after_key_usage() -> None:
|
||||
def test_select_probe_key_ids_keeps_periodic_probe_even_after_recent_usage() -> None:
|
||||
now = datetime(2026, 3, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
now_ts = int(now.timestamp())
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_select_probe_key_ids_resets_probe_window_after_key_usage() -> None:
|
||||
)
|
||||
]
|
||||
|
||||
# 上一次主动探测非常早,但 key 刚刚被真实流量使用,应跳过本次探测
|
||||
# 即使 key 刚刚被真实流量使用,只要上次额度刷新/主动探测已过窗口,仍应继续定期探测
|
||||
selected = _select_probe_key_ids(
|
||||
keys=keys, # type: ignore[arg-type]
|
||||
provider_type="codex",
|
||||
@@ -64,7 +64,7 @@ def test_select_probe_key_ids_resets_probe_window_after_key_usage() -> None:
|
||||
last_probe_timestamps={"k1": now_ts - (25 * 60)},
|
||||
limit=0,
|
||||
)
|
||||
assert selected == []
|
||||
assert selected == ["k1"]
|
||||
|
||||
|
||||
def test_select_probe_key_ids_applies_limit_by_oldest_anchor_first() -> None:
|
||||
@@ -72,9 +72,9 @@ def test_select_probe_key_ids_applies_limit_by_oldest_anchor_first() -> None:
|
||||
now_ts = int(now.timestamp())
|
||||
|
||||
keys = [
|
||||
_key("k1", last_used_at=now - timedelta(minutes=60)),
|
||||
_key("k2", last_used_at=now - timedelta(minutes=50)),
|
||||
_key("k3", last_used_at=now - timedelta(minutes=40)),
|
||||
_key("k1", upstream_metadata={"codex": {"updated_at": now_ts - (60 * 60)}}),
|
||||
_key("k2", upstream_metadata={"codex": {"updated_at": now_ts - (50 * 60)}}),
|
||||
_key("k3", upstream_metadata={"codex": {"updated_at": now_ts - (40 * 60)}}),
|
||||
]
|
||||
|
||||
selected = _select_probe_key_ids(
|
||||
|
||||
@@ -83,6 +83,20 @@ def test_account_state_takes_priority_over_manual_disabled() -> None:
|
||||
assert summary.reason == "account_forbidden"
|
||||
|
||||
|
||||
def test_workspace_deactivated_uses_specific_reason_code() -> None:
|
||||
dimensions = evaluate_pool_scheduling_dimensions(
|
||||
_snapshot(
|
||||
account_blocked=True,
|
||||
account_block_label="工作区停用",
|
||||
account_block_reason="deactivated_workspace",
|
||||
)
|
||||
)
|
||||
summary = summarize_pool_scheduling_dimensions(dimensions)
|
||||
|
||||
assert summary.status == "blocked"
|
||||
assert summary.reason == "workspace_deactivated"
|
||||
|
||||
|
||||
def test_summary_degraded_when_cost_reaches_soft_threshold() -> None:
|
||||
dimensions = evaluate_pool_scheduling_dimensions(
|
||||
_snapshot(cost_window_usage=8200, cost_limit=10000, cost_soft_threshold_percent=80)
|
||||
|
||||
@@ -47,9 +47,7 @@ class _FakeSessionCtx:
|
||||
return False
|
||||
|
||||
|
||||
def _install_module(
|
||||
monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str, Any]
|
||||
) -> None:
|
||||
def _install_module(monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str, Any]) -> None:
|
||||
fake_module = types.ModuleType(name)
|
||||
for key, value in attrs.items():
|
||||
setattr(fake_module, key, value)
|
||||
@@ -110,9 +108,7 @@ def test_mark_refresh_token_invalid_persists_detached_key(
|
||||
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(
|
||||
"[REFRESH_FAILED] Token 续期失败 (401)"
|
||||
)
|
||||
assert str(key.oauth_invalid_reason).startswith("[REFRESH_FAILED] Token 续期失败 (401)")
|
||||
assert "refresh_token_reused" in str(row.oauth_invalid_reason)
|
||||
|
||||
|
||||
@@ -153,6 +149,30 @@ def test_persist_refreshed_token_clears_legacy_token_invalidated_account_block(
|
||||
assert key.oauth_invalid_reason is None
|
||||
|
||||
|
||||
def test_persist_refreshed_token_preserves_true_account_block(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key = SimpleNamespace(
|
||||
id="key-1",
|
||||
api_key="old-api",
|
||||
auth_config="old-config",
|
||||
oauth_invalid_at=datetime.now(timezone.utc),
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] Google requires verification",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
module, "object_session", lambda _key: (_ for _ in ()).throw(RuntimeError())
|
||||
)
|
||||
monkeypatch.setattr(module.crypto_service, "encrypt", lambda value: f"enc:{value}")
|
||||
|
||||
module._persist_refreshed_token(key, "new-token", {"refresh_token": "rt-2"})
|
||||
|
||||
assert key.api_key == "enc:new-token"
|
||||
assert key.auth_config == 'enc:{"refresh_token": "rt-2"}'
|
||||
assert key.oauth_invalid_at is not None
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] Google requires verification"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_generic_oauth_token_persists_enriched_account_name(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -9,6 +11,32 @@ from src.core.vertex_auth import VertexAuthService
|
||||
from src.services.provider.auth import get_provider_auth
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeSessionCtx:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def __enter__(self) -> "_FakeSessionCtx":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
_ = exc_type, exc, tb
|
||||
return False
|
||||
|
||||
def query(self, _model: object) -> _FakeQuery:
|
||||
return _FakeQuery(self._row)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_provider_auth_vertex_service_account_uses_provider_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -124,3 +152,76 @@ async def test_get_provider_auth_vertex_service_account_prefers_key_proxy(
|
||||
|
||||
assert auth is not None
|
||||
assert captured["proxy_config"] == key_proxy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_provider_auth_vertex_service_account_uses_provider_id_lookup_without_touching_endpoint_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sa_json = {
|
||||
"client_email": "svc@example.iam.gserviceaccount.com",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nTEST\n-----END PRIVATE KEY-----\n",
|
||||
"project_id": "demo-project",
|
||||
}
|
||||
provider_proxy = {"node_id": "provider-node", "enabled": True}
|
||||
|
||||
class _DetachedEndpoint:
|
||||
provider_id = "provider-1"
|
||||
|
||||
@property
|
||||
def provider(self) -> object:
|
||||
raise RuntimeError("detached endpoint provider should not be lazy-loaded")
|
||||
|
||||
fake_provider = SimpleNamespace(
|
||||
id="provider-1", provider_type="vertex_ai", proxy=provider_proxy
|
||||
)
|
||||
fake_database = types.ModuleType("src.database")
|
||||
fake_database.create_session = lambda: _FakeSessionCtx(fake_provider)
|
||||
fake_models = types.ModuleType("src.models.database")
|
||||
fake_models.Provider = type("Provider", (), {"id": "id"})
|
||||
|
||||
monkeypatch.setitem(sys.modules, "src.database", fake_database)
|
||||
monkeypatch.setitem(sys.modules, "src.models.database", fake_models)
|
||||
monkeypatch.setattr(
|
||||
"src.core.crypto.crypto_service.decrypt",
|
||||
lambda value: json.dumps(sa_json) if value == "enc_cfg" else "",
|
||||
)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_build_proxy_client_kwargs(
|
||||
proxy_config: dict[str, object] | None = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
**_: object,
|
||||
) -> dict[str, object]:
|
||||
captured["proxy_config"] = proxy_config
|
||||
return {"timeout": timeout}
|
||||
|
||||
async def _fake_get_access_token(
|
||||
self: VertexAuthService,
|
||||
*,
|
||||
httpx_client_kwargs: dict[str, object] | None = None,
|
||||
) -> str:
|
||||
captured["httpx_client_kwargs"] = httpx_client_kwargs
|
||||
return "ya29.test-token"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.build_proxy_client_kwargs",
|
||||
_fake_build_proxy_client_kwargs,
|
||||
)
|
||||
monkeypatch.setattr(VertexAuthService, "get_access_token", _fake_get_access_token)
|
||||
|
||||
key = SimpleNamespace(
|
||||
auth_type="service_account",
|
||||
auth_config="enc_cfg",
|
||||
api_key="enc_key",
|
||||
provider_id="provider-1",
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
auth = await get_provider_auth(_DetachedEndpoint(), key) # type: ignore[arg-type]
|
||||
|
||||
assert auth is not None
|
||||
assert captured["proxy_config"] == provider_proxy
|
||||
assert captured["httpx_client_kwargs"] == {"timeout": 30}
|
||||
|
||||
@@ -244,10 +244,10 @@ def test_clear_oauth_invalid_response_invalidates_caches(
|
||||
|
||||
result = command_module.clear_oauth_invalid_response(cast(Any, db), key_id="key-1")
|
||||
|
||||
assert result["message"] == "已清除 OAuth 失效标记,Key 已自动启用"
|
||||
assert result["message"] == "已清除 OAuth 失效标记"
|
||||
assert key.oauth_invalid_at is None
|
||||
assert key.oauth_invalid_reason is None
|
||||
assert key.is_active is True
|
||||
assert key.is_active is False
|
||||
assert db.commit_count == 1
|
||||
assert cache_calls == [("key", "key-1"), ("models", None)]
|
||||
|
||||
|
||||
@@ -771,10 +771,10 @@ async def test_antigravity_refresher_forbidden_collects_updates_without_commit(
|
||||
)
|
||||
|
||||
assert result["status"] == "forbidden"
|
||||
assert result["auto_disabled"] is True
|
||||
assert result["auto_disabled"] is False
|
||||
assert key.is_active is True
|
||||
assert key.oauth_invalid_reason is None
|
||||
assert state_updates["k1"]["is_active"] is False
|
||||
assert "is_active" not in state_updates["k1"]
|
||||
assert state_updates["k1"]["oauth_invalid_reason"].startswith("账户访问被禁止")
|
||||
assert metadata_updates["k1"]["antigravity"]["is_forbidden"] is True
|
||||
assert db.commit_count == 0
|
||||
@@ -911,7 +911,7 @@ async def test_kiro_refresher_runtime_401_marks_key_invalid(
|
||||
assert "401" in result["message"]
|
||||
assert key.is_active is True
|
||||
assert key.oauth_invalid_reason is None
|
||||
assert state_updates["k1"]["is_active"] is False
|
||||
assert "is_active" not in state_updates["k1"]
|
||||
assert state_updates["k1"]["oauth_invalid_reason"] == "Kiro Token 无效或已过期"
|
||||
assert db.commit_count == 0
|
||||
|
||||
|
||||
@@ -414,3 +414,53 @@ async def test_refresh_provider_quota_auto_removes_banned_keys_when_enabled(
|
||||
assert result["results"][0]["auto_removed"] is True
|
||||
assert deleted_side_effect_calls == [("p1", ["gpt-4o"])]
|
||||
assert redis_cleared == [("p1", "k1"), ("p1", "k1")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_provider_quota_does_not_auto_remove_oauth_expired_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
provider = SimpleNamespace(
|
||||
id="p1",
|
||||
provider_type=ProviderType.CODEX,
|
||||
endpoints=[SimpleNamespace(api_format="openai:cli", is_active=True)],
|
||||
config={"pool_advanced": {"auto_remove_banned_keys": True}},
|
||||
)
|
||||
key = SimpleNamespace(
|
||||
id="k1",
|
||||
name="K1",
|
||||
provider_id="p1",
|
||||
allowed_models=None,
|
||||
upstream_metadata={},
|
||||
is_active=True,
|
||||
oauth_invalid_at=None,
|
||||
oauth_invalid_reason=None,
|
||||
)
|
||||
db = _FakeDB(provider=provider, keys=[key])
|
||||
|
||||
async def _fake_handler(**kwargs: Any) -> dict[str, Any]:
|
||||
state_updates = kwargs["state_updates"]
|
||||
state_updates["k1"] = {
|
||||
"oauth_invalid_at": "expired-at",
|
||||
"oauth_invalid_reason": "[OAUTH_EXPIRED] token invalidated",
|
||||
}
|
||||
return {"key_id": "k1", "key_name": "K1", "status": "error", "message": "expired"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
quota_service_module,
|
||||
"_select_refresh_endpoint",
|
||||
lambda provider, provider_type: provider.endpoints[0],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service_module, "_resolve_quota_refresh_handler", lambda _: _fake_handler
|
||||
)
|
||||
|
||||
result = await refresh_provider_quota_for_provider(
|
||||
db=cast(Any, db),
|
||||
provider_id="p1",
|
||||
codex_wham_usage_url="https://example.test/wham/usage",
|
||||
)
|
||||
|
||||
assert result["auto_removed"] == 0
|
||||
assert db.deleted == []
|
||||
assert key.oauth_invalid_reason == "[OAUTH_EXPIRED] token invalidated"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -16,6 +18,33 @@ class _DummyEndpoint:
|
||||
api_format: str
|
||||
custom_path: str | None = None
|
||||
provider: object | None = None
|
||||
provider_id: str | None = None
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeSessionCtx:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def __enter__(self) -> "_FakeSessionCtx":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
_ = exc_type, exc, tb
|
||||
return False
|
||||
|
||||
def query(self, _model: object) -> _FakeQuery:
|
||||
return _FakeQuery(self._row)
|
||||
|
||||
|
||||
def test_codex_openai_cli_uses_responses_path_without_v1_prefix() -> None:
|
||||
@@ -56,14 +85,16 @@ def test_codex_openai_cli_uses_compact_suffix_when_context_marked_compact() -> N
|
||||
api_format="openai:cli",
|
||||
provider=SimpleNamespace(provider_type="codex"),
|
||||
)
|
||||
set_codex_request_context(CodexRequestContext(is_compact=True))
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=False,
|
||||
)
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
set_codex_request_context(None)
|
||||
try:
|
||||
set_codex_request_context(CodexRequestContext(is_compact=True))
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=False,
|
||||
)
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
finally:
|
||||
set_codex_request_context(None)
|
||||
|
||||
|
||||
def test_codex_openai_compact_uses_compact_path_without_v1_prefix() -> None:
|
||||
@@ -78,3 +109,34 @@ def test_codex_openai_compact_uses_compact_path_without_v1_prefix() -> None:
|
||||
is_stream=False,
|
||||
)
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
|
||||
|
||||
def test_codex_openai_cli_uses_provider_id_lookup_without_touching_endpoint_provider(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
class _DetachedEndpoint:
|
||||
base_url = "https://chatgpt.com/backend-api/codex"
|
||||
api_format = "openai:cli"
|
||||
custom_path = None
|
||||
provider_id = "provider-1"
|
||||
|
||||
@property
|
||||
def provider(self) -> object:
|
||||
raise RuntimeError("detached endpoint provider should not be lazy-loaded")
|
||||
|
||||
fake_provider = SimpleNamespace(id="provider-1", provider_type="codex", proxy=None)
|
||||
fake_database = types.ModuleType("src.database")
|
||||
fake_database.create_session = lambda: _FakeSessionCtx(fake_provider)
|
||||
fake_models = types.ModuleType("src.models.database")
|
||||
fake_models.Provider = type("Provider", (), {"id": "id"})
|
||||
|
||||
monkeypatch.setitem(sys.modules, "src.database", fake_database)
|
||||
monkeypatch.setitem(sys.modules, "src.models.database", fake_models)
|
||||
|
||||
url = build_provider_url(
|
||||
_DetachedEndpoint(), # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=True,
|
||||
)
|
||||
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses"
|
||||
|
||||
@@ -192,8 +192,8 @@ def test_resolve_pool_account_state_keeps_codex_metadata_block() -> None:
|
||||
)
|
||||
|
||||
assert state.blocked is True
|
||||
assert state.code == "account_forbidden"
|
||||
assert state.label == "访问受限"
|
||||
assert state.code == "workspace_deactivated"
|
||||
assert state.label == "工作区停用"
|
||||
assert state.reason == "deactivated_workspace"
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -19,6 +21,33 @@ class _DummyEndpoint:
|
||||
api_format: str
|
||||
config: dict | None = None
|
||||
provider: object | None = None
|
||||
provider_id: str | None = None
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeSessionCtx:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def __enter__(self) -> "_FakeSessionCtx":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
_ = exc_type, exc, tb
|
||||
return False
|
||||
|
||||
def query(self, _model: object) -> _FakeQuery:
|
||||
return _FakeQuery(self._row)
|
||||
|
||||
|
||||
def test_get_upstream_stream_policy_defaults_to_auto() -> None:
|
||||
@@ -59,6 +88,24 @@ def test_get_upstream_stream_policy_codex_compact_forces_non_stream() -> None:
|
||||
set_codex_request_context(None)
|
||||
|
||||
|
||||
def test_get_upstream_stream_policy_uses_explicit_provider_type_without_touching_endpoint_provider() -> (
|
||||
None
|
||||
):
|
||||
class _DetachedEndpoint:
|
||||
api_format = "openai:cli"
|
||||
config = None
|
||||
|
||||
@property
|
||||
def provider(self) -> object:
|
||||
raise RuntimeError("detached endpoint provider should not be lazy-loaded")
|
||||
|
||||
ep = _DetachedEndpoint()
|
||||
|
||||
assert (
|
||||
get_upstream_stream_policy(ep, provider_type="codex") == UpstreamStreamPolicy.FORCE_STREAM
|
||||
)
|
||||
|
||||
|
||||
def test_get_upstream_stream_policy_codex_openai_compact_defaults_to_auto() -> None:
|
||||
ep = _DummyEndpoint(
|
||||
api_format="openai:compact",
|
||||
@@ -68,6 +115,30 @@ def test_get_upstream_stream_policy_codex_openai_compact_defaults_to_auto() -> N
|
||||
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.AUTO
|
||||
|
||||
|
||||
def test_get_upstream_stream_policy_uses_provider_id_lookup_without_touching_endpoint_provider(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
class _DetachedEndpoint:
|
||||
api_format = "openai:cli"
|
||||
config = None
|
||||
provider_id = "provider-1"
|
||||
|
||||
@property
|
||||
def provider(self) -> object:
|
||||
raise RuntimeError("detached endpoint provider should not be lazy-loaded")
|
||||
|
||||
fake_provider = SimpleNamespace(id="provider-1", provider_type="codex", proxy=None)
|
||||
fake_database = types.ModuleType("src.database")
|
||||
fake_database.create_session = lambda: _FakeSessionCtx(fake_provider)
|
||||
fake_models = types.ModuleType("src.models.database")
|
||||
fake_models.Provider = type("Provider", (), {"id": "id"})
|
||||
|
||||
monkeypatch.setitem(sys.modules, "src.database", fake_database)
|
||||
monkeypatch.setitem(sys.modules, "src.models.database", fake_models)
|
||||
|
||||
assert get_upstream_stream_policy(_DetachedEndpoint()) == UpstreamStreamPolicy.FORCE_STREAM
|
||||
|
||||
|
||||
def test_enforce_stream_mode_for_upstream_openai_chat_sets_stream_options_usage() -> None:
|
||||
body = {"stream": False}
|
||||
out = enforce_stream_mode_for_upstream(
|
||||
|
||||
108
tests/unit/test_pool_management_filters.py
Normal file
108
tests/unit/test_pool_management_filters.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from src.api.admin.pool.routes import _detail_is_oauth_invalid, _filter_pool_key_details
|
||||
from src.api.admin.pool.schemas import PoolKeyDetail
|
||||
|
||||
|
||||
def _detail(
|
||||
key_id: str,
|
||||
*,
|
||||
is_active: bool = True,
|
||||
scheduling_status: str = "available",
|
||||
account_status_blocked: bool = False,
|
||||
account_status_code: str | None = None,
|
||||
account_status_label: str | None = None,
|
||||
account_status_reason: str | None = None,
|
||||
auth_type: str = "api_key",
|
||||
oauth_invalid_at: int | None = None,
|
||||
oauth_invalid_reason: str | None = None,
|
||||
oauth_expires_at: int | None = None,
|
||||
cooldown_reason: str | None = None,
|
||||
circuit_breaker_open: bool = False,
|
||||
cost_limit: int | None = None,
|
||||
cost_window_usage: int = 0,
|
||||
) -> PoolKeyDetail:
|
||||
return PoolKeyDetail(
|
||||
key_id=key_id,
|
||||
key_name=key_id,
|
||||
is_active=is_active,
|
||||
auth_type=auth_type,
|
||||
oauth_invalid_at=oauth_invalid_at,
|
||||
oauth_invalid_reason=oauth_invalid_reason,
|
||||
oauth_expires_at=oauth_expires_at,
|
||||
scheduling_status=scheduling_status,
|
||||
scheduling_reason=scheduling_status or "available",
|
||||
scheduling_label=scheduling_status or "available",
|
||||
account_status_blocked=account_status_blocked,
|
||||
account_status_code=account_status_code,
|
||||
account_status_label=account_status_label,
|
||||
account_status_reason=account_status_reason,
|
||||
cooldown_reason=cooldown_reason,
|
||||
circuit_breaker_open=circuit_breaker_open,
|
||||
cost_limit=cost_limit,
|
||||
cost_window_usage=cost_window_usage,
|
||||
)
|
||||
|
||||
|
||||
def test_filter_pool_key_details_require_schedulable_keeps_available_and_degraded() -> None:
|
||||
details = [
|
||||
_detail("available", scheduling_status="available"),
|
||||
_detail("degraded", scheduling_status="degraded"),
|
||||
_detail("blocked", scheduling_status="blocked", account_status_blocked=True),
|
||||
]
|
||||
|
||||
filtered = _filter_pool_key_details(details, require_schedulable=True)
|
||||
|
||||
assert [item.key_id for item in filtered] == ["available", "degraded"]
|
||||
|
||||
|
||||
def test_filter_pool_key_details_require_schedulable_uses_fallback_when_status_missing() -> None:
|
||||
details = [
|
||||
_detail("manual-disabled", scheduling_status="", is_active=False),
|
||||
_detail("cooldown", scheduling_status="", cooldown_reason="rate_limited_429"),
|
||||
_detail("usable", scheduling_status="", is_active=True),
|
||||
]
|
||||
|
||||
filtered = _filter_pool_key_details(details, require_schedulable=True)
|
||||
|
||||
assert [item.key_id for item in filtered] == ["usable"]
|
||||
|
||||
|
||||
def test_detail_is_oauth_invalid_excludes_account_disabled_state() -> None:
|
||||
detail = _detail(
|
||||
"disabled-account",
|
||||
auth_type="oauth",
|
||||
oauth_invalid_at=1,
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] account has been deactivated",
|
||||
account_status_blocked=True,
|
||||
account_status_code="account_disabled",
|
||||
account_status_label="账号停用",
|
||||
)
|
||||
|
||||
assert _detail_is_oauth_invalid(detail) is False
|
||||
|
||||
|
||||
def test_detail_is_oauth_invalid_accepts_token_expired_state() -> None:
|
||||
detail = _detail(
|
||||
"expired-token",
|
||||
auth_type="oauth",
|
||||
oauth_invalid_at=1,
|
||||
oauth_invalid_reason="[OAUTH_EXPIRED] token invalidated",
|
||||
account_status_blocked=True,
|
||||
account_status_code="oauth_expired",
|
||||
account_status_label="Token 失效",
|
||||
)
|
||||
|
||||
assert _detail_is_oauth_invalid(detail) is True
|
||||
|
||||
|
||||
def test_detail_is_oauth_invalid_accepts_refresh_failed_state() -> None:
|
||||
detail = _detail(
|
||||
"refresh-failed",
|
||||
auth_type="oauth",
|
||||
oauth_invalid_at=1,
|
||||
oauth_invalid_reason="[REFRESH_FAILED] refresh_token_reused",
|
||||
account_status_blocked=False,
|
||||
account_status_code="oauth_refresh_failed",
|
||||
account_status_label="续期失败",
|
||||
)
|
||||
|
||||
assert _detail_is_oauth_invalid(detail) is True
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -84,6 +86,30 @@ def _make_oauth_key(*, key_id: str, name: str, auth_config: dict[str, object]) -
|
||||
)
|
||||
|
||||
|
||||
class _SingleKeyQuery:
|
||||
def __init__(self, key: SimpleNamespace | None) -> None:
|
||||
self._key = key
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_SingleKeyQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> SimpleNamespace | None:
|
||||
return self._key
|
||||
|
||||
|
||||
class _SingleKeyDB:
|
||||
def __init__(self, key: SimpleNamespace | None) -> None:
|
||||
self._key = key
|
||||
|
||||
def query(self, _model: object) -> _SingleKeyQuery:
|
||||
return _SingleKeyQuery(self._key)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _fake_db_context(db: _SingleKeyDB):
|
||||
yield db
|
||||
|
||||
|
||||
def test_check_duplicate_oauth_account_codex_allows_same_user_different_account_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -151,3 +177,87 @@ def test_check_duplicate_oauth_account_codex_rejects_same_account_user_identity(
|
||||
"plan_type": "team",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_mark_refresh_failed_sync_preserves_existing_account_block(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key = SimpleNamespace(
|
||||
id="key-1",
|
||||
oauth_invalid_at="old-invalid-at",
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] account has been deactivated",
|
||||
)
|
||||
db = _SingleKeyDB(key)
|
||||
monkeypatch.setattr(module, "get_db_context", lambda: _fake_db_context(db))
|
||||
|
||||
module._mark_refresh_failed_sync(
|
||||
"key-1",
|
||||
"[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused",
|
||||
)
|
||||
|
||||
assert key.oauth_invalid_at == "old-invalid-at"
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] account has been deactivated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_account_state_after_oauth_update_refreshes_supported_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_db = SimpleNamespace(close=MagicMock())
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _fake_refresh_provider_quota_for_provider(**kwargs: object) -> dict[str, object]:
|
||||
captured.update(kwargs)
|
||||
return {"success": 1}
|
||||
|
||||
monkeypatch.setattr(module, "create_session", lambda: fake_db)
|
||||
|
||||
from src.services.provider_keys import key_quota_service as quota_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
quota_module,
|
||||
"refresh_provider_quota_for_provider",
|
||||
_fake_refresh_provider_quota_for_provider,
|
||||
)
|
||||
|
||||
attempted, error = await module._refresh_account_state_after_oauth_update(
|
||||
provider_id="provider-1",
|
||||
provider_type="codex",
|
||||
key_ids=["key-1"],
|
||||
)
|
||||
|
||||
assert attempted is True
|
||||
assert error is None
|
||||
assert captured["provider_id"] == "provider-1"
|
||||
assert captured["key_ids"] == ["key-1"]
|
||||
fake_db.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_account_state_after_oauth_update_returns_error_when_refresh_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_db = SimpleNamespace(close=MagicMock())
|
||||
|
||||
async def _fake_refresh_provider_quota_for_provider(**_kwargs: object) -> dict[str, object]:
|
||||
raise RuntimeError("quota refresh failed")
|
||||
|
||||
monkeypatch.setattr(module, "create_session", lambda: fake_db)
|
||||
|
||||
from src.services.provider_keys import key_quota_service as quota_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
quota_module,
|
||||
"refresh_provider_quota_for_provider",
|
||||
_fake_refresh_provider_quota_for_provider,
|
||||
)
|
||||
|
||||
attempted, error = await module._refresh_account_state_after_oauth_update(
|
||||
provider_id="provider-1",
|
||||
provider_type="codex",
|
||||
key_ids=["key-1"],
|
||||
)
|
||||
|
||||
assert attempted is True
|
||||
assert "quota refresh failed" in error
|
||||
fake_db.close.assert_called_once()
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.api.admin.provider_query import TestModelFailoverRequest as FailoverRequestModel
|
||||
from src.api.admin import provider_query as provider_query_module
|
||||
from src.api.admin.provider_query import (
|
||||
DEFAULT_MODEL_TEST_MESSAGE,
|
||||
)
|
||||
from src.api.admin.provider_query import TestModelFailoverRequest as FailoverRequestModel
|
||||
from src.api.admin.provider_query import (
|
||||
_build_direct_test_candidates,
|
||||
_build_test_attempts_from_candidate_keys,
|
||||
_filter_test_candidates_by_endpoint,
|
||||
_flatten_test_candidates_for_concurrency,
|
||||
_maybe_mark_test_oauth_key_invalid,
|
||||
_require_test_endpoint_base_url,
|
||||
_resolve_test_message,
|
||||
_resolve_test_effective_model,
|
||||
_resolve_test_message,
|
||||
)
|
||||
from src.services.scheduling.schemas import PoolCandidate
|
||||
|
||||
@@ -176,3 +181,68 @@ def test_require_test_endpoint_base_url_trims_whitespace() -> None:
|
||||
)
|
||||
|
||||
assert _require_test_endpoint_base_url(endpoint) == "https://api.anthropic.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maybe_mark_test_oauth_key_invalid_skips_account_block_when_oauth_check_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key = SimpleNamespace(id="key-1", oauth_invalid_at=None, oauth_invalid_reason=None)
|
||||
endpoint = SimpleNamespace(api_format="openai:chat")
|
||||
db = MagicMock()
|
||||
|
||||
async def _fake_verify(**_: object) -> bool:
|
||||
key.oauth_invalid_reason = "[OAUTH_EXPIRED] refresh token expired"
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(provider_query_module, "verify_oauth_before_account_block", _fake_verify)
|
||||
|
||||
await _maybe_mark_test_oauth_key_invalid(
|
||||
db=db,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
auth_type="oauth",
|
||||
error_payload={
|
||||
"error": {
|
||||
"code": 403,
|
||||
"message": "Please verify your account",
|
||||
"status": "PERMISSION_DENIED",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert key.oauth_invalid_at is None
|
||||
assert key.oauth_invalid_reason == "[OAUTH_EXPIRED] refresh token expired"
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maybe_mark_test_oauth_key_invalid_marks_account_block_after_oauth_check(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key = SimpleNamespace(id="key-2", oauth_invalid_at=None, oauth_invalid_reason=None)
|
||||
endpoint = SimpleNamespace(api_format="openai:chat")
|
||||
db = MagicMock()
|
||||
|
||||
async def _fake_verify(**_: object) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(provider_query_module, "verify_oauth_before_account_block", _fake_verify)
|
||||
|
||||
await _maybe_mark_test_oauth_key_invalid(
|
||||
db=db,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
auth_type="oauth",
|
||||
error_payload={
|
||||
"error": {
|
||||
"code": 403,
|
||||
"message": "verify your account",
|
||||
"status": "PERMISSION_DENIED",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert key.oauth_invalid_at is not None
|
||||
assert str(key.oauth_invalid_reason).startswith("[ACCOUNT_BLOCK] ")
|
||||
db.commit.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user