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

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

View File

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