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:
fawney19
2026-03-20 19:16:52 +08:00
parent 25d38ae632
commit 46737d32f8
39 changed files with 2326 additions and 470 deletions

View File

@@ -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%"

View File

@@ -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,

View File

@@ -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"]

View File

@@ -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"

View 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"

View 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"
)