mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
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:
@@ -205,6 +205,7 @@ class TestModelFailoverRequest(BaseModel):
|
||||
mode: str # "global" = 模拟外部请求(用全局模型名), "direct" = 直接测试(用provider_model_name)
|
||||
model_name: str # global 模式传 global_model_name, direct 模式传 provider_model_name
|
||||
api_format: str | None = None # 指定 API 格式(endpoint signature)
|
||||
endpoint_id: str | None = None # 指定仅使用该端点测试
|
||||
message: str | None = "Hello"
|
||||
|
||||
|
||||
@@ -1124,6 +1125,7 @@ async def test_model(
|
||||
def _build_direct_test_candidates(
|
||||
provider: Provider,
|
||||
api_format: str | None = None,
|
||||
endpoint_id: str | None = None,
|
||||
) -> list[ProviderCandidate]:
|
||||
"""
|
||||
为直接测试模式构建候选列表。
|
||||
@@ -1134,6 +1136,8 @@ def _build_direct_test_candidates(
|
||||
|
||||
candidates: list[ProviderCandidate] = []
|
||||
for endpoint in provider.endpoints or []:
|
||||
if endpoint_id and str(getattr(endpoint, "id", "") or "") != str(endpoint_id):
|
||||
continue
|
||||
if not getattr(endpoint, "is_active", False):
|
||||
continue
|
||||
ep_format = str(getattr(endpoint, "api_format", "") or "")
|
||||
@@ -1161,6 +1165,21 @@ def _build_direct_test_candidates(
|
||||
return candidates
|
||||
|
||||
|
||||
def _filter_test_candidates_by_endpoint(
|
||||
candidates: list[ProviderCandidate],
|
||||
endpoint_id: str | None,
|
||||
) -> list[ProviderCandidate]:
|
||||
if not endpoint_id:
|
||||
return list(candidates)
|
||||
|
||||
target_id = str(endpoint_id)
|
||||
return [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if str(getattr(getattr(candidate, "endpoint", None), "id", "") or "") == target_id
|
||||
]
|
||||
|
||||
|
||||
@router.post("/test-model-failover")
|
||||
async def test_model_failover(
|
||||
request: TestModelFailoverRequest,
|
||||
@@ -1198,6 +1217,19 @@ async def test_model_failover(
|
||||
# 2. 构建候选列表
|
||||
candidates = []
|
||||
gm_obj = None # GlobalModel 对象,global 模式下用于 fallback 映射
|
||||
endpoint_by_id = {
|
||||
str(getattr(ep, "id", "") or ""): ep
|
||||
for ep in (provider.endpoints or [])
|
||||
if getattr(ep, "id", None)
|
||||
}
|
||||
requested_endpoint = None
|
||||
if request.endpoint_id:
|
||||
requested_endpoint = endpoint_by_id.get(str(request.endpoint_id))
|
||||
if requested_endpoint is None:
|
||||
raise HTTPException(status_code=404, detail="Endpoint not found")
|
||||
ep_format = str(getattr(requested_endpoint, "api_format", "") or "")
|
||||
if request.api_format and ep_format != request.api_format:
|
||||
raise HTTPException(status_code=400, detail="endpoint_id does not match api_format")
|
||||
|
||||
if request.mode == "global":
|
||||
# 模拟外部请求:走 CandidateBuilder 候选解析
|
||||
@@ -1210,6 +1242,8 @@ async def test_model_failover(
|
||||
|
||||
# 确定 client_format
|
||||
client_format = request.api_format
|
||||
if not client_format and requested_endpoint is not None:
|
||||
client_format = str(getattr(requested_endpoint, "api_format", "") or "")
|
||||
if not client_format:
|
||||
# 取第一个活跃端点的格式
|
||||
for ep in provider.endpoints or []:
|
||||
@@ -1249,11 +1283,13 @@ async def test_model_failover(
|
||||
except Exception as e:
|
||||
logger.warning("[test-model-failover] CandidateBuilder failed: {}", e)
|
||||
candidates = []
|
||||
candidates = _filter_test_candidates_by_endpoint(candidates, request.endpoint_id)
|
||||
else:
|
||||
# 直接测试:简单匹配 Endpoint + Key
|
||||
candidates = _build_direct_test_candidates(
|
||||
provider=provider,
|
||||
api_format=request.api_format,
|
||||
endpoint_id=request.endpoint_id,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
|
||||
@@ -69,6 +69,22 @@ def _get_fixed_provider_template(provider_type: str | None) -> Any | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_new_provider_priority(
|
||||
current_min_priority: int | None, requested_priority: int | None
|
||||
) -> tuple[int, bool]:
|
||||
"""Resolve insertion priority for a newly created provider.
|
||||
|
||||
Returns ``(priority, needs_shift)``. When the caller explicitly specifies
|
||||
a priority we need to shift existing rows; when auto-topping we simply pick
|
||||
``min - 1`` so no shift is required.
|
||||
"""
|
||||
if requested_priority is not None:
|
||||
return int(requested_priority), True
|
||||
if current_min_priority is not None:
|
||||
return int(current_min_priority) - 1, False
|
||||
return 100, False
|
||||
|
||||
|
||||
def _merge_pool_advanced_config(
|
||||
*,
|
||||
provider_config: dict[str, Any] | None,
|
||||
@@ -265,7 +281,7 @@ async def create_provider(request: Request, db: Session = Depends(get_db)) -> An
|
||||
- `quota_reset_day`: 配额重置日期(1-31)(可选)
|
||||
- `quota_last_reset_at`: 上次配额重置时间(可选)
|
||||
- `quota_expires_at`: 配额过期时间(可选)
|
||||
- `provider_priority`: 提供商优先级(数字越小优先级越高,默认 100)
|
||||
- `provider_priority`: 提供商优先级(数字越小优先级越高;不传时自动置顶,并将原有提供商顺延一位)
|
||||
- `is_active`: 是否启用(默认 true)
|
||||
- `concurrent_limit`: 并发限制(可选)
|
||||
- `max_retries`: 最大重试次数(可选)
|
||||
@@ -458,6 +474,20 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
|
||||
failover_rules_in_payload=validated_data.failover_rules is not None,
|
||||
)
|
||||
|
||||
current_min_priority = db.query(func.min(Provider.provider_priority)).scalar()
|
||||
target_priority, needs_shift = _resolve_new_provider_priority(
|
||||
current_min_priority=current_min_priority,
|
||||
requested_priority=validated_data.provider_priority,
|
||||
)
|
||||
if needs_shift:
|
||||
db.query(Provider).filter(
|
||||
Provider.provider_priority.isnot(None),
|
||||
Provider.provider_priority >= target_priority,
|
||||
).update(
|
||||
{Provider.provider_priority: Provider.provider_priority + 1},
|
||||
synchronize_session=False,
|
||||
)
|
||||
|
||||
# 创建 Provider 对象
|
||||
provider = Provider(
|
||||
name=validated_data.name,
|
||||
@@ -469,7 +499,7 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
|
||||
quota_reset_day=validated_data.quota_reset_day,
|
||||
quota_last_reset_at=validated_data.quota_last_reset_at,
|
||||
quota_expires_at=validated_data.quota_expires_at,
|
||||
provider_priority=validated_data.provider_priority,
|
||||
provider_priority=target_priority,
|
||||
keep_priority_on_conversion=validated_data.keep_priority_on_conversion,
|
||||
is_active=validated_data.is_active,
|
||||
concurrent_limit=validated_data.concurrent_limit,
|
||||
|
||||
@@ -394,7 +394,7 @@ class CreateProviderRequest(BaseModel):
|
||||
quota_last_reset_at: datetime | None = Field(None, description="当前周期开始时间")
|
||||
quota_expires_at: datetime | None = Field(None, description="配额过期时间")
|
||||
provider_priority: int | None = Field(
|
||||
100, ge=0, le=10000, description="提供商优先级(数字越小越优先)"
|
||||
None, ge=0, le=10000, description="提供商优先级(数字越小越优先,留空时新建自动置顶)"
|
||||
)
|
||||
keep_priority_on_conversion: bool = Field(
|
||||
False,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user