mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(pool,scheduling): 号池调度维度、配额冷却机制与管理后台重构
- 新增 scheduling_dimensions 模块,为每个 Key 计算多维调度状态(手动/冷却/熔断/成本/健康) - 新增 quota_cooldown 模块,统一判定 Key 的有效冷却原因 - Pool 管理后台 API 扩展 Key 详情字段(调度状态/维度/配额/OAuth 信息) - 前端 Pool 管理页面重写,支持调度状态展示、批量清理封禁 Key - Handler 基类增加请求调度元数据采集,stream telemetry 增强 - 请求时间线组件增强,支持 attempted 候选展示 - Kiro OAuth 凭证导入解析改进 - 新增 usage 表 provider_key 索引迁移 - 补充调度维度、配额冷却、候选枚举等单元测试 Closes #197 Co-authored-by: AAEE86 <33052466+AAEE86@users.noreply.github.com>
This commit is contained in:
@@ -163,13 +163,19 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
|
||||
# 只查询 candidates
|
||||
candidates = RequestCandidateService.get_candidates_by_request_id(db, self.request_id)
|
||||
# 查询所有候选后,默认只展示已发生调度结果的子集:
|
||||
# - 过滤 available/unused(预创建但未实际参与本次调度)
|
||||
# - 若过滤后为空(例如请求尚未开始),回退到全量,避免前端空白
|
||||
all_candidates = RequestCandidateService.get_candidates_by_request_id(db, self.request_id)
|
||||
|
||||
# 如果没有数据,返回 404
|
||||
if not candidates:
|
||||
if not all_candidates:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
candidates = [
|
||||
c for c in all_candidates if c.status not in ("available", "unused")
|
||||
] or all_candidates
|
||||
|
||||
# 计算总延迟(只统计已完成的候选:success, failed, cancelled)
|
||||
# 使用显式的 is not None 检查,避免过滤掉 0ms 的快速响应
|
||||
total_latency = sum(
|
||||
|
||||
@@ -9,12 +9,14 @@ Provides endpoints for managing account pools at scale:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
@@ -24,9 +26,14 @@ from src.core.crypto import crypto_service
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.database import Provider, ProviderAPIKey
|
||||
from src.models.database import Provider, ProviderAPIKey, Usage
|
||||
from src.services.provider.pool import redis_ops as pool_redis
|
||||
from src.services.provider.pool.config import parse_pool_config
|
||||
from src.services.provider.pool.scheduling_dimensions import (
|
||||
PoolSchedulingSnapshot,
|
||||
evaluate_pool_scheduling_dimensions,
|
||||
summarize_pool_scheduling_dimensions,
|
||||
)
|
||||
|
||||
from .schemas import (
|
||||
BatchActionRequest,
|
||||
@@ -38,6 +45,8 @@ from .schemas import (
|
||||
PoolKeysPageResponse,
|
||||
PoolOverviewItem,
|
||||
PoolOverviewResponse,
|
||||
PoolSchedulingDimension,
|
||||
PoolSchedulingReason,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/pool", tags=["pool-management"])
|
||||
@@ -108,6 +117,33 @@ async def batch_import_keys(
|
||||
|
||||
ALLOWED_ACTIONS = {"enable", "disable", "delete", "clear_cooldown", "reset_cost"}
|
||||
|
||||
_COOLDOWN_REASON_LABELS: dict[str, str] = {
|
||||
"rate_limited_429": "429 限流",
|
||||
"forbidden_403": "403 禁止",
|
||||
"overloaded_529": "529 过载",
|
||||
"auth_failed_401": "401 认证失败",
|
||||
"payment_required_402": "402 欠费",
|
||||
"server_error_500": "500 错误",
|
||||
}
|
||||
|
||||
_ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
|
||||
"account_block",
|
||||
"account blocked",
|
||||
"account has been disabled",
|
||||
"account disabled",
|
||||
"organization has been disabled",
|
||||
"organization_disabled",
|
||||
"validation_required",
|
||||
"verify your account",
|
||||
"forbidden",
|
||||
"suspended",
|
||||
"封禁",
|
||||
"封号",
|
||||
"被封",
|
||||
"访问被禁止",
|
||||
"账号异常",
|
||||
)
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
@@ -125,6 +161,67 @@ def _to_float(value: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _is_truthy_flag(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "y"}
|
||||
return False
|
||||
|
||||
|
||||
def _is_known_banned_reason(reason: str | None) -> bool:
|
||||
if not reason:
|
||||
return False
|
||||
|
||||
text = str(reason).strip()
|
||||
if not text:
|
||||
return False
|
||||
lowered = text.lower()
|
||||
|
||||
# 结构化账号级别封禁标记(如 [ACCOUNT_BLOCK] ...)
|
||||
try:
|
||||
from src.services.provider.oauth_token import is_account_level_block
|
||||
|
||||
if is_account_level_block(text):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return any(keyword in lowered for keyword in _ACCOUNT_BLOCK_REASON_KEYWORDS)
|
||||
|
||||
|
||||
def _is_known_banned_key(key: ProviderAPIKey, provider_type: str) -> bool:
|
||||
upstream_metadata = getattr(key, "upstream_metadata", None)
|
||||
normalized_provider = provider_type.strip().lower()
|
||||
provider_bucket: dict[str, Any] | None = None
|
||||
if isinstance(upstream_metadata, dict):
|
||||
maybe_bucket = upstream_metadata.get(normalized_provider)
|
||||
if isinstance(maybe_bucket, dict):
|
||||
provider_bucket = maybe_bucket
|
||||
|
||||
if normalized_provider == "kiro" and provider_bucket:
|
||||
if _is_truthy_flag(provider_bucket.get("is_banned")):
|
||||
return True
|
||||
if normalized_provider == "antigravity" and provider_bucket:
|
||||
if _is_truthy_flag(provider_bucket.get("is_forbidden")):
|
||||
return True
|
||||
|
||||
for source in (provider_bucket, upstream_metadata):
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
if _is_truthy_flag(source.get("is_banned")):
|
||||
return True
|
||||
if _is_truthy_flag(source.get("is_forbidden")):
|
||||
return True
|
||||
if _is_truthy_flag(source.get("account_disabled")):
|
||||
return True
|
||||
|
||||
return _is_known_banned_reason(getattr(key, "oauth_invalid_reason", None))
|
||||
|
||||
|
||||
def _format_percent(value: float) -> str:
|
||||
clamped = max(0.0, min(value, 100.0))
|
||||
return f"{clamped:.1f}%"
|
||||
@@ -177,11 +274,7 @@ def _build_kiro_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
|
||||
remaining = 100.0 - usage_percentage
|
||||
current_usage = _to_float(kiro.get("current_usage"))
|
||||
usage_limit = _to_float(kiro.get("usage_limit"))
|
||||
if (
|
||||
current_usage is not None
|
||||
and usage_limit is not None
|
||||
and usage_limit > 0
|
||||
):
|
||||
if current_usage is not None and usage_limit is not None and usage_limit > 0:
|
||||
return (
|
||||
f"剩余 {_format_percent(remaining)} "
|
||||
f"({_format_quota_value(current_usage)}/{_format_quota_value(usage_limit)})"
|
||||
@@ -247,6 +340,215 @@ def _build_account_quota(provider_type: str, upstream_metadata: Any) -> str | No
|
||||
return None
|
||||
|
||||
|
||||
def _extract_quota_updated_at(provider_type: str, upstream_metadata: Any) -> int | None:
|
||||
if not isinstance(upstream_metadata, dict):
|
||||
return None
|
||||
|
||||
normalized_type = provider_type.strip().lower()
|
||||
if normalized_type == "codex":
|
||||
source = upstream_metadata.get("codex")
|
||||
elif normalized_type == "antigravity":
|
||||
source = upstream_metadata.get("antigravity")
|
||||
elif normalized_type == "kiro":
|
||||
source = upstream_metadata.get("kiro")
|
||||
else:
|
||||
return None
|
||||
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
|
||||
updated_at = _to_float(source.get("updated_at"))
|
||||
if updated_at is None or updated_at <= 0:
|
||||
return None
|
||||
|
||||
# 部分上游可能返回毫秒时间戳,统一转换为秒
|
||||
if updated_at > 1_000_000_000_000:
|
||||
updated_at /= 1000
|
||||
|
||||
return int(updated_at)
|
||||
|
||||
|
||||
def _normalize_oauth_plan_type(plan_type: Any, provider_type: str) -> str | None:
|
||||
if not isinstance(plan_type, str):
|
||||
return None
|
||||
text = plan_type.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
ptype = provider_type.strip().lower()
|
||||
if ptype and text.lower().startswith(ptype):
|
||||
trimmed = text[len(ptype) :].strip(" :-_")
|
||||
if trimmed:
|
||||
text = trimmed
|
||||
|
||||
return text or None
|
||||
|
||||
|
||||
def _derive_oauth_plan_type(key: ProviderAPIKey, provider_type: str) -> str | None:
|
||||
# Prefer persisted normalized field
|
||||
persisted = _normalize_oauth_plan_type(getattr(key, "oauth_plan_type", None), provider_type)
|
||||
if persisted:
|
||||
return persisted
|
||||
|
||||
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
|
||||
return None
|
||||
|
||||
# Fallback 1: encrypted auth_config (common for Codex/Antigravity)
|
||||
auth_config_raw = getattr(key, "auth_config", None)
|
||||
if auth_config_raw:
|
||||
try:
|
||||
decrypted = crypto_service.decrypt(auth_config_raw)
|
||||
auth_config = json.loads(decrypted)
|
||||
if isinstance(auth_config, dict):
|
||||
for plan_key in ("plan_type", "tier", "plan", "subscription_plan"):
|
||||
normalized = _normalize_oauth_plan_type(
|
||||
auth_config.get(plan_key), provider_type
|
||||
)
|
||||
if normalized:
|
||||
return normalized
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback 2: upstream_metadata
|
||||
upstream_metadata = getattr(key, "upstream_metadata", None)
|
||||
if not isinstance(upstream_metadata, dict):
|
||||
return None
|
||||
|
||||
provider_bucket = upstream_metadata.get(provider_type.strip().lower())
|
||||
candidates: list[dict[str, Any]] = []
|
||||
if isinstance(provider_bucket, dict):
|
||||
candidates.append(provider_bucket)
|
||||
candidates.append(upstream_metadata)
|
||||
|
||||
for source in candidates:
|
||||
for plan_key in ("plan_type", "tier", "subscription_title", "subscription_plan"):
|
||||
normalized = _normalize_oauth_plan_type(source.get(plan_key), provider_type)
|
||||
if normalized:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def _compute_health_aggregate(
|
||||
health_by_format: Any, circuit_breaker_by_format: Any
|
||||
) -> tuple[float, bool]:
|
||||
"""从按格式健康数据聚合出列表展示字段。"""
|
||||
health_map = health_by_format if isinstance(health_by_format, dict) else {}
|
||||
circuit_map = circuit_breaker_by_format if isinstance(circuit_breaker_by_format, dict) else {}
|
||||
|
||||
if health_map:
|
||||
scores = [
|
||||
float(item.get("health_score") or 1.0)
|
||||
for item in health_map.values()
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
health_score = min(scores) if scores else 1.0
|
||||
else:
|
||||
health_score = 1.0
|
||||
|
||||
any_circuit_open = any(
|
||||
bool(item.get("open", False)) for item in circuit_map.values() if isinstance(item, dict)
|
||||
)
|
||||
|
||||
return health_score, any_circuit_open
|
||||
|
||||
|
||||
def _format_cooldown_detail(raw: str | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
return _COOLDOWN_REASON_LABELS.get(raw, raw)
|
||||
|
||||
|
||||
def _build_pool_scheduling_state(
|
||||
*,
|
||||
is_active: bool,
|
||||
cooldown_reason: str | None,
|
||||
cooldown_ttl_seconds: int | None,
|
||||
circuit_breaker_open: bool,
|
||||
cost_window_usage: int,
|
||||
cost_limit: int | None,
|
||||
cost_soft_threshold_percent: int,
|
||||
health_score: float,
|
||||
) -> tuple[
|
||||
str,
|
||||
str,
|
||||
str,
|
||||
list[PoolSchedulingReason],
|
||||
float,
|
||||
bool,
|
||||
int,
|
||||
int,
|
||||
list[PoolSchedulingDimension],
|
||||
]:
|
||||
"""Build unified scheduling state for frontend display."""
|
||||
snapshot = PoolSchedulingSnapshot(
|
||||
is_active=is_active,
|
||||
cooldown_reason=cooldown_reason,
|
||||
cooldown_ttl_seconds=cooldown_ttl_seconds,
|
||||
circuit_breaker_open=circuit_breaker_open,
|
||||
cost_window_usage=cost_window_usage,
|
||||
cost_limit=cost_limit,
|
||||
cost_soft_threshold_percent=cost_soft_threshold_percent,
|
||||
health_score=health_score,
|
||||
)
|
||||
dimensions_raw = evaluate_pool_scheduling_dimensions(snapshot)
|
||||
summary = summarize_pool_scheduling_dimensions(dimensions_raw)
|
||||
|
||||
scheduling_dimensions: list[PoolSchedulingDimension] = []
|
||||
scheduling_reasons: list[PoolSchedulingReason] = []
|
||||
|
||||
for item in dimensions_raw:
|
||||
detail = item.detail
|
||||
if item.code == "cooldown":
|
||||
detail = _format_cooldown_detail(detail)
|
||||
|
||||
model = PoolSchedulingDimension(
|
||||
code=item.code,
|
||||
label=item.label,
|
||||
status=item.status,
|
||||
blocking=bool(item.blocking or item.status == "blocked"),
|
||||
source=item.source,
|
||||
weight=item.weight,
|
||||
score=item.score,
|
||||
ttl_seconds=item.ttl_seconds,
|
||||
detail=detail,
|
||||
)
|
||||
scheduling_dimensions.append(model)
|
||||
|
||||
if item.status != "ok":
|
||||
scheduling_reasons.append(
|
||||
PoolSchedulingReason(
|
||||
code=item.code,
|
||||
label=item.label,
|
||||
blocking=bool(item.blocking or item.status == "blocked"),
|
||||
source=item.source,
|
||||
ttl_seconds=item.ttl_seconds,
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
summary.status,
|
||||
summary.reason,
|
||||
summary.label,
|
||||
scheduling_reasons,
|
||||
summary.score,
|
||||
summary.candidate_eligible,
|
||||
summary.blocked_count,
|
||||
summary.degraded_count,
|
||||
scheduling_dimensions,
|
||||
)
|
||||
|
||||
|
||||
def _mask_proxy_password(proxy_config: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(proxy_config, dict):
|
||||
return None
|
||||
masked = dict(proxy_config)
|
||||
password = masked.get("password")
|
||||
if isinstance(password, str) and password:
|
||||
masked["password"] = "******"
|
||||
return masked
|
||||
|
||||
|
||||
@router.post("/{provider_id}/keys/batch-action", response_model=BatchActionResponse)
|
||||
async def batch_action_keys(
|
||||
provider_id: str,
|
||||
@@ -259,6 +561,17 @@ async def batch_action_keys(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/{provider_id}/keys/cleanup-banned", response_model=BatchActionResponse)
|
||||
async def cleanup_banned_keys(
|
||||
provider_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> BatchActionResponse:
|
||||
"""Delete known banned/suspended accounts for the provider."""
|
||||
adapter = AdminCleanupBannedKeysAdapter(provider_id=provider_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapters
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -380,29 +693,135 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||
if pcfg
|
||||
else asyncio.sleep(0, result={})
|
||||
)
|
||||
cooldowns, cooldown_ttls, lru_scores, cost_totals = await asyncio.gather(
|
||||
cooldowns, cooldown_ttls, lru_scores, cost_totals, sticky_counts = await asyncio.gather(
|
||||
pool_redis.batch_get_cooldowns(pid, key_ids),
|
||||
pool_redis.batch_get_cooldown_ttls(pid, key_ids),
|
||||
_lru_coro,
|
||||
_cost_coro,
|
||||
pool_redis.batch_get_key_sticky_counts(pid, key_ids),
|
||||
)
|
||||
else:
|
||||
cooldowns, cooldown_ttls, lru_scores, cost_totals = {}, {}, {}, {}
|
||||
|
||||
# Sticky session count per key is expensive (SCAN+MGET per key).
|
||||
# Only compute when the page is small enough to avoid timeout.
|
||||
sticky_counts: dict[str, int] = {}
|
||||
if key_ids and len(key_ids) <= 30:
|
||||
counts = await asyncio.gather(
|
||||
*(pool_redis.get_key_sticky_count(pid, kid) for kid in key_ids)
|
||||
cooldowns, cooldown_ttls, lru_scores, cost_totals, sticky_counts = (
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
)
|
||||
sticky_counts = dict(zip(key_ids, counts))
|
||||
|
||||
usage_stats_by_key: dict[str, dict[str, Any]] = {}
|
||||
if key_ids:
|
||||
usage_rows = (
|
||||
db.query(
|
||||
Usage.provider_api_key_id.label("key_id"),
|
||||
func.count(Usage.id).label("request_count"),
|
||||
func.coalesce(func.sum(Usage.total_tokens), 0).label("total_tokens"),
|
||||
func.coalesce(func.sum(Usage.total_cost_usd), 0.0).label("total_cost_usd"),
|
||||
func.max(Usage.created_at).label("last_used_at"),
|
||||
)
|
||||
.filter(
|
||||
Usage.provider_id == pid,
|
||||
Usage.provider_api_key_id.in_(key_ids),
|
||||
Usage.status.notin_(["pending", "streaming"]),
|
||||
)
|
||||
.group_by(Usage.provider_api_key_id)
|
||||
.all()
|
||||
)
|
||||
usage_stats_by_key = {
|
||||
str(row.key_id): {
|
||||
"request_count": int(row.request_count or 0),
|
||||
"total_tokens": int(row.total_tokens or 0),
|
||||
"total_cost_usd": float(row.total_cost_usd or 0.0),
|
||||
"last_used_at": getattr(row, "last_used_at", None),
|
||||
}
|
||||
for row in usage_rows
|
||||
if getattr(row, "key_id", None)
|
||||
}
|
||||
|
||||
key_details: list[PoolKeyDetail] = []
|
||||
for k in keys:
|
||||
kid = str(k.id)
|
||||
cd_reason = cooldowns.get(kid)
|
||||
cd_ttl = cooldown_ttls.get(kid) if cd_reason else None
|
||||
health_score, any_circuit_open = _compute_health_aggregate(
|
||||
getattr(k, "health_by_format", None),
|
||||
getattr(k, "circuit_breaker_by_format", None),
|
||||
)
|
||||
cost_usage = int(cost_totals.get(kid, 0) or 0)
|
||||
cost_limit = pcfg.cost_limit_per_key_tokens if pcfg else None
|
||||
(
|
||||
scheduling_status,
|
||||
scheduling_reason,
|
||||
scheduling_label,
|
||||
scheduling_reasons,
|
||||
scheduling_score,
|
||||
candidate_eligible,
|
||||
scheduling_blocked_count,
|
||||
scheduling_degraded_count,
|
||||
scheduling_dimensions,
|
||||
) = _build_pool_scheduling_state(
|
||||
is_active=bool(k.is_active),
|
||||
cooldown_reason=cd_reason,
|
||||
cooldown_ttl_seconds=cd_ttl,
|
||||
circuit_breaker_open=any_circuit_open,
|
||||
cost_window_usage=cost_usage,
|
||||
cost_limit=cost_limit,
|
||||
cost_soft_threshold_percent=(pcfg.cost_soft_threshold_percent if pcfg else 80),
|
||||
health_score=health_score,
|
||||
)
|
||||
|
||||
raw_allowed_models = getattr(k, "allowed_models", None)
|
||||
allowed_models = (
|
||||
[str(item) for item in raw_allowed_models]
|
||||
if isinstance(raw_allowed_models, list)
|
||||
else None
|
||||
)
|
||||
raw_locked_models = getattr(k, "locked_models", None)
|
||||
locked_models = (
|
||||
[str(item) for item in raw_locked_models]
|
||||
if isinstance(raw_locked_models, list)
|
||||
else None
|
||||
)
|
||||
raw_include_patterns = getattr(k, "model_include_patterns", None)
|
||||
include_patterns = (
|
||||
[str(item) for item in raw_include_patterns]
|
||||
if isinstance(raw_include_patterns, list)
|
||||
else None
|
||||
)
|
||||
raw_exclude_patterns = getattr(k, "model_exclude_patterns", None)
|
||||
exclude_patterns = (
|
||||
[str(item) for item in raw_exclude_patterns]
|
||||
if isinstance(raw_exclude_patterns, list)
|
||||
else None
|
||||
)
|
||||
capabilities = (
|
||||
{str(name): bool(enabled) for name, enabled in k.capabilities.items()}
|
||||
if isinstance(getattr(k, "capabilities", None), dict)
|
||||
else None
|
||||
)
|
||||
rate_multipliers: dict[str, float] | None = None
|
||||
if isinstance(getattr(k, "rate_multipliers", None), dict):
|
||||
converted: dict[str, float] = {}
|
||||
for fmt, raw_val in k.rate_multipliers.items():
|
||||
num_val = _to_float(raw_val)
|
||||
if num_val is None:
|
||||
continue
|
||||
converted[str(fmt)] = num_val
|
||||
rate_multipliers = converted or None
|
||||
api_formats = (
|
||||
[str(fmt) for fmt in getattr(k, "api_formats", []) if isinstance(fmt, str)]
|
||||
if isinstance(getattr(k, "api_formats", None), list)
|
||||
else []
|
||||
)
|
||||
key_usage_stats = usage_stats_by_key.get(kid, {})
|
||||
key_request_count = int(
|
||||
key_usage_stats.get("request_count") or getattr(k, "request_count", 0) or 0
|
||||
)
|
||||
key_total_tokens = int(key_usage_stats.get("total_tokens") or 0)
|
||||
key_total_cost_usd = float(key_usage_stats.get("total_cost_usd") or 0.0)
|
||||
key_last_used_at = getattr(k, "last_used_at", None) or key_usage_stats.get(
|
||||
"last_used_at"
|
||||
)
|
||||
|
||||
key_details.append(
|
||||
PoolKeyDetail(
|
||||
@@ -410,22 +829,66 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||
key_name=k.name or "",
|
||||
is_active=bool(k.is_active),
|
||||
auth_type=str(getattr(k, "auth_type", "api_key") or "api_key"),
|
||||
oauth_expires_at=(
|
||||
int(k.oauth_expires_at.timestamp())
|
||||
if getattr(k, "oauth_expires_at", None)
|
||||
else None
|
||||
),
|
||||
oauth_invalid_at=(
|
||||
int(k.oauth_invalid_at.timestamp())
|
||||
if getattr(k, "oauth_invalid_at", None)
|
||||
else None
|
||||
),
|
||||
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
||||
oauth_plan_type=_derive_oauth_plan_type(k, provider_type),
|
||||
quota_updated_at=_extract_quota_updated_at(
|
||||
provider_type,
|
||||
getattr(k, "upstream_metadata", None),
|
||||
),
|
||||
health_score=health_score,
|
||||
circuit_breaker_open=any_circuit_open,
|
||||
api_formats=api_formats,
|
||||
rate_multipliers=rate_multipliers,
|
||||
internal_priority=int(getattr(k, "internal_priority", 50) or 50),
|
||||
rpm_limit=getattr(k, "rpm_limit", None),
|
||||
cache_ttl_minutes=int(getattr(k, "cache_ttl_minutes", 5) or 5),
|
||||
max_probe_interval_minutes=int(
|
||||
getattr(k, "max_probe_interval_minutes", 32) or 32
|
||||
),
|
||||
note=getattr(k, "note", None),
|
||||
allowed_models=allowed_models,
|
||||
capabilities=capabilities,
|
||||
auto_fetch_models=bool(getattr(k, "auto_fetch_models", False)),
|
||||
locked_models=locked_models,
|
||||
model_include_patterns=include_patterns,
|
||||
model_exclude_patterns=exclude_patterns,
|
||||
proxy=_mask_proxy_password(getattr(k, "proxy", None)),
|
||||
account_quota=_build_account_quota(
|
||||
provider_type,
|
||||
getattr(k, "upstream_metadata", None),
|
||||
),
|
||||
cooldown_reason=cd_reason,
|
||||
cooldown_ttl_seconds=cd_ttl,
|
||||
cost_window_usage=cost_totals.get(kid, 0),
|
||||
cost_limit=pcfg.cost_limit_per_key_tokens if pcfg else None,
|
||||
cost_window_usage=cost_usage,
|
||||
cost_limit=cost_limit,
|
||||
request_count=key_request_count,
|
||||
total_tokens=key_total_tokens,
|
||||
total_cost_usd=key_total_cost_usd,
|
||||
sticky_sessions=sticky_counts.get(kid, 0),
|
||||
lru_score=lru_scores.get(kid),
|
||||
created_at=(
|
||||
k.created_at.isoformat() if getattr(k, "created_at", None) else None
|
||||
),
|
||||
last_used_at=(
|
||||
k.last_used_at.isoformat() if getattr(k, "last_used_at", None) else None
|
||||
),
|
||||
last_used_at=(key_last_used_at.isoformat() if key_last_used_at else None),
|
||||
scheduling_status=scheduling_status,
|
||||
scheduling_reason=scheduling_reason,
|
||||
scheduling_label=scheduling_label,
|
||||
scheduling_reasons=scheduling_reasons,
|
||||
scheduling_score=scheduling_score,
|
||||
candidate_eligible=candidate_eligible,
|
||||
scheduling_blocked_count=scheduling_blocked_count,
|
||||
scheduling_degraded_count=scheduling_degraded_count,
|
||||
scheduling_dimensions=scheduling_dimensions,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -447,6 +910,9 @@ class AdminBatchImportKeysAdapter(AdminApiAdapter):
|
||||
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider not found", "provider")
|
||||
key_proxy: dict[str, Any] | None = None
|
||||
if self.body.proxy_node_id and self.body.proxy_node_id.strip():
|
||||
key_proxy = {"node_id": self.body.proxy_node_id.strip(), "enabled": True}
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
@@ -466,6 +932,7 @@ class AdminBatchImportKeysAdapter(AdminApiAdapter):
|
||||
name=item.name or f"imported-{idx}",
|
||||
api_key=encrypted_key,
|
||||
auth_type=item.auth_type or "api_key",
|
||||
proxy=key_proxy,
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -591,3 +1058,56 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
|
||||
affected=affected,
|
||||
message=f"{affected} keys {action_labels.get(self.body.action, self.body.action)}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminCleanupBannedKeysAdapter(AdminApiAdapter):
|
||||
provider_id: str = ""
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider not found", "provider")
|
||||
|
||||
pid = str(provider.id)
|
||||
provider_type = str(getattr(provider, "provider_type", "") or "").strip().lower()
|
||||
|
||||
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == pid).all()
|
||||
banned_keys = [key for key in keys if _is_known_banned_key(key, provider_type)]
|
||||
|
||||
if not banned_keys:
|
||||
return BatchActionResponse(affected=0, message="未发现已知封号账号")
|
||||
|
||||
banned_key_ids = [str(key.id) for key in banned_keys]
|
||||
for key in banned_keys:
|
||||
db.delete(key)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.error("cleanup banned keys commit failed: {}", exc)
|
||||
return BatchActionResponse(affected=0, message=f"commit failed: {exc}")
|
||||
|
||||
# 清理 Redis 中可能残留的状态,避免删除后仍有旧状态占用资源。
|
||||
cleanup_coros = []
|
||||
for kid in banned_key_ids:
|
||||
cleanup_coros.append(pool_redis.clear_cooldown(pid, kid))
|
||||
cleanup_coros.append(pool_redis.clear_cost(pid, kid))
|
||||
if cleanup_coros:
|
||||
await asyncio.gather(*cleanup_coros, return_exceptions=True)
|
||||
|
||||
admin_name = context.user.username if context.user else "admin"
|
||||
logger.warning(
|
||||
"Pool cleanup banned by {}: provider={}, affected={}, key_ids={}",
|
||||
admin_name,
|
||||
self.provider_id[:8],
|
||||
len(banned_key_ids),
|
||||
[kid[:8] for kid in banned_key_ids],
|
||||
)
|
||||
|
||||
return BatchActionResponse(
|
||||
affected=len(banned_key_ids),
|
||||
message=f"已清理 {len(banned_key_ids)} 个已知封号账号",
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -32,6 +34,31 @@ class PoolOverviewResponse(BaseModel):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PoolSchedulingReason(BaseModel):
|
||||
"""Structured scheduling reason for a key."""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
blocking: bool = False
|
||||
source: str = "pool" # manual / pool / health / policy
|
||||
ttl_seconds: int | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class PoolSchedulingDimension(BaseModel):
|
||||
"""Detailed scheduling dimension status."""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
status: str = "ok" # ok / degraded / blocked
|
||||
blocking: bool = False
|
||||
source: str = "pool"
|
||||
weight: int = 1
|
||||
score: float = 1.0 # normalized 0~1
|
||||
ttl_seconds: int | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class PoolKeyDetail(BaseModel):
|
||||
"""Detailed status of a single pool key."""
|
||||
|
||||
@@ -39,15 +66,50 @@ 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_plan_type: str | None = None
|
||||
quota_updated_at: int | None = None
|
||||
# 健康度聚合字段(与 Provider Key 列表口径一致)
|
||||
health_score: float = 1.0
|
||||
circuit_breaker_open: bool = False
|
||||
# 编辑/权限/代理所需字段
|
||||
api_formats: list[str] = Field(default_factory=list)
|
||||
rate_multipliers: dict[str, float] | None = None
|
||||
internal_priority: int = 50
|
||||
rpm_limit: int | None = None
|
||||
cache_ttl_minutes: int = 5
|
||||
max_probe_interval_minutes: int = 32
|
||||
note: str | None = None
|
||||
allowed_models: list[str] | None = None
|
||||
capabilities: dict[str, bool] | None = None
|
||||
auto_fetch_models: bool = False
|
||||
locked_models: list[str] | None = None
|
||||
model_include_patterns: list[str] | None = None
|
||||
model_exclude_patterns: list[str] | None = None
|
||||
proxy: dict[str, Any] | None = None
|
||||
account_quota: str | None = None
|
||||
cooldown_reason: str | None = None
|
||||
cooldown_ttl_seconds: int | None = None
|
||||
cost_window_usage: int = 0
|
||||
cost_limit: int | None = None
|
||||
request_count: int = 0
|
||||
total_tokens: int = 0
|
||||
total_cost_usd: float = 0.0
|
||||
sticky_sessions: int = 0
|
||||
lru_score: float | None = None
|
||||
created_at: str | None = None
|
||||
last_used_at: str | None = None
|
||||
scheduling_status: str = "available" # available / degraded / blocked
|
||||
scheduling_reason: str = "available"
|
||||
scheduling_label: str = "可用"
|
||||
scheduling_reasons: list[PoolSchedulingReason] = Field(default_factory=list)
|
||||
scheduling_score: float = 100.0
|
||||
candidate_eligible: bool = True
|
||||
scheduling_blocked_count: int = 0
|
||||
scheduling_degraded_count: int = 0
|
||||
scheduling_dimensions: list[PoolSchedulingDimension] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -76,6 +138,10 @@ class PoolKeyImportItem(BaseModel):
|
||||
|
||||
class BatchImportRequest(BaseModel):
|
||||
keys: list[PoolKeyImportItem] = Field(..., max_length=500)
|
||||
proxy_node_id: str | None = Field(
|
||||
default=None,
|
||||
description="导入时绑定到账号的代理节点 ID(可选)",
|
||||
)
|
||||
|
||||
|
||||
class BatchImportError(BaseModel):
|
||||
|
||||
@@ -1368,6 +1368,61 @@ def _parse_kiro_import_input(raw_input: str) -> list[dict[str, Any]]:
|
||||
|
||||
返回: 凭据字典列表
|
||||
"""
|
||||
|
||||
def _normalize_item(item: Any) -> dict[str, Any] | None:
|
||||
"""规范化单条 Kiro 导入项,兼容导出结构。"""
|
||||
if isinstance(item, str) and item.strip():
|
||||
return {"refreshToken": item.strip()}
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
nested = item.get("auth_config") or item.get("authConfig")
|
||||
if isinstance(nested, dict):
|
||||
# 优先使用 auth_config,兼容导出对象形态:
|
||||
# {"name": "...", "auth_config": {...}, ...}
|
||||
merged = dict(nested)
|
||||
# 若顶层也包含关键字段,允许覆盖 nested(便于手工修正)
|
||||
for key in (
|
||||
"provider_type",
|
||||
"providerType",
|
||||
"auth_method",
|
||||
"authMethod",
|
||||
"auth_type",
|
||||
"authType",
|
||||
"refresh_token",
|
||||
"refreshToken",
|
||||
"expires_at",
|
||||
"expiresAt",
|
||||
"profile_arn",
|
||||
"profileArn",
|
||||
"region",
|
||||
"auth_region",
|
||||
"authRegion",
|
||||
"api_region",
|
||||
"apiRegion",
|
||||
"client_id",
|
||||
"clientId",
|
||||
"client_secret",
|
||||
"clientSecret",
|
||||
"machine_id",
|
||||
"machineId",
|
||||
"kiro_version",
|
||||
"kiroVersion",
|
||||
"system_version",
|
||||
"systemVersion",
|
||||
"node_version",
|
||||
"nodeVersion",
|
||||
"email",
|
||||
"access_token",
|
||||
"accessToken",
|
||||
):
|
||||
value = item.get(key)
|
||||
if value is not None and value != "":
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
return item
|
||||
|
||||
raw = raw_input.strip()
|
||||
if not raw:
|
||||
return []
|
||||
@@ -1380,18 +1435,15 @@ def _parse_kiro_import_input(raw_input: str) -> list[dict[str, Any]]:
|
||||
if isinstance(parsed, list):
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in parsed:
|
||||
if isinstance(item, dict):
|
||||
result.append(item)
|
||||
elif isinstance(item, str) and item.strip():
|
||||
result.append({"refreshToken": item.strip()})
|
||||
normalized = _normalize_item(item)
|
||||
if normalized:
|
||||
result.append(normalized)
|
||||
return result
|
||||
|
||||
if isinstance(parsed, dict):
|
||||
# 兼容嵌套格式: {"auth_config": {...}} / {"authConfig": {...}}
|
||||
nested = parsed.get("auth_config") or parsed.get("authConfig")
|
||||
if isinstance(nested, dict):
|
||||
return [nested]
|
||||
return [parsed]
|
||||
normalized = _normalize_item(parsed)
|
||||
if normalized:
|
||||
return [normalized]
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user