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:
fawney19
2026-03-03 09:22:20 +08:00
parent f787b1b02a
commit 11997c024e
40 changed files with 4419 additions and 823 deletions

View File

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

View File

@@ -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)} 个已知封号账号",
)

View File

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

View File

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