feat(admin,pool,billing): 端点级模型测试、Provider 自动置顶、缓存 TTL 分级计费展示与账号状态增强

- 模型测试支持指定端点:新增 ModelTestDialog 组件,多端点时弹窗选择,单端点直接测试;
  后端 test-model-failover 接口新增 endpoint_id 参数,支持 global/direct 模式下按端点过滤候选
- 创建 Provider 时优先级自动置顶(provider_priority 默认 None,后端取 min-1),
  显式指定优先级时 shift 已有行;前端创建时不发送 priority,更新时保留
- 缓存计费 UI 增强:RequestDetailDrawer 支持 5min/1h 缓存创建 token 分级展示,
  含按 TTL 匹配单价和分行成本计算;ModelDetailDrawer/ModelsTab 标签区分 5min/1h 缓存创建
- Pool 批量操作额度筛选拆分为「无5H限额」和「无周限额」,按 | 分隔 segment 匹配
- KeyFormDialog 优化非 vertex_ai 时布局,API 密钥输入内联到 grid 右列
- Codex refresher 结构化错误标记:401/402/403 使用 [OAUTH_EXPIRED]/[ACCOUNT_BLOCK] 前缀,
  新增 deactivated_workspace 识别与分类
- 前后端 accountBlock 关键词同步:新增 token invalidated、deactivated_workspace 识别,
  OAuth 失效提示清理 block 前缀后展示
- PoolConfig 新增 batch_concurrency 配置(默认 8,上限 32)
- 预设模型新增 gpt-5.4;TestResultDialog 响应式布局与 key 脱敏优化
This commit is contained in:
fawney19
2026-03-06 13:13:01 +08:00
parent d17472f09e
commit d97ec3fde2
27 changed files with 1109 additions and 105 deletions

View File

@@ -35,11 +35,18 @@ _KEYWORDS_DISABLED: tuple[str, ...] = (
"account deactivated",
"organization has been disabled",
"organization_disabled",
"deactivated_workspace",
"deactivated",
"访问被禁止",
"账户访问被禁止",
)
_TOKEN_INVALID_KEYWORDS: tuple[str, ...] = (
"authentication token has been invalidated",
"token has been invalidated",
"codex token 无效或已过期",
)
# 需要验证类
_KEYWORDS_VERIFICATION: tuple[str, ...] = (
"validation_required",
@@ -50,6 +57,7 @@ _KEYWORDS_VERIFICATION: tuple[str, ...] = (
ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
*_KEYWORDS_SUSPENDED,
*_KEYWORDS_DISABLED,
*_TOKEN_INVALID_KEYWORDS,
*_KEYWORDS_VERIFICATION,
)
@@ -57,8 +65,12 @@ ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
def _classify_block_reason(text: str) -> tuple[str, str]:
"""Return (code, label) based on the oauth_invalid_reason text."""
lowered = text.lower()
if any(kw in lowered for kw in _TOKEN_INVALID_KEYWORDS):
return "oauth_expired", "Token 失效"
if any(kw in lowered for kw in _KEYWORDS_VERIFICATION):
return "account_verification", "需要验证"
if 'deactivated_workspace' in lowered:
return "workspace_deactivated", "工作区停用"
if any(kw in lowered for kw in _KEYWORDS_DISABLED):
return "account_disabled", "账号停用"
if any(kw in lowered for kw in _KEYWORDS_SUSPENDED):

View File

@@ -82,6 +82,9 @@ class PoolConfig:
# -- Temporary Unschedulable Rules ----------------------------------------
unschedulable_rules: list[UnschedulableRule] = field(default_factory=list)
# -- Batch Operations -----------------------------------------------------
batch_concurrency: int = 8
# -- Quota Probing --------------------------------------------------------
probing_enabled: bool = False
probing_interval_minutes: int = 10
@@ -193,6 +196,7 @@ def parse_pool_config(provider_config: Any) -> PoolConfig | None:
proactive_refresh_seconds=_int_or("proactive_refresh_seconds", 180),
health_policy_enabled=_bool_or("health_policy_enabled", True),
unschedulable_rules=rules,
batch_concurrency=max(1, min(_int_or("batch_concurrency", 8), 32)),
probing_enabled=_bool_or("probing_enabled", False),
probing_interval_minutes=max(1, min(_int_or("probing_interval_minutes", 10), 1440)),
auto_remove_banned_keys=_bool_or("auto_remove_banned_keys", False),

View File

@@ -155,6 +155,12 @@ PRESET_MODELS: dict[str, list[dict[str, Any]]] = {
"owned_by": "openai",
"display_name": "GPT-5.3 Codex",
},
{
"id": "gpt-5.4",
"object": "model",
"owned_by": "openai",
"display_name": "GPT-5.4",
},
],
}

