mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 引入 status_snapshot 统一 provider key 状态管理
- 新增 StatusSnapshot 模型,聚合 OAuth / 账号 / 配额三维状态 - 新增 StatusSnapshotStore 负责快照的持久化与查询 - 重构 response_builder / endpoint_models,基于 snapshot 输出状态字段 - 前端抽取 providerKeyStatus / oauthRefreshFeedback 工具函数, 统一 PoolManagement、ProviderDetailDrawer、BatchDialog 的状态展示 - errorParser 增加已知 OAuth 错误的友好提示 - refresher 适配 snapshot 写入,account_state 扩展状态分类 - 新增 alembic 迁移及存量数据回填脚本 - 补充前后端单元测试
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.services.provider.pool.account_state import (
|
||||
build_provider_key_status_snapshot,
|
||||
resolve_pool_account_state,
|
||||
should_auto_remove_account_state,
|
||||
)
|
||||
@@ -192,3 +193,68 @@ def test_auto_remove_state_excludes_token_expired_and_verification() -> None:
|
||||
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
|
||||
|
||||
|
||||
def test_build_provider_key_status_snapshot_separates_account_block_from_oauth_state() -> None:
|
||||
snapshot = build_provider_key_status_snapshot(
|
||||
auth_type="oauth",
|
||||
oauth_expires_at=2_000_000_000,
|
||||
oauth_invalid_at=1_900_000_000,
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)",
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
now_ts=1_800_000_000,
|
||||
)
|
||||
|
||||
assert snapshot.account.blocked is True
|
||||
assert snapshot.account.code == "workspace_deactivated"
|
||||
assert snapshot.account.label == "工作区停用"
|
||||
assert snapshot.oauth.code == "valid"
|
||||
assert snapshot.oauth.requires_reauth is False
|
||||
|
||||
|
||||
def test_build_provider_key_status_snapshot_keeps_refresh_failure_visible_beside_account_block() -> (
|
||||
None
|
||||
):
|
||||
snapshot = build_provider_key_status_snapshot(
|
||||
auth_type="oauth",
|
||||
oauth_expires_at=2_000_000_000,
|
||||
oauth_invalid_at=1_900_000_000,
|
||||
oauth_invalid_reason=(
|
||||
"[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n"
|
||||
"[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||
),
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
now_ts=1_800_000_000,
|
||||
)
|
||||
|
||||
assert snapshot.account.blocked is True
|
||||
assert snapshot.account.code == "workspace_deactivated"
|
||||
assert snapshot.oauth.code == "invalid"
|
||||
assert snapshot.oauth.label == "已失效"
|
||||
assert snapshot.oauth.reason == "Token 续期失败 (400): refresh_token_reused"
|
||||
assert snapshot.oauth.requires_reauth is True
|
||||
|
||||
|
||||
def test_build_provider_key_status_snapshot_marks_quota_exhausted() -> None:
|
||||
snapshot = build_provider_key_status_snapshot(
|
||||
auth_type="oauth",
|
||||
oauth_expires_at=2_000_000_000,
|
||||
oauth_invalid_at=None,
|
||||
oauth_invalid_reason=None,
|
||||
provider_type="codex",
|
||||
upstream_metadata={
|
||||
"codex": {
|
||||
"primary_used_percent": 100.0,
|
||||
"secondary_used_percent": 20.0,
|
||||
"updated_at": 1_800_000_000,
|
||||
"plan_type": "team",
|
||||
}
|
||||
},
|
||||
now_ts=1_800_000_000,
|
||||
)
|
||||
|
||||
assert snapshot.quota.code == "exhausted"
|
||||
assert snapshot.quota.exhausted is True
|
||||
assert snapshot.quota.reason == "Codex 周限额剩余 0%"
|
||||
|
||||
@@ -263,6 +263,141 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
|
||||
assert state_updates["k1"]["oauth_invalid_reason"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_refresher_success_preserves_refresh_failed_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.provider_keys.quota_refresh import codex_refresher as module
|
||||
|
||||
key = SimpleNamespace(
|
||||
id="k1",
|
||||
name="K1",
|
||||
api_key="enc-key",
|
||||
auth_type="oauth",
|
||||
auth_config="enc-config",
|
||||
proxy=None,
|
||||
oauth_invalid_at="sentinel-invalid-at",
|
||||
oauth_invalid_reason="[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused",
|
||||
)
|
||||
provider = SimpleNamespace(proxy=None)
|
||||
endpoint = SimpleNamespace()
|
||||
metadata_updates: dict[str, dict[str, Any]] = {}
|
||||
state_updates: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
|
||||
return None
|
||||
|
||||
_install_module(
|
||||
monkeypatch,
|
||||
"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 value: (
|
||||
"sk-test"
|
||||
if value == "enc-key"
|
||||
else json.dumps({"plan_type": "team", "account_id": "acc-1"})
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
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)
|
||||
)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
provider=cast(Any, provider),
|
||||
key=cast(Any, key),
|
||||
endpoint=cast(Any, endpoint),
|
||||
codex_wham_usage_url="https://example.test",
|
||||
metadata_updates=metadata_updates,
|
||||
state_updates=state_updates,
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert state_updates["k1"]["oauth_invalid_at"] == "sentinel-invalid-at"
|
||||
assert (
|
||||
state_updates["k1"]["oauth_invalid_reason"]
|
||||
== "[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_refresher_quota_exhausted_preserves_refresh_failed_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.provider_keys.quota_refresh import codex_refresher as module
|
||||
|
||||
key = SimpleNamespace(
|
||||
id="k1",
|
||||
name="K1",
|
||||
api_key="enc-key",
|
||||
auth_type="oauth",
|
||||
auth_config="enc-config",
|
||||
proxy=None,
|
||||
oauth_invalid_at="sentinel-invalid-at",
|
||||
oauth_invalid_reason="[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused",
|
||||
)
|
||||
provider = SimpleNamespace(proxy=None)
|
||||
endpoint = SimpleNamespace()
|
||||
metadata_updates: dict[str, dict[str, Any]] = {}
|
||||
state_updates: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
|
||||
return None
|
||||
|
||||
_install_module(
|
||||
monkeypatch,
|
||||
"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 value: (
|
||||
"sk-test"
|
||||
if value == "enc-key"
|
||||
else json.dumps({"plan_type": "team", "account_id": "acc-1"})
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
provider=cast(Any, provider),
|
||||
key=cast(Any, key),
|
||||
endpoint=cast(Any, endpoint),
|
||||
codex_wham_usage_url="https://example.test",
|
||||
metadata_updates=metadata_updates,
|
||||
state_updates=state_updates,
|
||||
)
|
||||
|
||||
assert result["status"] == "quota_exhausted"
|
||||
assert state_updates["k1"]["oauth_invalid_at"] == "sentinel-invalid-at"
|
||||
assert (
|
||||
state_updates["k1"]["oauth_invalid_reason"]
|
||||
== "[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||
)
|
||||
codex_meta = metadata_updates["k1"]["codex"]
|
||||
assert codex_meta["secondary_used_percent"] == 100.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -19,6 +19,7 @@ def _detail(
|
||||
circuit_breaker_open: bool = False,
|
||||
cost_limit: int | None = None,
|
||||
cost_window_usage: int = 0,
|
||||
status_snapshot: dict | None = None,
|
||||
) -> PoolKeyDetail:
|
||||
return PoolKeyDetail(
|
||||
key_id=key_id,
|
||||
@@ -39,6 +40,12 @@ def _detail(
|
||||
circuit_breaker_open=circuit_breaker_open,
|
||||
cost_limit=cost_limit,
|
||||
cost_window_usage=cost_window_usage,
|
||||
status_snapshot=status_snapshot # type: ignore[arg-type]
|
||||
or {
|
||||
"oauth": {"code": "none"},
|
||||
"account": {"code": "ok", "blocked": False},
|
||||
"quota": {"code": "unknown", "exhausted": False},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -106,3 +113,58 @@ def test_detail_is_oauth_invalid_accepts_refresh_failed_state() -> None:
|
||||
)
|
||||
|
||||
assert _detail_is_oauth_invalid(detail) is True
|
||||
|
||||
|
||||
def test_detail_is_oauth_invalid_uses_status_snapshot_without_legacy_fields() -> None:
|
||||
detail = _detail(
|
||||
"snapshot-invalid",
|
||||
auth_type="oauth",
|
||||
oauth_invalid_at=None,
|
||||
oauth_invalid_reason=None,
|
||||
account_status_blocked=False,
|
||||
account_status_code=None,
|
||||
account_status_label=None,
|
||||
status_snapshot={
|
||||
"oauth": {
|
||||
"code": "invalid",
|
||||
"label": "已失效",
|
||||
"reason": "refresh_token_reused",
|
||||
"requires_reauth": True,
|
||||
},
|
||||
"account": {"code": "workspace_deactivated", "label": "工作区停用", "blocked": True},
|
||||
"quota": {"code": "ok", "exhausted": False},
|
||||
},
|
||||
)
|
||||
|
||||
assert _detail_is_oauth_invalid(detail) is True
|
||||
|
||||
|
||||
def test_filter_pool_key_details_require_schedulable_uses_status_snapshot_account_block() -> None:
|
||||
details = [
|
||||
_detail(
|
||||
"snapshot-blocked",
|
||||
scheduling_status="",
|
||||
is_active=True,
|
||||
account_status_blocked=False,
|
||||
status_snapshot={
|
||||
"oauth": {"code": "valid"},
|
||||
"account": {"code": "account_disabled", "label": "账号停用", "blocked": True},
|
||||
"quota": {"code": "ok", "exhausted": False},
|
||||
},
|
||||
),
|
||||
_detail(
|
||||
"snapshot-ok",
|
||||
scheduling_status="",
|
||||
is_active=True,
|
||||
account_status_blocked=False,
|
||||
status_snapshot={
|
||||
"oauth": {"code": "valid"},
|
||||
"account": {"code": "ok", "blocked": False},
|
||||
"quota": {"code": "ok", "exhausted": False},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
filtered = _filter_pool_key_details(details, require_schedulable=True)
|
||||
|
||||
assert [item.key_id for item in filtered] == ["snapshot-ok"]
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_build_key_response_includes_codex_identity_metadata(
|
||||
api_formats=["openai:chat"],
|
||||
auth_type="oauth",
|
||||
api_key="enc-access-token",
|
||||
auth_config='{"email":"u@example.com","plan_type":"team","account_id":"acc-1","account_name":"Workspace Alpha","account_user_id":"user-1__acc-1","organizations":[{"id":"org-1","title":"Personal","is_default":true,"role":"owner"}],"expires_at":123456}',
|
||||
auth_config='{"email":"u@example.com","plan_type":"team","account_id":"acc-1","account_name":"Workspace Alpha","account_user_id":"user-1__acc-1","organizations":[{"id":"org-1","title":"Personal","is_default":true,"role":"owner"}],"expires_at":2100000000}',
|
||||
name="codex-user",
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -54,3 +54,63 @@ def test_build_key_response_includes_codex_identity_metadata(
|
||||
assert len(result.oauth_organizations) == 1
|
||||
assert result.oauth_organizations[0].title == "Personal"
|
||||
assert result.oauth_organizations[0].is_default is True
|
||||
assert result.status_snapshot.oauth.code == "valid"
|
||||
assert result.status_snapshot.oauth.expires_at == 2100000000
|
||||
assert result.status_snapshot.account.code == "ok"
|
||||
|
||||
|
||||
def test_build_key_response_prefers_persisted_status_snapshot_layers(
|
||||
monkeypatch: "pytest.MonkeyPatch",
|
||||
) -> None:
|
||||
key = ProviderAPIKey(
|
||||
id="key-2",
|
||||
provider_id="provider-1",
|
||||
api_formats=["openai:chat"],
|
||||
auth_type="oauth",
|
||||
api_key="enc-access-token",
|
||||
auth_config='{"expires_at":100}',
|
||||
name="codex-user",
|
||||
status_snapshot={
|
||||
"oauth": {"code": "valid", "label": "有效", "expires_at": 100},
|
||||
"account": {
|
||||
"code": "workspace_deactivated",
|
||||
"label": "工作区停用",
|
||||
"reason": "persisted",
|
||||
"blocked": True,
|
||||
},
|
||||
"quota": {
|
||||
"code": "exhausted",
|
||||
"label": "额度耗尽",
|
||||
"reason": "persisted quota",
|
||||
"exhausted": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
key.success_count = 0
|
||||
key.request_count = 0
|
||||
key.error_count = 0
|
||||
key.total_response_time_ms = 0
|
||||
key.rpm_limit = None
|
||||
key.global_priority_by_format = None
|
||||
key.allowed_models = None
|
||||
key.capabilities = None
|
||||
key.is_active = True
|
||||
key.created_at = now
|
||||
key.updated_at = now
|
||||
key.cache_ttl_minutes = 5
|
||||
key.max_probe_interval_minutes = 32
|
||||
key.health_by_format = None
|
||||
key.circuit_breaker_by_format = None
|
||||
key.oauth_invalid_at = None
|
||||
key.oauth_invalid_reason = None
|
||||
key.note = None
|
||||
key.last_used_at = None
|
||||
|
||||
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value: value)
|
||||
|
||||
result = build_key_response(key)
|
||||
|
||||
assert result.status_snapshot.oauth.code == "expired"
|
||||
assert result.status_snapshot.account.code == "workspace_deactivated"
|
||||
assert result.status_snapshot.quota.code == "exhausted"
|
||||
|
||||
96
tests/unit/test_provider_key_status_snapshot_store.py
Normal file
96
tests/unit/test_provider_key_status_snapshot_store.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.database import Provider, ProviderAPIKey, _provider_api_key_before_insert
|
||||
from src.services.provider_keys import status_snapshot_store as module
|
||||
|
||||
|
||||
def test_provider_api_key_before_insert_populates_status_snapshot(
|
||||
monkeypatch: "pytest.MonkeyPatch",
|
||||
) -> None:
|
||||
provider = Provider(
|
||||
id="provider-1",
|
||||
name="Codex Pool",
|
||||
provider_type="codex",
|
||||
)
|
||||
key = ProviderAPIKey(
|
||||
id="key-1",
|
||||
provider_id="provider-1",
|
||||
provider=provider, # type: ignore[arg-type]
|
||||
api_key="enc-access-token",
|
||||
auth_type="oauth",
|
||||
auth_config='{"expires_at":2100000000}',
|
||||
name="codex-user",
|
||||
oauth_invalid_reason=(
|
||||
"[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n"
|
||||
"[REFRESH_FAILED] refresh_token_reused"
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value: value)
|
||||
|
||||
_provider_api_key_before_insert(None, None, key)
|
||||
|
||||
assert isinstance(key.status_snapshot, dict)
|
||||
assert key.status_snapshot["oauth"]["code"] == "invalid"
|
||||
assert key.status_snapshot["oauth"]["label"] == "已失效"
|
||||
assert key.status_snapshot["oauth"]["reason"] == "refresh_token_reused"
|
||||
assert key.status_snapshot["account"]["code"] == "workspace_deactivated"
|
||||
assert key.status_snapshot["account"]["blocked"] is True
|
||||
|
||||
|
||||
def test_resolve_provider_key_status_snapshot_prefers_persisted_snapshot_layers(
|
||||
monkeypatch: "pytest.MonkeyPatch",
|
||||
) -> None:
|
||||
provider = Provider(
|
||||
id="provider-1",
|
||||
name="Codex Pool",
|
||||
provider_type="codex",
|
||||
)
|
||||
key = ProviderAPIKey(
|
||||
id="key-2",
|
||||
provider_id="provider-1",
|
||||
provider=provider, # type: ignore[arg-type]
|
||||
api_key="enc-access-token",
|
||||
auth_type="oauth",
|
||||
auth_config='{"expires_at":100}',
|
||||
name="codex-user",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason=None,
|
||||
status_snapshot={
|
||||
"oauth": {
|
||||
"code": "valid",
|
||||
"label": "有效",
|
||||
"expires_at": 100,
|
||||
},
|
||||
"account": {
|
||||
"code": "workspace_deactivated",
|
||||
"label": "工作区停用",
|
||||
"reason": "persisted",
|
||||
"blocked": True,
|
||||
"source": "persisted",
|
||||
},
|
||||
"quota": {
|
||||
"code": "exhausted",
|
||||
"label": "额度耗尽",
|
||||
"reason": "persisted quota",
|
||||
"exhausted": True,
|
||||
"usage_ratio": 1.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value: value)
|
||||
|
||||
snapshot = module.resolve_provider_key_status_snapshot(
|
||||
key,
|
||||
now_ts=200,
|
||||
)
|
||||
|
||||
assert snapshot.oauth.code == "expired"
|
||||
assert snapshot.oauth.label == "已过期"
|
||||
assert snapshot.account.code == "workspace_deactivated"
|
||||
assert snapshot.account.reason == "persisted"
|
||||
assert snapshot.quota.code == "exhausted"
|
||||
assert snapshot.quota.reason == "persisted quota"
|
||||
56
tests/unit/test_provider_oauth_refresh_error.py
Normal file
56
tests/unit/test_provider_oauth_refresh_error.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from src.api.admin import provider_oauth as module
|
||||
|
||||
|
||||
def test_extract_oauth_refresh_error_reason_for_reused_refresh_token() -> None:
|
||||
response = httpx.Response(
|
||||
400,
|
||||
json={
|
||||
"error": {
|
||||
"message": (
|
||||
"Your refresh token has already been used to generate a new access token. "
|
||||
"Please try signing in again."
|
||||
),
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "refresh_token_reused",
|
||||
}
|
||||
},
|
||||
request=httpx.Request("POST", "https://example.com/oauth/token"),
|
||||
)
|
||||
|
||||
assert (
|
||||
module._extract_oauth_refresh_error_reason(response)
|
||||
== "refresh_token 已被使用并轮换,请重新登录授权"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_oauth_refresh_error_reason_prefers_nested_message() -> None:
|
||||
response = httpx.Response(
|
||||
401,
|
||||
json={
|
||||
"error": {
|
||||
"message": "refresh token expired",
|
||||
"type": "invalid_request_error",
|
||||
}
|
||||
},
|
||||
request=httpx.Request("POST", "https://example.com/oauth/token"),
|
||||
)
|
||||
|
||||
assert (
|
||||
module._extract_oauth_refresh_error_reason(response)
|
||||
== "refresh_token 无效、已过期或已撤销,请重新登录授权"
|
||||
)
|
||||
|
||||
|
||||
def test_merge_refresh_failure_reason_keeps_account_block_and_appends_refresh_failure() -> None:
|
||||
current_reason = "[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)"
|
||||
refresh_reason = "[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||
|
||||
assert module._merge_refresh_failure_reason(current_reason, refresh_reason) == (
|
||||
"[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n"
|
||||
"[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||
)
|
||||
Reference in New Issue
Block a user