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
+58 -65
View File
@@ -9,11 +9,10 @@ Provides endpoints for managing account pools at scale:
from __future__ import annotations
import asyncio
import json
import re
import time
import uuid
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, cast
@@ -35,7 +34,9 @@ from src.models.database import Provider, ProviderAPIKey
from src.services.billing.precision import to_money_decimal
from src.services.provider.fingerprint import generate_fingerprint
from src.services.provider.pool import redis_ops as pool_redis
from src.services.provider.pool.account_state import resolve_pool_account_state
from src.services.provider.pool.account_state import (
resolve_pool_account_state,
)
from src.services.provider.pool.config import parse_pool_config
from src.services.provider.pool.dimensions import get_preset_dimension_metas
from src.services.provider.pool.scheduling_dimensions import (
@@ -45,6 +46,18 @@ from src.services.provider.pool.scheduling_dimensions import (
)
from src.services.provider_keys.key_side_effects import cleanup_key_references
from src.services.provider_keys.quota_reader import get_quota_reader
from src.services.provider_keys.status_snapshot_store import (
derive_oauth_expires_at as derive_persisted_oauth_expires_at,
)
from src.services.provider_keys.status_snapshot_store import (
extract_oauth_auth_config as extract_persisted_oauth_auth_config,
)
from src.services.provider_keys.status_snapshot_store import (
normalize_oauth_expires_at as normalize_persisted_oauth_expires_at,
)
from src.services.provider_keys.status_snapshot_store import (
resolve_provider_key_status_snapshot,
)
from .schemas import (
BatchActionRequest,
@@ -252,10 +265,6 @@ def _build_account_quota(provider_type: str, upstream_metadata: Any) -> str | No
return get_quota_reader(provider_type, upstream_metadata).display_summary()
def _extract_quota_updated_at(provider_type: str, upstream_metadata: Any) -> int | None:
return get_quota_reader(provider_type, upstream_metadata).updated_at()
def _normalize_oauth_plan_type(plan_type: Any, provider_type: str) -> str | None:
if not isinstance(plan_type, str):
return None
@@ -273,52 +282,17 @@ def _normalize_oauth_plan_type(plan_type: Any, provider_type: str) -> str | None
def _extract_oauth_auth_config(key: ProviderAPIKey) -> dict[str, Any] | None:
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
return None
auth_config_raw = getattr(key, "auth_config", None)
if not auth_config_raw:
return None
try:
decrypted = crypto_service.decrypt(auth_config_raw)
parsed = json.loads(decrypted)
if isinstance(parsed, dict):
return parsed
except Exception:
return None
return None
return extract_persisted_oauth_auth_config(key)
def _normalize_oauth_expires_at(raw: Any) -> int | None:
value = _to_float(raw)
if value is None or value <= 0:
return None
# 兼容毫秒时间戳
if value > 1_000_000_000_000:
value /= 1000
return int(value)
return normalize_persisted_oauth_expires_at(raw)
def _derive_oauth_expires_at(
key: ProviderAPIKey, auth_config: dict[str, Any] | None = None
) -> int | None:
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
return None
cfg = auth_config if isinstance(auth_config, dict) else _extract_oauth_auth_config(key)
if cfg:
for field in ("expires_at", "expiresAt", "expiry", "exp"):
expires_at = _normalize_oauth_expires_at(cfg.get(field))
if expires_at is not None:
return expires_at
# 兼容历史字段
expires_dt = getattr(key, "expires_at", None)
if isinstance(expires_dt, datetime):
return int(expires_dt.timestamp())
return None
return derive_persisted_oauth_expires_at(key, auth_config=auth_config)
def _derive_oauth_plan_type(
@@ -864,7 +838,16 @@ def _has_no_weekly_limit(account_quota: Any) -> bool:
def _detail_is_oauth_invalid(detail: PoolKeyDetail) -> bool:
if _normalize_batch_text(detail.auth_type) != "oauth":
return False
status_code = _normalize_batch_text(detail.account_status_code)
snapshot_oauth_code = _normalize_batch_text(getattr(detail.status_snapshot.oauth, "code", None))
if snapshot_oauth_code == "invalid":
return True
if snapshot_oauth_code == "expired":
return True
if snapshot_oauth_code == "check_failed":
return False
status_code = _normalize_batch_text(
getattr(detail.status_snapshot.account, "code", None) or detail.account_status_code
)
if status_code in _TOKEN_ISSUE_CODES:
return True
if status_code in _ACCOUNT_BANNED_CODES or status_code == "oauth_request_failed":
@@ -883,9 +866,16 @@ def _detail_is_oauth_invalid(detail: PoolKeyDetail) -> bool:
def _detail_is_banned(detail: PoolKeyDetail) -> bool:
snapshot_account_code = _normalize_batch_text(
getattr(detail.status_snapshot.account, "code", None)
)
if snapshot_account_code in _ACCOUNT_BANNED_CODES:
return True
if _normalize_batch_text(detail.account_status_code) in _ACCOUNT_BANNED_CODES:
return True
reason = _normalize_batch_text(detail.oauth_invalid_reason)
reason = _normalize_batch_text(
getattr(detail.status_snapshot.account, "reason", None) or detail.oauth_invalid_reason
)
if reason and _BANNED_REASON_PATTERN.search(reason):
return True
for item in detail.scheduling_reasons or []:
@@ -916,12 +906,15 @@ def _matches_pool_key_search(
detail.key_name,
detail.auth_type,
detail.oauth_plan_type,
detail.account_status_label,
detail.account_status_reason,
getattr(detail.status_snapshot.account, "label", None) or detail.account_status_label,
getattr(detail.status_snapshot.account, "reason", None) or detail.account_status_reason,
detail.account_quota,
"独立代理" if _detail_has_proxy(detail) else "未配置代理",
"已启用" if detail.is_active else "已禁用",
detail.oauth_invalid_reason,
getattr(detail.status_snapshot.oauth, "reason", None) or detail.oauth_invalid_reason,
getattr(detail.status_snapshot.oauth, "label", None),
getattr(detail.status_snapshot.quota, "label", None),
getattr(detail.status_snapshot.quota, "reason", None),
]
return any(keyword in _normalize_batch_text(part) for part in parts)
@@ -984,7 +977,9 @@ def _detail_is_schedulable(detail: PoolKeyDetail) -> bool:
if not detail.is_active:
return False
if detail.account_status_blocked:
if bool(
getattr(detail.status_snapshot.account, "blocked", False) or detail.account_status_blocked
):
return False
if detail.cooldown_reason:
return False
@@ -1081,11 +1076,15 @@ async def _serialize_pool_key_details(
cost_limit = pcfg.cost_limit_per_key_tokens if pcfg else None
latency_avg_raw = latency_avgs.get(kid)
latency_avg_ms = float(latency_avg_raw) if latency_avg_raw is not None else None
account_state = resolve_pool_account_state(
oauth_auth_config = _extract_oauth_auth_config(k)
oauth_expires_at = _derive_oauth_expires_at(k, auth_config=oauth_auth_config)
status_snapshot = resolve_provider_key_status_snapshot(
k,
provider_type=provider_type,
upstream_metadata=getattr(k, "upstream_metadata", None),
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
auth_config=oauth_auth_config,
oauth_expires_at=oauth_expires_at,
)
account_state = status_snapshot.account
(
scheduling_status,
scheduling_reason,
@@ -1153,7 +1152,7 @@ async def _serialize_pool_key_details(
key_total_tokens = int(getattr(k, "total_tokens", 0) or 0)
key_total_cost_usd = _serialize_money(getattr(k, "total_cost_usd", 0.0))
key_last_used_at = getattr(k, "last_used_at", None)
oauth_auth_config = _extract_oauth_auth_config(k)
oauth_invalid_at = status_snapshot.oauth.invalid_at
key_details.append(
PoolKeyDetail(
@@ -1161,12 +1160,8 @@ async def _serialize_pool_key_details(
key_name=str(getattr(k, "name", "") or ""),
is_active=bool(k.is_active),
auth_type=str(getattr(k, "auth_type", "api_key") or "api_key"),
oauth_expires_at=_derive_oauth_expires_at(k, auth_config=oauth_auth_config),
oauth_invalid_at=(
int(k.oauth_invalid_at.timestamp())
if getattr(k, "oauth_invalid_at", None)
else None
),
oauth_expires_at=oauth_expires_at,
oauth_invalid_at=oauth_invalid_at,
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
oauth_plan_type=_derive_oauth_plan_type(
k, provider_type, auth_config=oauth_auth_config
@@ -1181,10 +1176,8 @@ async def _serialize_pool_key_details(
account_status_blocked=account_state.blocked,
account_status_recoverable=bool(getattr(account_state, "recoverable", False)),
account_status_source=getattr(account_state, "source", None),
quota_updated_at=_extract_quota_updated_at(
provider_type,
getattr(k, "upstream_metadata", None),
),
status_snapshot=asdict(status_snapshot),
quota_updated_at=status_snapshot.quota.updated_at,
health_score=health_score,
circuit_breaker_open=any_circuit_open,
api_formats=api_formats,
+33 -9
View File
@@ -6,6 +6,8 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from src.models.status_snapshot import ProviderKeyStatusSnapshotResponse
# ---------------------------------------------------------------------------
# Overview
# ---------------------------------------------------------------------------
@@ -80,20 +82,42 @@ class PoolKeyDetail(BaseModel):
key_name: str
is_active: bool
auth_type: str = "api_key"
oauth_expires_at: int | None = None
oauth_invalid_at: int | None = None
oauth_invalid_reason: str | None = None
oauth_expires_at: int | None = Field(
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
)
oauth_invalid_at: int | None = Field(
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
)
oauth_invalid_reason: str | None = Field(
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
)
oauth_plan_type: str | None = None
oauth_account_id: str | None = None
oauth_account_name: str | None = None
oauth_account_user_id: str | None = None
oauth_organizations: list[OAuthOrganizationSummary] = Field(default_factory=list)
account_status_code: str | None = None
account_status_label: str | None = None
account_status_reason: str | None = None
account_status_blocked: bool = False
account_status_recoverable: bool = False
account_status_source: str | None = None
account_status_code: str | None = Field(
default=None, description="兼容字段;优先使用 status_snapshot.account"
)
account_status_label: str | None = Field(
default=None, description="兼容字段;优先使用 status_snapshot.account"
)
account_status_reason: str | None = Field(
default=None, description="兼容字段;优先使用 status_snapshot.account"
)
account_status_blocked: bool = Field(
default=False, description="兼容字段;优先使用 status_snapshot.account"
)
account_status_recoverable: bool = Field(
default=False, description="兼容字段;优先使用 status_snapshot.account"
)
account_status_source: str | None = Field(
default=None, description="兼容字段;优先使用 status_snapshot.account"
)
status_snapshot: ProviderKeyStatusSnapshotResponse = Field(
default_factory=ProviderKeyStatusSnapshotResponse,
description="统一的账号/OAuth/额度状态快照",
)
quota_updated_at: int | None = None
# 健康度聚合字段(与 Provider Key 列表口径一致)
health_score: float = 1.0
+99 -19
View File
@@ -51,6 +51,85 @@ from src.utils.auth_utils import require_admin
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
def _normalize_oauth_refresh_error_message(
message: str | None,
*,
status_code: int | None = None,
error_code: str | None = None,
error_type: str | None = None,
) -> str:
text = str(message or "").strip()
lowered = text.lower()
code = str(error_code or "").strip().lower()
err_type = str(error_type or "").strip().lower()
if code == "refresh_token_reused" or (
"already been used to generate a new access token" in lowered
):
return "refresh_token 已被使用并轮换,请重新登录授权"
if code in {"invalid_grant", "invalid_refresh_token"} or (
"refresh token" in lowered
and any(keyword in lowered for keyword in ("expired", "revoked", "invalid"))
):
return "refresh_token 无效、已过期或已撤销,请重新登录授权"
if err_type == "invalid_request_error" and text:
return text
if text:
return text
if status_code is not None:
return f"HTTP {status_code}"
return "未知错误"
def _extract_oauth_refresh_error_reason(resp: httpx.Response) -> str:
status_code = int(resp.status_code)
message: str | None = None
error_code: str | None = None
error_type: str | None = None
try:
error_body = resp.json()
if isinstance(error_body, dict):
err = error_body.get("error")
if isinstance(err, dict):
raw_message = err.get("message") or err.get("error_description")
if raw_message is not None:
message = str(raw_message).strip() or None
raw_code = err.get("code")
if raw_code is not None:
error_code = str(raw_code).strip() or None
raw_type = err.get("type")
if raw_type is not None:
error_type = str(raw_type).strip() or None
elif isinstance(err, str):
message = err.strip() or None
raw_message = error_body.get("message") or error_body.get("error_description")
if raw_message is not None and not message:
message = str(raw_message).strip() or None
raw_code = error_body.get("code")
if raw_code is not None and not error_code:
error_code = str(raw_code).strip() or None
raw_type = error_body.get("type")
if raw_type is not None and not error_type:
error_type = str(raw_type).strip() or None
except Exception:
pass
if not message:
text = str(getattr(resp, "text", "") or "").strip()
message = text[:300] if text else None
return _normalize_oauth_refresh_error_message(
message,
status_code=status_code,
error_code=error_code,
error_type=error_type,
)
def _store_completed_oauth_sync(
key_id: str,
provider_type: str,
@@ -71,22 +150,31 @@ def _mark_refresh_failed_sync(key_id: str, reason: str) -> None:
if not key:
raise NotFoundException("Key 不存在", "key")
current_reason = str(getattr(key, "oauth_invalid_reason", None) or "").strip()
if _should_preserve_refresh_failure_reason(current_reason):
merged_reason = _merge_refresh_failure_reason(current_reason, reason)
if merged_reason is None:
return
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = reason
key.oauth_invalid_reason = merged_reason
def _should_preserve_refresh_failure_reason(reason: str | None) -> bool:
def _merge_refresh_failure_reason(current_reason: str | None, refresh_reason: str) -> str | None:
from src.services.provider.oauth_token import is_account_level_block
from src.services.provider.pool.account_state import OAUTH_EXPIRED_PREFIX
text = str(reason or "").strip()
if not text:
return False
if is_account_level_block(text):
return True
return text.startswith(OAUTH_EXPIRED_PREFIX)
current = str(current_reason or "").strip()
next_reason = str(refresh_reason or "").strip()
if not next_reason:
return current or None
if not current:
return next_reason
if current.startswith(OAUTH_EXPIRED_PREFIX):
return None
if is_account_level_block(current):
if "[REFRESH_FAILED]" in current:
head, _sep, _tail = current.partition("[REFRESH_FAILED]")
return f"{head.rstrip()}\n{next_reason}".strip()
return f"{current}\n{next_reason}"
return next_reason
def _store_refreshed_oauth_sync(
@@ -1168,15 +1256,7 @@ async def refresh_oauth(
)
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(
error_body.get("error_description") or error_body.get("error")
)
except Exception:
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
error_reason = _extract_oauth_refresh_error_reason(resp)
if resp.status_code in (400, 401, 403):
await run_in_threadpool(
@@ -1194,7 +1274,7 @@ async def refresh_oauth(
refresh_error=error_reason,
)
raise InvalidRequestException(f"token refresh 失败: {error_reason}")
raise InvalidRequestException(f"Token 刷新失败:{error_reason}")
token = resp.json()
access_token = str(token.get("access_token") or "")
+46
View File
@@ -30,6 +30,8 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
event,
inspect,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
@@ -2066,6 +2068,7 @@ class ProviderAPIKey(ExportMixin, Base):
# OAuth 失效状态(账号被封、授权撤销、刷新失败等)
oauth_invalid_at = Column(DateTime(timezone=True), nullable=True) # 失效时间
oauth_invalid_reason = Column(String(255), nullable=True) # 失效原因
status_snapshot = Column(JSON, nullable=True, default=None) # 结构化状态快照(兼容旧字段)
# Key 级别的代理配置(覆盖 Provider 级别的代理设置)
# 结构: {"node_id": "xxx", "enabled": true} 或 {"url": "socks5://...", "enabled": true}
@@ -2092,6 +2095,49 @@ class ProviderAPIKey(ExportMixin, Base):
provider = relationship("Provider", back_populates="api_keys")
_PROVIDER_API_KEY_STATUS_SNAPSHOT_FIELDS: tuple[str, ...] = (
"auth_type",
"auth_config",
"expires_at",
"oauth_invalid_at",
"oauth_invalid_reason",
"upstream_metadata",
"provider_id",
)
def _sync_provider_api_key_status_snapshot(
target: ProviderAPIKey,
*,
connection: Any | None,
force: bool,
) -> None:
state = inspect(target)
if state is None:
return
if not force:
relevant_changed = any(
state.attrs[field].history.has_changes()
for field in _PROVIDER_API_KEY_STATUS_SNAPSHOT_FIELDS
)
if not relevant_changed and getattr(target, "status_snapshot", None) is not None:
return
from src.services.provider_keys.status_snapshot_store import sync_provider_key_status_snapshot
sync_provider_key_status_snapshot(target, connection=connection)
@event.listens_for(ProviderAPIKey, "before_insert")
def _provider_api_key_before_insert(mapper: Any, connection: Any, target: ProviderAPIKey) -> None:
_sync_provider_api_key_status_snapshot(target, connection=connection, force=True)
@event.listens_for(ProviderAPIKey, "before_update")
def _provider_api_key_before_update(mapper: Any, connection: Any, target: ProviderAPIKey) -> None:
_sync_provider_api_key_status_snapshot(target, connection=connection, force=False)
def _generate_short_id(length: int = 12) -> str:
"""生成 Gemini 风格的短 ID(小写字母+数字)"""
import secrets
+60 -162
View File
@@ -16,6 +16,7 @@ from src.models.admin_requests import (
PoolAdvancedConfig,
ProxyConfig,
)
from src.models.status_snapshot import ProviderKeyStatusSnapshotResponse
# ========== Header Rule 类型定义 ==========
# 请求头规则支持三种操作:
@@ -130,8 +131,7 @@ def _validate_condition(condition: Any, rule_label: str) -> None:
op = condition.get("op")
if not isinstance(op, str) or op not in _CONDITION_OPS:
raise ValueError(
f"{rule_label}: condition.op 必须是 {sorted(_CONDITION_OPS)} 之一,"
f"当前值: {op!r}"
f"{rule_label}: condition.op 必须是 {sorted(_CONDITION_OPS)} 之一," f"当前值: {op!r}"
)
path = condition.get("path")
@@ -152,15 +152,11 @@ def _validate_condition(condition: Any, rule_label: str) -> None:
# matches 正则校验
if op == "matches":
if not isinstance(value, str) or not value:
raise ValueError(
f"{rule_label}: condition op=matches 的 value 必须为非空字符串"
)
raise ValueError(f"{rule_label}: condition op=matches 的 value 必须为非空字符串")
try:
re.compile(value)
except re.error as e:
raise ValueError(
f"{rule_label}: condition op=matches 的 value 不是合法正则: {e}"
)
raise ValueError(f"{rule_label}: condition op=matches 的 value 不是合法正则: {e}")
# in 校验
if op == "in":
@@ -188,10 +184,7 @@ def _validate_header_rules(rules: list[HeaderRule]) -> list[HeaderRule]:
raise ValueError(f"header_rules[{idx}]: 规则必须是 JSON 对象")
action = rule.get("action")
if (
not isinstance(action, str)
or action.strip().lower() not in _HEADER_RULE_ACTIONS
):
if not isinstance(action, str) or action.strip().lower() not in _HEADER_RULE_ACTIONS:
raise ValueError(
f"header_rules[{idx}]: action 必须是 {sorted(_HEADER_RULE_ACTIONS)} 之一,"
f"当前值: {action!r}"
@@ -241,10 +234,7 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
raise ValueError(f"body_rules[{idx}]: 规则必须是 JSON 对象")
action = rule.get("action")
if (
not isinstance(action, str)
or action.strip().lower() not in _BODY_RULE_ACTIONS
):
if not isinstance(action, str) or action.strip().lower() not in _BODY_RULE_ACTIONS:
raise ValueError(
f"body_rules[{idx}]: action 必须是 {sorted(_BODY_RULE_ACTIONS)} 之一,"
f"当前值: {action!r}"
@@ -255,9 +245,7 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
if action in {"set", "drop", "append", "insert", "regex_replace", "name_style"}:
path = rule.get("path")
if not isinstance(path, str) or not path.strip():
raise ValueError(
f"body_rules[{idx}]: action={action!r} 必须提供非空 path"
)
raise ValueError(f"body_rules[{idx}]: action={action!r} 必须提供非空 path")
# ---------- rename 校验 ----------
if action == "rename":
@@ -278,15 +266,11 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
if action == "regex_replace":
pattern = rule.get("pattern")
if not isinstance(pattern, str) or not pattern:
raise ValueError(
f"body_rules[{idx}]: regex_replace 必须提供非空 pattern 字符串"
)
raise ValueError(f"body_rules[{idx}]: regex_replace 必须提供非空 pattern 字符串")
replacement = rule.get("replacement", "")
if not isinstance(replacement, str):
raise ValueError(
f"body_rules[{idx}]: regex_replace 的 replacement 必须为字符串"
)
raise ValueError(f"body_rules[{idx}]: regex_replace 的 replacement 必须为字符串")
# 校验 flags
flags_str = rule.get("flags", "")
@@ -312,9 +296,7 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
# 校验 count
count = rule.get("count", 0)
if not isinstance(count, int) or count < 0:
raise ValueError(
f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数"
)
raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数")
# ---------- name_style 校验 ----------
if action == "name_style":
@@ -347,9 +329,7 @@ class ProviderEndpointCreate(BaseModel):
),
)
base_url: str = Field(..., min_length=1, max_length=500, description="API 基础 URL")
custom_path: str | None = Field(
default=None, max_length=200, description="自定义请求路径"
)
custom_path: str | None = Field(default=None, max_length=200, description="自定义请求路径")
# 请求头配置
header_rules: list[HeaderRule] | None = Field(
@@ -411,9 +391,7 @@ class ProviderEndpointCreate(BaseModel):
@field_validator("header_rules")
@classmethod
def validate_header_rules(
cls, v: list[HeaderRule] | None
) -> list[HeaderRule] | None:
def validate_header_rules(cls, v: list[HeaderRule] | None) -> list[HeaderRule] | None:
"""校验 header_rules 结构和 condition 合法性"""
if v is None:
return v
@@ -426,9 +404,7 @@ class ProviderEndpointUpdate(BaseModel):
base_url: str | None = Field(
default=None, min_length=1, max_length=500, description="API 基础 URL"
)
custom_path: str | None = Field(
default=None, max_length=200, description="自定义请求路径"
)
custom_path: str | None = Field(default=None, max_length=200, description="自定义请求路径")
# 请求头配置
header_rules: list[HeaderRule] | None = Field(
@@ -442,9 +418,7 @@ class ProviderEndpointUpdate(BaseModel):
description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作",
)
max_retries: int | None = Field(
default=None, ge=0, le=999, description="最大重试次数"
)
max_retries: int | None = Field(default=None, ge=0, le=999, description="最大重试次数")
is_active: bool | None = Field(default=None, description="是否启用")
config: dict[str, Any] | None = Field(default=None, description="额外配置")
proxy: ProxyConfig | None = Field(default=None, description="代理配置")
@@ -477,9 +451,7 @@ class ProviderEndpointUpdate(BaseModel):
@field_validator("header_rules")
@classmethod
def validate_header_rules(
cls, v: list[HeaderRule] | None
) -> list[HeaderRule] | None:
def validate_header_rules(cls, v: list[HeaderRule] | None) -> list[HeaderRule] | None:
"""校验 header_rules 结构和 condition 合法性"""
if v is None:
return v
@@ -499,14 +471,10 @@ class ProviderEndpointResponse(BaseModel):
custom_path: str | None = None
# 请求头配置
header_rules: list[HeaderRule] | None = Field(
default=None, description="请求头规则列表"
)
header_rules: list[HeaderRule] | None = Field(default=None, description="请求头规则列表")
# 请求体配置
body_rules: list[BodyRule] | None = Field(
default=None, description="请求体规则列表"
)
body_rules: list[BodyRule] | None = Field(default=None, description="请求体规则列表")
max_retries: int
@@ -517,9 +485,7 @@ class ProviderEndpointResponse(BaseModel):
config: dict[str, Any] | None = None
# 代理配置(响应中密码已脱敏)
proxy: dict[str, Any] | None = Field(
default=None, description="代理配置(密码已脱敏)"
)
proxy: dict[str, Any] | None = Field(default=None, description="代理配置(密码已脱敏)")
# 格式转换配置
format_acceptance_config: dict[str, Any] | None = Field(
@@ -544,9 +510,7 @@ class ProviderEndpointResponse(BaseModel):
class EndpointAPIKeyCreate(BaseModel):
"""为 Provider 添加 API Key"""
provider_id: str | None = Field(
default=None, description="Provider ID(从 URL 获取)"
)
provider_id: str | None = Field(default=None, description="Provider ID(从 URL 获取)")
api_formats: list[str] | None = Field(
default=None,
min_length=1,
@@ -569,9 +533,7 @@ class EndpointAPIKeyCreate(BaseModel):
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
),
)
name: str = Field(
..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)"
)
name: str = Field(..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)")
# 成本计算
rate_multipliers: dict[str, float] | None = Field(
@@ -580,9 +542,7 @@ class EndpointAPIKeyCreate(BaseModel):
)
# 优先级和限制(数字越小越优先)
internal_priority: int = Field(
default=50, description="Key 内部优先级(提供商优先模式)"
)
internal_priority: int = Field(default=50, description="Key 内部优先级(提供商优先模式)")
# rpm_limit: NULL=自适应模式(系统自动学习),数字=固定限制模式
rpm_limit: int | None = Field(
default=None, ge=1, le=10000, description="RPM 限制(NULL=自适应模式)"
@@ -607,9 +567,7 @@ class EndpointAPIKeyCreate(BaseModel):
)
# 备注
note: str | None = Field(
default=None, max_length=500, description="备注说明(可选)"
)
note: str | None = Field(default=None, max_length=500, description="备注说明(可选)")
# 自动获取模型
auto_fetch_models: bool = Field(
@@ -649,9 +607,7 @@ class EndpointAPIKeyCreate(BaseModel):
for fmt in v:
normalized = normalize_signature_key(fmt)
if resolve_endpoint_definition(normalized) is None:
raise ValueError(
f"api_formats 必须是以下之一: {allowed},当前值: {fmt}"
)
raise ValueError(f"api_formats 必须是以下之一: {allowed},当前值: {fmt}")
if normalized in seen:
continue # 静默去重
seen.add(normalized)
@@ -737,9 +693,7 @@ class EndpointAPIKeyUpdate(BaseModel):
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
),
)
name: str | None = Field(
default=None, min_length=1, max_length=100, description="密钥名称"
)
name: str | None = Field(default=None, min_length=1, max_length=100, description="密钥名称")
rate_multipliers: dict[str, float] | None = Field(
default=None,
description="按 endpoint signature 的成本倍率,如 {'claude:cli': 1.0, 'openai:cli': 0.8}",
@@ -774,9 +728,7 @@ class EndpointAPIKeyUpdate(BaseModel):
)
is_active: bool | None = Field(default=None, description="是否启用")
note: str | None = Field(default=None, max_length=500, description="备注说明")
auto_fetch_models: bool | None = Field(
default=None, description="是否启用自动获取模型"
)
auto_fetch_models: bool | None = Field(default=None, description="是否启用自动获取模型")
locked_models: list[str] | None = Field(
default=None, description="被锁定的模型列表(刷新时不会被删除)"
)
@@ -891,9 +843,7 @@ class EndpointAPIKeyResponse(BaseModel):
)
rpm_limit: int | None = None
allowed_models: list[str] | None = None
capabilities: dict[str, bool] | None = Field(
default=None, description="Key 能力标签"
)
capabilities: dict[str, bool] | None = Field(default=None, description="Key 能力标签")
# OAuth 相关
oauth_expires_at: int | None = Field(
@@ -904,9 +854,7 @@ class EndpointAPIKeyResponse(BaseModel):
default=None, description="OAuth 账号套餐类型(如 free/plus/team/enterprise"
)
oauth_account_id: str | None = Field(default=None, description="OAuth 账号 ID")
oauth_account_name: str | None = Field(
default=None, description="OAuth 当前工作区/账号名称"
)
oauth_account_name: str | None = Field(default=None, description="OAuth 当前工作区/账号名称")
oauth_account_user_id: str | None = Field(
default=None,
description="OAuth 账号-工作区联合 ID(如 Codex chatgpt_account_user_id",
@@ -917,17 +865,19 @@ class EndpointAPIKeyResponse(BaseModel):
)
oauth_invalid_at: int | None = Field(
default=None,
description="OAuth Token 失效时间(Unix 时间戳),如账号被封、授权撤销等",
description="OAuth Token 失效时间(Unix 时间戳,兼容字段;优先使用 status_snapshot.oauth",
)
oauth_invalid_reason: str | None = Field(
default=None, description="OAuth Token 失效原因"
default=None, description="OAuth Token 失效原因(兼容字段;优先使用 status_snapshot.oauth"
)
status_snapshot: ProviderKeyStatusSnapshotResponse = Field(
default_factory=ProviderKeyStatusSnapshotResponse,
description="统一的账号/OAuth/额度状态快照",
)
# 缓存与熔断配置
cache_ttl_minutes: int = Field(default=5, description="缓存 TTL(分钟),0=禁用")
max_probe_interval_minutes: int = Field(
default=32, description="熔断探测间隔(分钟)"
)
max_probe_interval_minutes: int = Field(default=32, description="熔断探测间隔(分钟)")
# 按 endpoint signature 的健康度数据
health_by_format: dict[str, Any] | None = Field(
@@ -943,18 +893,10 @@ class EndpointAPIKeyResponse(BaseModel):
last_failure_at: datetime | None = None
# 聚合熔断器字段
circuit_breaker_open: bool = Field(
default=False, description="熔断器是否打开(任何格式)"
)
circuit_breaker_open_at: datetime | None = Field(
default=None, description="熔断器打开时间"
)
next_probe_at: datetime | None = Field(
default=None, description="下次进入半开状态时间"
)
half_open_until: datetime | None = Field(
default=None, description="半开状态结束时间"
)
circuit_breaker_open: bool = Field(default=False, description="熔断器是否打开(任何格式)")
circuit_breaker_open_at: datetime | None = Field(default=None, description="熔断器打开时间")
next_probe_at: datetime | None = Field(default=None, description="下次进入半开状态时间")
half_open_until: datetime | None = Field(default=None, description="半开状态结束时间")
half_open_successes: int | None = Field(default=0, description="半开状态成功次数")
half_open_failures: int | None = Field(default=0, description="半开状态失败次数")
request_results_window: list[dict[str, Any]] | None = Field(
@@ -972,18 +914,12 @@ class EndpointAPIKeyResponse(BaseModel):
is_active: bool
# 自适应 RPM 信息
is_adaptive: bool = Field(
default=False, description="是否为自适应模式(rpm_limit=NULL"
)
is_adaptive: bool = Field(default=False, description="是否为自适应模式(rpm_limit=NULL")
learned_rpm_limit: int | None = Field(None, description="学习到的 RPM 限制")
effective_limit: int | None = Field(None, description="当前有效限制")
# 滑动窗口利用率采样
utilization_samples: list[dict[str, Any]] | None = Field(
None, description="利用率采样窗口"
)
last_probe_increase_at: datetime | None = Field(
None, description="上次探测性扩容时间"
)
utilization_samples: list[dict[str, Any]] | None = Field(None, description="利用率采样窗口")
last_probe_increase_at: datetime | None = Field(None, description="上次探测性扩容时间")
concurrent_429_count: int | None = None
rpm_429_count: int | None = None
last_429_at: datetime | None = None
@@ -995,9 +931,7 @@ class EndpointAPIKeyResponse(BaseModel):
# 自动获取模型
auto_fetch_models: bool = Field(default=False, description="是否启用自动获取模型")
last_models_fetch_at: datetime | None = Field(None, description="最后获取模型时间")
last_models_fetch_error: str | None = Field(
None, description="最后获取模型错误信息"
)
last_models_fetch_error: str | None = Field(None, description="最后获取模型错误信息")
locked_models: list[str] | None = Field(None, description="被锁定的模型列表")
# 模型过滤规则
model_include_patterns: list[str] | None = Field(None, description="模型包含规则")
@@ -1071,9 +1005,7 @@ class HealthStatusResponse(BaseModel):
class HealthSummaryResponse(BaseModel):
"""健康状态摘要"""
endpoints: dict[str, int] = Field(
..., description="Endpoint 统计 (total, active, unhealthy)"
)
endpoints: dict[str, int] = Field(..., description="Endpoint 统计 (total, active, unhealthy)")
keys: dict[str, int] = Field(..., description="Key 统计 (total, active, unhealthy)")
@@ -1092,17 +1024,13 @@ class KeyPriorityItem(BaseModel):
"""单个 Key 优先级项"""
key_id: str = Field(..., description="Key ID")
internal_priority: int = Field(
..., ge=0, description="Key 内部优先级(数字越小越优先)"
)
internal_priority: int = Field(..., ge=0, description="Key 内部优先级(数字越小越优先)")
class BatchUpdateKeyPriorityRequest(BaseModel):
"""批量更新 Key 优先级请求"""
priorities: list[KeyPriorityItem] = Field(
..., min_length=1, description="Key 优先级列表"
)
priorities: list[KeyPriorityItem] = Field(..., min_length=1, description="Key 优先级列表")
# ========== 提供商摘要(增强版) ==========
@@ -1114,9 +1042,7 @@ class ProviderUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
website: str | None = Field(None, max_length=500, description="主站网站")
provider_priority: int | None = Field(
None, description="提供商优先级(数字越小越优先)"
)
provider_priority: int | None = Field(None, description="提供商优先级(数字越小越优先)")
keep_priority_on_conversion: bool | None = Field(
None,
description="格式转换时是否保持优先级(True=保持原优先级,False=需要转换时降级)",
@@ -1130,9 +1056,7 @@ class ProviderUpdateRequest(BaseModel):
None, description="计费类型:monthly_quota/pay_as_you_go/free_tier"
)
monthly_quota_usd: float | None = Field(None, ge=0, description="订阅配额(美元)")
quota_reset_day: int | None = Field(
None, ge=1, le=31, description="配额重置日(1-31"
)
quota_reset_day: int | None = Field(None, ge=1, le=31, description="配额重置日(1-31")
quota_expires_at: datetime | None = Field(None, description="配额过期时间")
# 请求配置(从 Endpoint 迁移)
max_retries: int | None = Field(None, ge=0, le=10, description="最大重试次数")
@@ -1148,9 +1072,7 @@ class ProviderUpdateRequest(BaseModel):
None, description="Claude Code 高级配置"
)
pool_advanced: PoolAdvancedConfig | None = Field(None, description="通用号池配置")
failover_rules: FailoverRulesConfig | None = Field(
None, description="故障转移规则配置"
)
failover_rules: FailoverRulesConfig | None = Field(None, description="故障转移规则配置")
class ProviderWithEndpointsSummary(BaseModel):
@@ -1165,9 +1087,7 @@ class ProviderWithEndpointsSummary(BaseModel):
)
description: str | None = None
website: str | None = None
provider_priority: int = Field(
default=100, description="提供商优先级(数字越小越优先)"
)
provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)")
keep_priority_on_conversion: bool = Field(
default=False,
description="格式转换时是否保持优先级(True=保持原优先级,False=需要转换时降级)",
@@ -1182,12 +1102,8 @@ class ProviderWithEndpointsSummary(BaseModel):
billing_type: str | None = None
monthly_quota_usd: float | None = None
monthly_used_usd: float | None = None
quota_reset_day: int | None = Field(
default=None, description="配额重置周期(天数)"
)
quota_last_reset_at: datetime | None = Field(
default=None, description="当前周期开始时间"
)
quota_reset_day: int | None = Field(default=None, description="配额重置周期(天数)")
quota_last_reset_at: datetime | None = Field(default=None, description="当前周期开始时间")
quota_expires_at: datetime | None = Field(default=None, description="配额过期时间")
# 请求配置(从 Endpoint 迁移)
@@ -1197,18 +1113,12 @@ class ProviderWithEndpointsSummary(BaseModel):
stream_first_byte_timeout: float | None = Field(
default=None, description="流式请求首字节超时(秒)"
)
request_timeout: float | None = Field(
default=None, description="非流式请求整体超时(秒)"
)
request_timeout: float | None = Field(default=None, description="非流式请求整体超时(秒)")
claude_code_advanced: ClaudeCodeAdvancedConfig | None = Field(
default=None, description="Claude Code 高级配置"
)
pool_advanced: PoolAdvancedConfig | None = Field(
default=None, description="通用号池配置"
)
failover_rules: FailoverRulesConfig | None = Field(
default=None, description="故障转移规则配置"
)
pool_advanced: PoolAdvancedConfig | None = Field(default=None, description="通用号池配置")
failover_rules: FailoverRulesConfig | None = Field(default=None, description="故障转移规则配置")
# Endpoint 统计
total_endpoints: int = Field(default=0, description="总 Endpoint 数量")
@@ -1221,9 +1131,7 @@ class ProviderWithEndpointsSummary(BaseModel):
# Model 统计
total_models: int = Field(default=0, description="总模型数量")
active_models: int = Field(default=0, description="活跃模型数量")
global_model_ids: list[str] = Field(
default=[], description="活跃模型关联的全局模型 ID 列表"
)
global_model_ids: list[str] = Field(default=[], description="活跃模型关联的全局模型 ID 列表")
# API 格式列表
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")
@@ -1241,9 +1149,7 @@ class ProviderWithEndpointsSummary(BaseModel):
)
# Provider Ops 配置状态
ops_configured: bool = Field(
default=False, description="是否配置了扩展操作(余额监控等)"
)
ops_configured: bool = Field(default=False, description="是否配置了扩展操作(余额监控等)")
ops_architecture_id: str | None = Field(
default=None, description="扩展操作使用的架构 ID(如 cubence, anyrouter"
)
@@ -1322,9 +1228,7 @@ class ApiFormatHealthMonitor(BaseModel):
time_range_start: datetime | None = Field(
default=None, description="时间线所覆盖区间的开始时间"
)
time_range_end: datetime | None = Field(
default=None, description="时间线所覆盖区间的结束时间"
)
time_range_end: datetime | None = Field(default=None, description="时间线所覆盖区间的结束时间")
class ApiFormatHealthMonitorResponse(BaseModel):
@@ -1358,19 +1262,13 @@ class PublicApiFormatHealthMonitor(BaseModel):
skipped_count: int = Field(default=0, description="跳过次数")
success_rate: float = Field(default=1.0, description="成功率")
last_event_at: datetime | None = None
events: list[PublicHealthEvent] = Field(
default_factory=list, description="事件列表"
)
events: list[PublicHealthEvent] = Field(default_factory=list, description="事件列表")
timeline: list[str] = Field(
default_factory=list,
description="Usage 表生成的健康时间线(healthy/warning/unhealthy/unknown",
)
time_range_start: datetime | None = Field(
default=None, description="时间线覆盖区间开始时间"
)
time_range_end: datetime | None = Field(
default=None, description="时间线覆盖区间结束时间"
)
time_range_start: datetime | None = Field(default=None, description="时间线覆盖区间开始时间")
time_range_end: datetime | None = Field(default=None, description="时间线覆盖区间结束时间")
class PublicApiFormatHealthMonitorResponse(BaseModel):
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class OAuthStatusSnapshotResponse(BaseModel):
code: str = Field(default="none", description="OAuth 状态代码")
label: str | None = Field(default=None, description="OAuth 状态标签")
reason: str | None = Field(default=None, description="OAuth 状态原因")
expires_at: int | None = Field(default=None, description="OAuth 过期时间(Unix 时间戳)")
invalid_at: int | None = Field(default=None, description="OAuth 失效时间(Unix 时间戳)")
source: str | None = Field(default=None, description="OAuth 状态来源")
requires_reauth: bool = Field(default=False, description="是否需要重新授权")
expiring_soon: bool = Field(default=False, description="是否即将过期")
class AccountStatusSnapshotResponse(BaseModel):
code: str = Field(default="ok", description="账号状态代码")
label: str | None = Field(default=None, description="账号状态标签")
reason: str | None = Field(default=None, description="账号状态原因")
blocked: bool = Field(default=False, description="是否为账号级阻塞")
source: str | None = Field(default=None, description="账号状态来源")
recoverable: bool = Field(default=False, description="是否为可恢复状态")
class QuotaStatusSnapshotResponse(BaseModel):
code: str = Field(default="unknown", description="额度状态代码")
label: str | None = Field(default=None, description="额度状态标签")
reason: str | None = Field(default=None, description="额度状态原因")
exhausted: bool = Field(default=False, description="额度是否耗尽")
usage_ratio: float | None = Field(default=None, description="额度使用比例 [0, 1]")
updated_at: int | None = Field(default=None, description="额度刷新时间(Unix 时间戳)")
reset_seconds: float | None = Field(default=None, description="距离重置剩余秒数")
plan_type: str | None = Field(default=None, description="额度读取到的套餐类型")
class ProviderKeyStatusSnapshotResponse(BaseModel):
oauth: OAuthStatusSnapshotResponse = Field(default_factory=OAuthStatusSnapshotResponse)
account: AccountStatusSnapshotResponse = Field(default_factory=AccountStatusSnapshotResponse)
quota: QuotaStatusSnapshotResponse = Field(default_factory=QuotaStatusSnapshotResponse)
+281
View File
@@ -6,6 +6,8 @@ from upstream metadata and OAuth invalid reasons.
from __future__ import annotations
import re
import time
from dataclasses import dataclass
from typing import Any
@@ -106,6 +108,47 @@ class PoolAccountState:
recoverable: bool = False
@dataclass(frozen=True, slots=True)
class OAuthStatusSnapshot:
code: str = "none" # none / valid / expiring / expired / invalid / check_failed
label: str | None = None
reason: str | None = None
expires_at: int | None = None
invalid_at: int | None = None
source: str | None = None
requires_reauth: bool = False
expiring_soon: bool = False
@dataclass(frozen=True, slots=True)
class AccountStatusSnapshot:
code: str = "ok"
label: str | None = None
reason: str | None = None
blocked: bool = False
source: str | None = None
recoverable: bool = False
@dataclass(frozen=True, slots=True)
class QuotaStatusSnapshot:
code: str = "unknown" # unknown / ok / exhausted
label: str | None = None
reason: str | None = None
exhausted: bool = False
usage_ratio: float | None = None
updated_at: int | None = None
reset_seconds: float | None = None
plan_type: str | None = None
@dataclass(frozen=True, slots=True)
class ProviderKeyStatusSnapshot:
oauth: OAuthStatusSnapshot
account: AccountStatusSnapshot
quota: QuotaStatusSnapshot
def _is_truthy_flag(value: Any) -> bool:
if isinstance(value, bool):
return value
@@ -139,6 +182,26 @@ def _is_workspace_deactivated_reason(reason: str | None) -> bool:
return bool(text and "deactivated_workspace" in text.lower())
_TAGGED_REASON_PATTERN = re.compile(
r"(?:^|\n)\[(?P<tag>[A-Z_]+)\]\s*(?P<detail>.*?)(?=\n\[[A-Z_]+\]|\Z)",
re.S,
)
def _extract_tagged_reason_sections(reason: str | None) -> dict[str, str]:
text = _clean_text(reason)
if not text:
return {}
sections: dict[str, str] = {}
for match in _TAGGED_REASON_PATTERN.finditer(text):
tag = str(match.group("tag") or "").strip().upper()
if not tag or tag in sections:
continue
detail = str(match.group("detail") or "").strip()
sections[tag] = detail
return sections
def _resolve_from_metadata(
provider_type: str | None,
upstream_metadata: Any,
@@ -266,6 +329,216 @@ def _resolve_from_oauth_invalid_reason(reason: str | None) -> PoolAccountState |
return None
def resolve_account_status_snapshot(
*,
provider_type: str | None,
upstream_metadata: Any,
oauth_invalid_reason: str | None,
) -> AccountStatusSnapshot:
from_metadata = _resolve_from_metadata(provider_type, upstream_metadata)
if from_metadata is not None:
return AccountStatusSnapshot(
code=from_metadata.code or "ok",
label=from_metadata.label,
reason=from_metadata.reason,
blocked=from_metadata.blocked,
source=from_metadata.source,
recoverable=from_metadata.recoverable,
)
text = _clean_text(oauth_invalid_reason)
if not text:
return AccountStatusSnapshot()
tagged_sections = _extract_tagged_reason_sections(text)
if "ACCOUNT_BLOCK" in tagged_sections:
cleaned = tagged_sections["ACCOUNT_BLOCK"]
code, label = (
_classify_block_reason(cleaned) if cleaned else ("account_blocked", "账号异常")
)
return AccountStatusSnapshot(
code=code,
label=label,
reason=cleaned or "账号异常",
blocked=True,
source="oauth_invalid",
)
if text.startswith("["):
return AccountStatusSnapshot()
lowered = text.lower()
if any(keyword in lowered for keyword in ACCOUNT_BLOCK_REASON_KEYWORDS):
code, label = _classify_block_reason(text)
return AccountStatusSnapshot(
code=code,
label=label,
reason=text,
blocked=True,
source="oauth_invalid",
)
return AccountStatusSnapshot()
def resolve_oauth_status_snapshot(
*,
auth_type: str | None,
oauth_expires_at: int | None,
oauth_invalid_at: int | None,
oauth_invalid_reason: str | None,
now_ts: int | None = None,
) -> OAuthStatusSnapshot:
if str(auth_type or "").strip().lower() != "oauth":
return OAuthStatusSnapshot()
now = int(now_ts if now_ts is not None else time.time())
invalid_at = int(oauth_invalid_at) if isinstance(oauth_invalid_at, int) else None
tagged_sections = _extract_tagged_reason_sections(oauth_invalid_reason)
raw_reason = _clean_text(oauth_invalid_reason)
expired_reason = tagged_sections.get("OAUTH_EXPIRED")
if expired_reason:
return OAuthStatusSnapshot(
code="invalid",
label="已失效",
reason=expired_reason,
invalid_at=invalid_at,
expires_at=oauth_expires_at,
source="oauth_invalid",
requires_reauth=True,
)
refresh_failed_reason = tagged_sections.get("REFRESH_FAILED")
if refresh_failed_reason:
return OAuthStatusSnapshot(
code="invalid",
label="已失效",
reason=refresh_failed_reason,
invalid_at=invalid_at,
expires_at=oauth_expires_at,
source="oauth_refresh",
requires_reauth=True,
)
request_failed_reason = tagged_sections.get("REQUEST_FAILED")
if request_failed_reason:
return OAuthStatusSnapshot(
code="check_failed",
label="检查失败",
reason=request_failed_reason,
expires_at=oauth_expires_at,
source="oauth_request",
)
account_snapshot = resolve_account_status_snapshot(
provider_type=None,
upstream_metadata=None,
oauth_invalid_reason=raw_reason,
)
if account_snapshot.blocked:
if oauth_expires_at is None:
return OAuthStatusSnapshot()
elif raw_reason or invalid_at is not None:
return OAuthStatusSnapshot(
code="invalid",
label="已失效",
reason=raw_reason,
invalid_at=invalid_at,
expires_at=oauth_expires_at,
source="oauth_invalid",
requires_reauth=True,
)
expires_at = int(oauth_expires_at) if isinstance(oauth_expires_at, int) else None
if expires_at is None:
return OAuthStatusSnapshot()
if expires_at <= now:
return OAuthStatusSnapshot(
code="expired",
label="已过期",
reason="Token 已过期,请重新授权",
expires_at=expires_at,
source="expires_at",
requires_reauth=True,
)
expiring_soon = (expires_at - now) < 24 * 3600
return OAuthStatusSnapshot(
code="expiring" if expiring_soon else "valid",
label="即将过期" if expiring_soon else "有效",
expires_at=expires_at,
source="expires_at",
expiring_soon=expiring_soon,
)
def resolve_quota_status_snapshot(
*,
provider_type: str | None,
upstream_metadata: Any,
) -> QuotaStatusSnapshot:
normalized_provider = str(provider_type or "").strip().lower()
reader = get_quota_reader(normalized_provider, upstream_metadata)
quota_state = reader.is_exhausted()
usage_ratio = reader.usage_ratio()
updated_at = reader.updated_at()
reset_seconds = reader.reset_seconds()
plan_type = reader.plan_type()
if quota_state.exhausted:
return QuotaStatusSnapshot(
code="exhausted",
label="额度耗尽",
reason=quota_state.reason,
exhausted=True,
usage_ratio=usage_ratio,
updated_at=updated_at,
reset_seconds=reset_seconds,
plan_type=plan_type,
)
if any(value is not None for value in (usage_ratio, updated_at, reset_seconds, plan_type)):
return QuotaStatusSnapshot(
code="ok",
exhausted=False,
usage_ratio=usage_ratio,
updated_at=updated_at,
reset_seconds=reset_seconds,
plan_type=plan_type,
)
return QuotaStatusSnapshot()
def build_provider_key_status_snapshot(
*,
auth_type: str | None,
oauth_expires_at: int | None,
oauth_invalid_at: int | None,
oauth_invalid_reason: str | None,
provider_type: str | None,
upstream_metadata: Any,
now_ts: int | None = None,
) -> ProviderKeyStatusSnapshot:
account = resolve_account_status_snapshot(
provider_type=provider_type,
upstream_metadata=upstream_metadata,
oauth_invalid_reason=oauth_invalid_reason,
)
oauth = resolve_oauth_status_snapshot(
auth_type=auth_type,
oauth_expires_at=oauth_expires_at,
oauth_invalid_at=oauth_invalid_at,
oauth_invalid_reason=oauth_invalid_reason,
now_ts=now_ts,
)
quota = resolve_quota_status_snapshot(
provider_type=provider_type,
upstream_metadata=upstream_metadata,
)
return ProviderKeyStatusSnapshot(oauth=oauth, account=account, quota=quota)
def resolve_pool_account_state(
*,
provider_type: str | None,
@@ -303,11 +576,19 @@ def should_auto_remove_account_state(state: PoolAccountState) -> bool:
__all__ = [
"ACCOUNT_BLOCK_REASON_KEYWORDS",
"AUTO_REMOVABLE_ACCOUNT_STATE_CODES",
"AccountStatusSnapshot",
"OAUTH_ACCOUNT_BLOCK_PREFIX",
"OAUTH_EXPIRED_PREFIX",
"OAUTH_REFRESH_FAILED_PREFIX",
"OAUTH_REQUEST_FAILED_PREFIX",
"OAuthStatusSnapshot",
"PoolAccountState",
"ProviderKeyStatusSnapshot",
"QuotaStatusSnapshot",
"build_provider_key_status_snapshot",
"resolve_account_status_snapshot",
"resolve_oauth_status_snapshot",
"resolve_pool_account_state",
"resolve_quota_status_snapshot",
"should_auto_remove_account_state",
]
@@ -204,6 +204,12 @@ def list_provider_keys_responses(
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException(f"Provider {provider_id} 不存在")
provider_type = (
str(
getattr(provider, "provider_type", None) or getattr(provider, "type", None) or ""
).strip()
or None
)
keys = (
db.query(ProviderAPIKey)
@@ -213,7 +219,7 @@ def list_provider_keys_responses(
.limit(limit)
.all()
)
return [build_key_response(key) for key in keys]
return [build_key_response(key, provider_type=provider_type) for key in keys]
def reveal_endpoint_key_payload(
@@ -0,0 +1,25 @@
"""Shared helpers for quota refresh strategies."""
from __future__ import annotations
from typing import Any
from src.models.database import ProviderAPIKey
from src.services.provider.pool.account_state import OAUTH_REFRESH_FAILED_PREFIX
def build_success_state_update(key: ProviderAPIKey) -> dict[str, Any]:
"""配额刷新成功时的 state_updates 构建。
如果当前 key 携带 [REFRESH_FAILED] 标记保留该标记配额刷新不等于 token 刷新成功
"""
current_reason = str(getattr(key, "oauth_invalid_reason", None) or "").strip()
if current_reason.startswith(OAUTH_REFRESH_FAILED_PREFIX):
return {
"oauth_invalid_at": getattr(key, "oauth_invalid_at", None),
"oauth_invalid_reason": current_reason,
}
return {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
}
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.provider.auth import get_provider_auth
from src.services.provider_keys.quota_refresh._helpers import build_success_state_update
async def refresh_antigravity_key_quota(
@@ -110,10 +111,7 @@ async def refresh_antigravity_key_quota(
upstream_meta["antigravity"]["forbidden_reason"] = None
upstream_meta["antigravity"]["forbidden_at"] = None
metadata_updates[key.id] = upstream_meta
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
}
state_updates[key.id] = build_success_state_update(key)
return {
"key_id": key.id,
"key_name": key.name,
@@ -26,6 +26,7 @@ from src.services.provider_keys.codex_usage_parser import (
parse_codex_usage_headers,
parse_codex_wham_usage_response,
)
from src.services.provider_keys.quota_refresh._helpers import build_success_state_update
def _normalize_plan_type(value: Any) -> str | None:
@@ -277,10 +278,7 @@ async def refresh_codex_key_quota(
metadata_updates[key.id] = {
"codex": _build_quota_exhausted_fallback_metadata(oauth_plan_type)
}
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
}
state_updates[key.id] = build_success_state_update(key)
return {
"key_id": key.id,
"key_name": key.name,
@@ -348,10 +346,7 @@ async def refresh_codex_key_quota(
if metadata:
# 收集元数据,稍后统一更新数据库(存储到 codex 子对象)
metadata_updates[key.id] = {"codex": metadata}
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
}
state_updates[key.id] = build_success_state_update(key)
return {
"key_id": key.id,
"key_name": key.name,
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.provider_keys.quota_refresh._helpers import build_success_state_update
async def refresh_kiro_key_quota(
@@ -132,10 +133,7 @@ async def refresh_kiro_key_quota(
metadata["banned_at"] = None
# 收集元数据,稍后统一更新数据库(存储到 kiro 子对象)
metadata_updates[key.id] = {"kiro": metadata}
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
}
state_updates[key.id] = build_success_state_update(key)
# 如果 auth_config 有更新(例如 token 刷新),也需要更新
if updated_auth_config:
+38 -44
View File
@@ -5,7 +5,7 @@ Provider Key 响应对象构建器。
from __future__ import annotations
import json
from datetime import datetime
from dataclasses import asdict
from typing import Any
from src.core.crypto import crypto_service
@@ -14,10 +14,17 @@ from src.core.provider_oauth_utils import normalize_oauth_organizations
from src.models.database import ProviderAPIKey
from src.models.endpoint_models import EndpointAPIKeyResponse
from src.services.provider_keys.auth_type import normalize_auth_type
from src.services.provider_keys.status_snapshot_store import (
normalize_oauth_expires_at,
resolve_provider_key_status_snapshot,
)
def build_key_response(
key: ProviderAPIKey, api_key_plain: str | None = None
key: ProviderAPIKey,
api_key_plain: str | None = None,
*,
provider_type: str | None = None,
) -> EndpointAPIKeyResponse:
"""构建 Key 响应对象。"""
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
@@ -40,9 +47,7 @@ def build_key_response(
masked_key = "***ERROR***"
success_rate = success_count / request_count if request_count > 0 else 0.0
avg_response_time_ms = (
total_response_time_ms / success_count if success_count > 0 else 0.0
)
avg_response_time_ms = total_response_time_ms / success_count if success_count > 0 else 0.0
is_adaptive = rpm_limit is None
key_dict: dict[str, Any] = dict(getattr(key, "__dict__", {}))
@@ -57,66 +62,61 @@ def build_key_response(
oauth_account_id = None
oauth_account_name = None
oauth_account_user_id = None
auth_config: dict[str, Any] | None = None
oauth_organizations: list[dict[str, object]] = []
encrypted_auth_config = key_dict.pop("auth_config", None) # 移除敏感字段,避免泄露
if (
auth_type == "oauth"
and isinstance(encrypted_auth_config, str)
and encrypted_auth_config
):
if auth_type == "oauth" and isinstance(encrypted_auth_config, str) and encrypted_auth_config:
try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
auth_config = json.loads(decrypted_config)
oauth_expires_at = auth_config.get("expires_at")
oauth_expires_at = normalize_oauth_expires_at(auth_config.get("expires_at"))
oauth_email = auth_config.get("email")
oauth_plan_type = auth_config.get(
"plan_type"
) # Codex: plus/free/team/enterprise
oauth_plan_type = auth_config.get("plan_type") # Codex: plus/free/team/enterprise
# Antigravity 使用 "tier" 字段(如 "PAID"/"FREE"),做小写化 fallback
if not oauth_plan_type:
ag_tier = auth_config.get("tier")
if ag_tier and isinstance(ag_tier, str):
oauth_plan_type = ag_tier.lower()
oauth_account_id = auth_config.get(
"account_id"
) # Codex: chatgpt_account_id
oauth_account_id = auth_config.get("account_id") # Codex: chatgpt_account_id
oauth_account_name = auth_config.get("account_name")
oauth_account_user_id = auth_config.get("account_user_id")
oauth_organizations = normalize_oauth_organizations(
auth_config.get("organizations")
)
oauth_organizations = normalize_oauth_organizations(auth_config.get("organizations"))
except Exception as e:
logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e)
if not provider_type:
provider_rel = getattr(key, "provider", None)
provider_type = (
str(getattr(provider_rel, "provider_type", None) or "").strip()
or str(getattr(provider_rel, "type", None) or "").strip()
or None
)
status_snapshot = resolve_provider_key_status_snapshot(
key,
provider_type=provider_type,
auth_config=auth_config,
oauth_expires_at=oauth_expires_at,
)
# 从 health_by_format 计算汇总字段(便于列表展示)
raw_health_by_format = getattr(key, "health_by_format", None)
health_by_format = (
raw_health_by_format if isinstance(raw_health_by_format, dict) else {}
)
health_by_format = raw_health_by_format if isinstance(raw_health_by_format, dict) else {}
raw_circuit_by_format = getattr(key, "circuit_breaker_by_format", None)
circuit_by_format = (
raw_circuit_by_format if isinstance(raw_circuit_by_format, dict) else {}
)
circuit_by_format = raw_circuit_by_format if isinstance(raw_circuit_by_format, dict) else {}
# 计算整体健康度(取所有格式中的最低值)
if health_by_format:
health_scores = [
float(h.get("health_score") or 1.0) for h in health_by_format.values()
]
health_scores = [float(h.get("health_score") or 1.0) for h in health_by_format.values()]
min_health_score = min(health_scores) if health_scores else 1.0
# 取最大的连续失败次数
max_consecutive = max(
(
int(h.get("consecutive_failures") or 0)
for h in health_by_format.values()
),
(int(h.get("consecutive_failures") or 0) for h in health_by_format.values()),
default=0,
)
# 取最近的失败时间
failure_times = [
h.get("last_failure_at")
for h in health_by_format.values()
if h.get("last_failure_at")
h.get("last_failure_at") for h in health_by_format.values() if h.get("last_failure_at")
]
last_failure = max(failure_times) if failure_times else None
else:
@@ -154,15 +154,9 @@ def build_key_response(
"oauth_account_name": oauth_account_name,
"oauth_account_user_id": oauth_account_user_id,
"oauth_organizations": oauth_organizations,
"oauth_invalid_at": (
int(oauth_invalid_at.timestamp())
if isinstance(
(oauth_invalid_at := getattr(key, "oauth_invalid_at", None)),
datetime,
)
else None
),
"oauth_invalid_at": status_snapshot.oauth.invalid_at,
"oauth_invalid_reason": getattr(key, "oauth_invalid_reason", None),
"status_snapshot": asdict(status_snapshot),
}
)
@@ -0,0 +1,293 @@
from __future__ import annotations
import json
from dataclasses import asdict
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.engine import Connection
from src.core.crypto import crypto_service
from src.models.database import Provider, ProviderAPIKey
from src.services.provider.pool.account_state import (
AccountStatusSnapshot,
OAuthStatusSnapshot,
ProviderKeyStatusSnapshot,
QuotaStatusSnapshot,
build_provider_key_status_snapshot,
resolve_oauth_status_snapshot,
)
def _clean_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
return text or None
def _coerce_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y"}
return False
def _coerce_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
text = value.strip()
if not text:
return None
try:
return int(float(text))
except ValueError:
return None
return None
def _coerce_float(value: Any) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
text = value.strip()
if not text:
return None
try:
return float(text)
except ValueError:
return None
return None
def extract_oauth_auth_config(key: ProviderAPIKey) -> dict[str, Any] | None:
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
return None
auth_config_raw = getattr(key, "auth_config", None)
if not auth_config_raw:
return None
try:
decrypted = crypto_service.decrypt(auth_config_raw)
if isinstance(decrypted, str) and decrypted.strip():
parsed = json.loads(decrypted)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return None
def normalize_oauth_expires_at(raw: Any) -> int | None:
value = _coerce_float(raw)
if value is None or value <= 0:
return None
if value > 1_000_000_000_000:
value /= 1000
return int(value)
def hydrate_provider_key_status_snapshot(raw: Any) -> ProviderKeyStatusSnapshot | None:
if not isinstance(raw, dict):
return None
oauth_raw = raw.get("oauth") if isinstance(raw.get("oauth"), dict) else {}
account_raw = raw.get("account") if isinstance(raw.get("account"), dict) else {}
quota_raw = raw.get("quota") if isinstance(raw.get("quota"), dict) else {}
return ProviderKeyStatusSnapshot(
oauth=OAuthStatusSnapshot(
code=_clean_text(oauth_raw.get("code")) or "none",
label=_clean_text(oauth_raw.get("label")),
reason=_clean_text(oauth_raw.get("reason")),
expires_at=_coerce_int(oauth_raw.get("expires_at")),
invalid_at=_coerce_int(oauth_raw.get("invalid_at")),
source=_clean_text(oauth_raw.get("source")),
requires_reauth=_coerce_bool(oauth_raw.get("requires_reauth")),
expiring_soon=_coerce_bool(oauth_raw.get("expiring_soon")),
),
account=AccountStatusSnapshot(
code=_clean_text(account_raw.get("code")) or "ok",
label=_clean_text(account_raw.get("label")),
reason=_clean_text(account_raw.get("reason")),
blocked=_coerce_bool(account_raw.get("blocked")),
source=_clean_text(account_raw.get("source")),
recoverable=_coerce_bool(account_raw.get("recoverable")),
),
quota=QuotaStatusSnapshot(
code=_clean_text(quota_raw.get("code")) or "unknown",
label=_clean_text(quota_raw.get("label")),
reason=_clean_text(quota_raw.get("reason")),
exhausted=_coerce_bool(quota_raw.get("exhausted")),
usage_ratio=_coerce_float(quota_raw.get("usage_ratio")),
updated_at=_coerce_int(quota_raw.get("updated_at")),
reset_seconds=_coerce_float(quota_raw.get("reset_seconds")),
plan_type=_clean_text(quota_raw.get("plan_type")),
),
)
def resolve_provider_type_for_key(
key: ProviderAPIKey,
*,
provider_type: str | None = None,
connection: Connection | None = None,
) -> str | None:
normalized = _clean_text(provider_type)
if normalized:
return normalized
provider_rel = getattr(key, "__dict__", {}).get("provider")
rel_type = _clean_text(getattr(provider_rel, "provider_type", None)) or _clean_text(
getattr(provider_rel, "type", None)
)
if rel_type:
return rel_type
provider_id = _clean_text(getattr(key, "provider_id", None))
if provider_id and connection is not None:
result = connection.execute(
select(Provider.provider_type).where(Provider.id == provider_id)
).scalar_one_or_none()
return _clean_text(result)
return None
def derive_oauth_expires_at(
key: ProviderAPIKey,
*,
auth_config: dict[str, Any] | None = None,
) -> int | None:
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
return None
cfg = auth_config if isinstance(auth_config, dict) else extract_oauth_auth_config(key)
if cfg:
for field in ("expires_at", "expiresAt", "expiry", "exp"):
expires_at = normalize_oauth_expires_at(cfg.get(field))
if expires_at is not None:
return expires_at
expires_dt = getattr(key, "expires_at", None)
if isinstance(expires_dt, datetime):
return int(expires_dt.timestamp())
return None
def resolve_provider_key_status_snapshot(
key: ProviderAPIKey,
*,
provider_type: str | None = None,
connection: Connection | None = None,
auth_config: dict[str, Any] | None = None,
oauth_expires_at: int | None = None,
now_ts: int | None = None,
) -> ProviderKeyStatusSnapshot:
persisted_snapshot = hydrate_provider_key_status_snapshot(getattr(key, "status_snapshot", None))
current_snapshot = _build_snapshot_from_current_fields(
key,
provider_type=provider_type,
connection=connection,
auth_config=auth_config,
oauth_expires_at=oauth_expires_at,
now_ts=now_ts,
)
if persisted_snapshot is None:
return current_snapshot
resolved_oauth_expires_at = current_snapshot.oauth.expires_at
if resolved_oauth_expires_at is None and persisted_snapshot.oauth.expires_at is not None:
resolved_oauth_expires_at = int(persisted_snapshot.oauth.expires_at)
resolved_oauth_invalid_at = current_snapshot.oauth.invalid_at
if resolved_oauth_invalid_at is None and persisted_snapshot.oauth.invalid_at is not None:
resolved_oauth_invalid_at = int(persisted_snapshot.oauth.invalid_at)
oauth_invalid_reason = _clean_text(getattr(key, "oauth_invalid_reason", None)) or (
persisted_snapshot.oauth.reason if persisted_snapshot is not None else None
)
return ProviderKeyStatusSnapshot(
oauth=resolve_oauth_status_snapshot(
auth_type=str(getattr(key, "auth_type", "api_key") or "api_key"),
oauth_expires_at=resolved_oauth_expires_at,
oauth_invalid_at=resolved_oauth_invalid_at,
oauth_invalid_reason=oauth_invalid_reason,
now_ts=now_ts,
),
account=persisted_snapshot.account,
quota=persisted_snapshot.quota,
)
def _build_snapshot_from_current_fields(
key: ProviderAPIKey,
*,
provider_type: str | None = None,
connection: Connection | None = None,
auth_config: dict[str, Any] | None = None,
oauth_expires_at: int | None = None,
now_ts: int | None = None,
) -> ProviderKeyStatusSnapshot:
resolved_provider_type = resolve_provider_type_for_key(
key, provider_type=provider_type, connection=connection
)
oauth_auth_config = (
auth_config if isinstance(auth_config, dict) else extract_oauth_auth_config(key)
)
normalized_oauth_expires_at = normalize_oauth_expires_at(oauth_expires_at)
resolved_oauth_expires_at = (
normalized_oauth_expires_at
if normalized_oauth_expires_at is not None
else derive_oauth_expires_at(
key,
auth_config=oauth_auth_config,
)
)
raw_invalid_at = getattr(key, "oauth_invalid_at", None)
oauth_invalid_at = (
int(raw_invalid_at.timestamp()) if isinstance(raw_invalid_at, datetime) else None
)
oauth_invalid_reason = _clean_text(getattr(key, "oauth_invalid_reason", None))
return build_provider_key_status_snapshot(
auth_type=str(getattr(key, "auth_type", "api_key") or "api_key"),
oauth_expires_at=resolved_oauth_expires_at,
oauth_invalid_at=oauth_invalid_at,
oauth_invalid_reason=oauth_invalid_reason,
provider_type=resolved_provider_type,
upstream_metadata=getattr(key, "upstream_metadata", None),
now_ts=now_ts,
)
def sync_provider_key_status_snapshot(
key: ProviderAPIKey,
*,
provider_type: str | None = None,
connection: Connection | None = None,
auth_config: dict[str, Any] | None = None,
oauth_expires_at: int | None = None,
) -> dict[str, Any]:
snapshot = _build_snapshot_from_current_fields(
key,
provider_type=provider_type,
connection=connection,
auth_config=auth_config,
oauth_expires_at=oauth_expires_at,
)
snapshot_dict = asdict(snapshot)
key.status_snapshot = snapshot_dict
return snapshot_dict