View File

@@ -16,6 +16,10 @@ from src.api.handlers.base.request_builder import get_provider_auth
from src.core.crypto import crypto_service
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.provider_keys.auth_type import normalize_auth_type
from src.services.provider.pool.account_state import (
OAUTH_ACCOUNT_BLOCK_PREFIX,
OAUTH_EXPIRED_PREFIX,
)
from src.services.provider_keys.codex_usage_parser import (
parse_codex_usage_headers,
parse_codex_wham_usage_response,
@@ -63,6 +67,42 @@ def _extract_error_message_from_response(response: httpx.Response) -> str:
return text[:300] if text else ""
def _looks_like_token_invalidated(message: str | None) -> bool:
lowered = str(message or '').strip().lower()
return 'authentication token has been invalidated' in lowered or 'token has been invalidated' in lowered
def _looks_like_account_deactivated(message: str | None) -> bool:
lowered = str(message or '').strip().lower()
return 'account has been deactivated' in lowered or 'account deactivated' in lowered
def _looks_like_workspace_deactivated(message: str | None) -> bool:
lowered = str(message or '').strip().lower()
return 'deactivated_workspace' in lowered or ('workspace' in lowered and 'deactivated' in lowered)
def _build_structured_invalid_reason(*, status_code: int, upstream_message: str | None) -> str:
message = str(upstream_message or '').strip()
if status_code == 402 and _looks_like_workspace_deactivated(message):
return f'{OAUTH_ACCOUNT_BLOCK_PREFIX}工作区已停用 (deactivated_workspace)'
if _looks_like_account_deactivated(message):
detail = message or 'OpenAI 账号已停用'
return f'{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}'
if status_code == 401:
detail = message or 'Codex Token 无效或已过期 (401)'
return f'{OAUTH_EXPIRED_PREFIX}{detail}'
if status_code == 403:
detail = message or 'Codex 账户访问受限 (403)'
return f'{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}'
return message
async def refresh_codex_key_quota(
*,
db: Session,
@@ -146,7 +186,10 @@ async def refresh_codex_key_quota(
state_updates[key.id] = {
"is_active": False,
"oauth_invalid_at": datetime.now(timezone.utc),
"oauth_invalid_reason": "Codex Token 无效或已过期 (401)",
"oauth_invalid_reason": _build_structured_invalid_reason(
status_code=401,
upstream_message=err_msg,
),
}
return {
"key_id": key.id,
@@ -158,6 +201,35 @@ async def refresh_codex_key_quota(
}
if status_code == 402:
if _looks_like_workspace_deactivated(err_msg):
codex_meta = metadata_updates.get(key.id, {}).get('codex')
if not isinstance(codex_meta, dict):
codex_meta = {}
codex_meta = {
**codex_meta,
'updated_at': int(time.time()),
'account_disabled': True,
'reason': 'deactivated_workspace',
'message': err_msg or 'deactivated_workspace',
}
if oauth_plan_type and not codex_meta.get('plan_type'):
codex_meta['plan_type'] = oauth_plan_type
metadata_updates[key.id] = {'codex': codex_meta}
state_updates[key.id] = {
'oauth_invalid_at': datetime.now(timezone.utc),
'oauth_invalid_reason': _build_structured_invalid_reason(
status_code=402,
upstream_message=err_msg,
),
}
return {
'key_id': key.id,
'key_name': key.name,
'status': 'workspace_deactivated',
'message': f"wham/usage API 返回状态码 402{f': {err_msg}' if err_msg else ''}",
'status_code': 402,
}
if key.id not in metadata_updates:
metadata_updates[key.id] = {
"codex": _build_quota_exhausted_fallback_metadata(oauth_plan_type)
@@ -178,7 +250,10 @@ async def refresh_codex_key_quota(
state_updates[key.id] = {
"is_active": False,
"oauth_invalid_at": datetime.now(timezone.utc),
"oauth_invalid_reason": "Codex 账户访问受限 (403)",
"oauth_invalid_reason": _build_structured_invalid_reason(
status_code=403,
upstream_message=err_msg,
),
}
return {
"key_id": key.id,