mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: Codex account_name 透传并优化池列表 OAuth 标识与额度重置倒计时展示
(cherry picked from commit 1b9176fc4a)
This commit is contained in:
@@ -15,7 +15,7 @@ import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import case
|
||||
@@ -53,6 +53,7 @@ from .schemas import (
|
||||
BatchImportError,
|
||||
BatchImportRequest,
|
||||
BatchImportResponse,
|
||||
OAuthOrganizationSummary,
|
||||
PoolKeyDetail,
|
||||
PoolKeySelectionItem,
|
||||
PoolKeySelectionRequest,
|
||||
@@ -81,7 +82,9 @@ async def pool_overview(
|
||||
) -> PoolOverviewResponse:
|
||||
"""Return all pool-enabled providers with summary stats."""
|
||||
adapter = AdminPoolOverviewAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
return await pipeline.run(
|
||||
adapter=adapter, http_request=request, db=db, mode=adapter.mode
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,7 +109,9 @@ async def list_scheduling_presets(
|
||||
"""Return scheduling preset definitions for frontend rendering."""
|
||||
|
||||
adapter = AdminListSchedulingPresetsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
return await pipeline.run(
|
||||
adapter=adapter, http_request=request, db=db, mode=adapter.mode
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -138,7 +143,9 @@ async def list_pool_keys(
|
||||
quick_selectors=quick_selectors.split(",") if quick_selectors else [],
|
||||
search_scope=search_scope,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
return await pipeline.run(
|
||||
adapter=adapter, http_request=request, db=db, mode=adapter.mode
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -155,7 +162,9 @@ async def batch_import_keys(
|
||||
) -> BatchImportResponse:
|
||||
"""Batch import keys into a provider's pool."""
|
||||
adapter = AdminBatchImportKeysAdapter(provider_id=provider_id, body=body)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
return await pipeline.run(
|
||||
adapter=adapter, http_request=request, db=db, mode=adapter.mode
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -186,7 +195,9 @@ def _iter_batches(items: list[str], batch_size: int) -> list[list[str]]:
|
||||
def _resolve_delete_batch_size(db: Session) -> int:
|
||||
try:
|
||||
bind = db.get_bind()
|
||||
dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "") or "").lower()
|
||||
dialect_name = str(
|
||||
getattr(getattr(bind, "dialect", None), "name", "") or ""
|
||||
).lower()
|
||||
except Exception:
|
||||
dialect_name = ""
|
||||
|
||||
@@ -302,7 +313,11 @@ def _derive_oauth_expires_at(
|
||||
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
|
||||
return None
|
||||
|
||||
cfg = auth_config if isinstance(auth_config, dict) else _extract_oauth_auth_config(key)
|
||||
cfg = (
|
||||
auth_config
|
||||
if isinstance(auth_config, dict)
|
||||
else _extract_oauth_auth_config(key)
|
||||
)
|
||||
if cfg:
|
||||
for field in ("expires_at", "expiresAt", "expiry", "exp"):
|
||||
expires_at = _normalize_oauth_expires_at(cfg.get(field))
|
||||
@@ -322,7 +337,9 @@ def _derive_oauth_plan_type(
|
||||
auth_config: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
# Prefer persisted normalized field
|
||||
persisted = _normalize_oauth_plan_type(getattr(key, "oauth_plan_type", None), provider_type)
|
||||
persisted = _normalize_oauth_plan_type(
|
||||
getattr(key, "oauth_plan_type", None), provider_type
|
||||
)
|
||||
if persisted:
|
||||
return persisted
|
||||
|
||||
@@ -330,7 +347,11 @@ def _derive_oauth_plan_type(
|
||||
return None
|
||||
|
||||
# Fallback 1: encrypted auth_config (common for Codex/Antigravity)
|
||||
cfg = auth_config if isinstance(auth_config, dict) else _extract_oauth_auth_config(key)
|
||||
cfg = (
|
||||
auth_config
|
||||
if isinstance(auth_config, dict)
|
||||
else _extract_oauth_auth_config(key)
|
||||
)
|
||||
if cfg:
|
||||
for plan_key in ("plan_type", "tier", "plan", "subscription_plan"):
|
||||
normalized = _normalize_oauth_plan_type(cfg.get(plan_key), provider_type)
|
||||
@@ -349,7 +370,12 @@ def _derive_oauth_plan_type(
|
||||
candidates.append(upstream_metadata)
|
||||
|
||||
for source in candidates:
|
||||
for plan_key in ("plan_type", "tier", "subscription_title", "subscription_plan"):
|
||||
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
|
||||
@@ -366,7 +392,19 @@ def _derive_oauth_account_id(auth_config: dict[str, Any] | None = None) -> str |
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _derive_oauth_account_user_id(auth_config: dict[str, Any] | None = None) -> str | None:
|
||||
def _derive_oauth_account_name(auth_config: dict[str, Any] | None = None) -> str | None:
|
||||
if not isinstance(auth_config, dict):
|
||||
return None
|
||||
raw = auth_config.get("account_name")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
normalized = raw.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _derive_oauth_account_user_id(
|
||||
auth_config: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
if not isinstance(auth_config, dict):
|
||||
return None
|
||||
raw = auth_config.get("account_user_id")
|
||||
@@ -376,10 +414,15 @@ def _derive_oauth_account_user_id(auth_config: dict[str, Any] | None = None) ->
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _derive_oauth_organizations(auth_config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
def _derive_oauth_organizations(
|
||||
auth_config: dict[str, Any] | None = None,
|
||||
) -> list[OAuthOrganizationSummary]:
|
||||
if not isinstance(auth_config, dict):
|
||||
return []
|
||||
return normalize_oauth_organizations(auth_config.get("organizations"))
|
||||
return [
|
||||
OAuthOrganizationSummary(**item)
|
||||
for item in normalize_oauth_organizations(auth_config.get("organizations"))
|
||||
]
|
||||
|
||||
|
||||
def _compute_health_aggregate(
|
||||
@@ -387,7 +430,9 @@ def _compute_health_aggregate(
|
||||
) -> 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 {}
|
||||
circuit_map = (
|
||||
circuit_breaker_by_format if isinstance(circuit_breaker_by_format, dict) else {}
|
||||
)
|
||||
|
||||
if health_map:
|
||||
scores = [
|
||||
@@ -400,7 +445,9 @@ def _compute_health_aggregate(
|
||||
health_score = 1.0
|
||||
|
||||
any_circuit_open = any(
|
||||
bool(item.get("open", False)) for item in circuit_map.values() if isinstance(item, dict)
|
||||
bool(item.get("open", False))
|
||||
for item in circuit_map.values()
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
|
||||
return health_score, any_circuit_open
|
||||
@@ -489,10 +536,14 @@ async def batch_action_keys(
|
||||
) -> BatchActionResponse:
|
||||
"""Batch enable/disable/delete/clear_cooldown/reset_cost/regenerate_fingerprint on pool keys."""
|
||||
adapter = AdminBatchActionKeysAdapter(provider_id=provider_id, body=body)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
return await pipeline.run(
|
||||
adapter=adapter, http_request=request, db=db, mode=adapter.mode
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{provider_id}/keys/resolve-selection", response_model=PoolKeySelectionResponse)
|
||||
@router.post(
|
||||
"/{provider_id}/keys/resolve-selection", response_model=PoolKeySelectionResponse
|
||||
)
|
||||
async def resolve_pool_key_selection(
|
||||
provider_id: str,
|
||||
body: PoolKeySelectionRequest,
|
||||
@@ -501,7 +552,9 @@ async def resolve_pool_key_selection(
|
||||
) -> PoolKeySelectionResponse:
|
||||
"""Resolve all key ids matching the current batch dialog filters."""
|
||||
adapter = AdminResolvePoolKeySelectionAdapter(provider_id=provider_id, body=body)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
return await pipeline.run(
|
||||
adapter=adapter, http_request=request, db=db, mode=adapter.mode
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -515,8 +568,12 @@ async def get_batch_delete_task_status(
|
||||
db: Session = Depends(get_db),
|
||||
) -> BatchDeleteTaskResponse:
|
||||
"""Query the progress of an async batch-delete task."""
|
||||
adapter = AdminBatchDeleteTaskStatusAdapter(provider_id=provider_id, task_id=task_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
adapter = AdminBatchDeleteTaskStatusAdapter(
|
||||
provider_id=provider_id, task_id=task_id
|
||||
)
|
||||
return await pipeline.run(
|
||||
adapter=adapter, http_request=request, db=db, mode=adapter.mode
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{provider_id}/keys/cleanup-banned", response_model=BatchActionResponse)
|
||||
@@ -527,7 +584,9 @@ async def cleanup_banned_keys(
|
||||
) -> 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)
|
||||
return await pipeline.run(
|
||||
adapter=adapter, http_request=request, db=db, mode=adapter.mode
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -600,13 +659,7 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
|
||||
providers = (
|
||||
db.query(Provider)
|
||||
.options(
|
||||
load_only(
|
||||
Provider.id,
|
||||
Provider.name,
|
||||
Provider.provider_type,
|
||||
Provider.provider_priority,
|
||||
Provider.config,
|
||||
)
|
||||
load_only(*cast(tuple[Any, ...], _PROVIDER_OVERVIEW_LOAD_ONLY_ATTRS))
|
||||
)
|
||||
.order_by(Provider.provider_priority.asc())
|
||||
.all()
|
||||
@@ -628,7 +681,9 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
|
||||
ProviderAPIKey.provider_id,
|
||||
func.count(ProviderAPIKey.id).label("total"),
|
||||
func.coalesce(
|
||||
func.sum(case((ProviderAPIKey.is_active.is_(True), 1), else_=0)),
|
||||
func.sum(
|
||||
case((ProviderAPIKey.is_active.is_(True), 1), else_=0)
|
||||
),
|
||||
0,
|
||||
).label("active"),
|
||||
)
|
||||
@@ -651,8 +706,8 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
|
||||
if key_stats_by_provider.get(pid, {}).get("total", 0) > 0
|
||||
]
|
||||
if cooldown_targets:
|
||||
cooldown_count_by_provider = await pool_redis.batch_count_provider_cooldowns(
|
||||
cooldown_targets
|
||||
cooldown_count_by_provider = (
|
||||
await pool_redis.batch_count_provider_cooldowns(cooldown_targets)
|
||||
)
|
||||
|
||||
items: list[PoolOverviewItem] = []
|
||||
@@ -663,8 +718,10 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
|
||||
items.append(
|
||||
PoolOverviewItem(
|
||||
provider_id=pid,
|
||||
provider_name=p.name,
|
||||
provider_type=str(getattr(p, "provider_type", "custom") or "custom"),
|
||||
provider_name=str(getattr(p, "name", "") or ""),
|
||||
provider_type=str(
|
||||
getattr(p, "provider_type", "custom") or "custom"
|
||||
),
|
||||
total_keys=key_stats["total"],
|
||||
active_keys=key_stats["active"],
|
||||
cooldown_count=cooldown_count_by_provider.get(pid, 0),
|
||||
@@ -690,16 +747,65 @@ _ALLOWED_POOL_KEY_QUICK_SELECTORS = frozenset(
|
||||
"enabled",
|
||||
}
|
||||
)
|
||||
_ACCOUNT_BANNED_CODES = frozenset({"account_banned", "account_forbidden", "account_blocked"})
|
||||
_ACCOUNT_BANNED_CODES = frozenset(
|
||||
{"account_banned", "account_forbidden", "account_blocked"}
|
||||
)
|
||||
_BANNED_REASON_PATTERN = re.compile(r"(banned|forbidden|blocked|suspend|封|禁|受限)")
|
||||
|
||||
_PROVIDER_OVERVIEW_LOAD_ONLY_ATTRS: tuple[Any, ...] = (
|
||||
cast(Any, Provider.id),
|
||||
cast(Any, Provider.name),
|
||||
cast(Any, Provider.provider_type),
|
||||
cast(Any, Provider.provider_priority),
|
||||
cast(Any, Provider.config),
|
||||
)
|
||||
|
||||
_POOL_KEY_LOAD_ONLY_ATTRS: tuple[Any, ...] = (
|
||||
cast(Any, ProviderAPIKey.id),
|
||||
cast(Any, ProviderAPIKey.provider_id),
|
||||
cast(Any, ProviderAPIKey.name),
|
||||
cast(Any, ProviderAPIKey.auth_type),
|
||||
cast(Any, ProviderAPIKey.auth_config),
|
||||
cast(Any, ProviderAPIKey.is_active),
|
||||
cast(Any, ProviderAPIKey.expires_at),
|
||||
cast(Any, ProviderAPIKey.oauth_invalid_at),
|
||||
cast(Any, ProviderAPIKey.oauth_invalid_reason),
|
||||
cast(Any, ProviderAPIKey.api_formats),
|
||||
cast(Any, ProviderAPIKey.rate_multipliers),
|
||||
cast(Any, ProviderAPIKey.internal_priority),
|
||||
cast(Any, ProviderAPIKey.rpm_limit),
|
||||
cast(Any, ProviderAPIKey.cache_ttl_minutes),
|
||||
cast(Any, ProviderAPIKey.max_probe_interval_minutes),
|
||||
cast(Any, ProviderAPIKey.note),
|
||||
cast(Any, ProviderAPIKey.allowed_models),
|
||||
cast(Any, ProviderAPIKey.capabilities),
|
||||
cast(Any, ProviderAPIKey.auto_fetch_models),
|
||||
cast(Any, ProviderAPIKey.locked_models),
|
||||
cast(Any, ProviderAPIKey.model_include_patterns),
|
||||
cast(Any, ProviderAPIKey.model_exclude_patterns),
|
||||
cast(Any, ProviderAPIKey.proxy),
|
||||
cast(Any, ProviderAPIKey.fingerprint),
|
||||
cast(Any, ProviderAPIKey.health_by_format),
|
||||
cast(Any, ProviderAPIKey.circuit_breaker_by_format),
|
||||
cast(Any, ProviderAPIKey.request_count),
|
||||
cast(Any, ProviderAPIKey.total_tokens),
|
||||
cast(Any, ProviderAPIKey.total_cost_usd),
|
||||
cast(Any, ProviderAPIKey.last_used_at),
|
||||
cast(Any, ProviderAPIKey.created_at),
|
||||
cast(Any, ProviderAPIKey.upstream_metadata),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_batch_text(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _normalize_pool_search_scope(value: Any) -> str:
|
||||
return _FULL_SEARCH_SCOPE if _normalize_batch_text(value) == _FULL_SEARCH_SCOPE else "name"
|
||||
return (
|
||||
_FULL_SEARCH_SCOPE
|
||||
if _normalize_batch_text(value) == _FULL_SEARCH_SCOPE
|
||||
else "name"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_pool_quick_selectors(values: Any) -> list[str]:
|
||||
@@ -731,7 +837,8 @@ def _get_quota_segments(account_quota: Any) -> list[str]:
|
||||
return [
|
||||
segment
|
||||
for segment in (
|
||||
_normalize_quota_segment(part) for part in str(account_quota or "").split("|")
|
||||
_normalize_quota_segment(part)
|
||||
for part in str(account_quota or "").split("|")
|
||||
)
|
||||
if segment
|
||||
]
|
||||
@@ -739,7 +846,9 @@ def _get_quota_segments(account_quota: Any) -> list[str]:
|
||||
|
||||
def _quota_segment_has_depleted_keyword(segment: str) -> bool:
|
||||
return bool(
|
||||
re.search(r"(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)", segment)
|
||||
re.search(
|
||||
r"(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)", segment
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -791,10 +900,16 @@ def _has_no_weekly_limit(account_quota: Any) -> bool:
|
||||
def _detail_is_oauth_invalid(detail: PoolKeyDetail) -> bool:
|
||||
if _normalize_batch_text(detail.auth_type) != "oauth":
|
||||
return False
|
||||
if detail.oauth_invalid_at is not None or _normalize_batch_text(detail.oauth_invalid_reason):
|
||||
if detail.oauth_invalid_at is not None or _normalize_batch_text(
|
||||
detail.oauth_invalid_reason
|
||||
):
|
||||
return True
|
||||
expires_at = detail.oauth_expires_at
|
||||
return isinstance(expires_at, int) and expires_at > 0 and expires_at <= int(time.time())
|
||||
return (
|
||||
isinstance(expires_at, int)
|
||||
and expires_at > 0
|
||||
and expires_at <= int(time.time())
|
||||
)
|
||||
|
||||
|
||||
def _detail_is_banned(detail: PoolKeyDetail) -> bool:
|
||||
@@ -875,10 +990,13 @@ def _filter_pool_key_details(
|
||||
for detail in details:
|
||||
if require_cooldown and not detail.cooldown_reason:
|
||||
continue
|
||||
if not _matches_pool_key_search(detail, search, search_scope=normalized_search_scope):
|
||||
if not _matches_pool_key_search(
|
||||
detail, search, search_scope=normalized_search_scope
|
||||
):
|
||||
continue
|
||||
if normalized_selectors and not any(
|
||||
_matches_pool_key_quick_selector(detail, selector) for selector in normalized_selectors
|
||||
_matches_pool_key_quick_selector(detail, selector)
|
||||
for selector in normalized_selectors
|
||||
):
|
||||
continue
|
||||
filtered.append(detail)
|
||||
@@ -888,42 +1006,7 @@ def _filter_pool_key_details(
|
||||
def _build_pool_keys_base_query(db: Session, provider_id: str) -> Any:
|
||||
return (
|
||||
db.query(ProviderAPIKey)
|
||||
.options(
|
||||
load_only(
|
||||
ProviderAPIKey.id,
|
||||
ProviderAPIKey.provider_id,
|
||||
ProviderAPIKey.name,
|
||||
ProviderAPIKey.auth_type,
|
||||
ProviderAPIKey.auth_config,
|
||||
ProviderAPIKey.is_active,
|
||||
ProviderAPIKey.expires_at,
|
||||
ProviderAPIKey.oauth_invalid_at,
|
||||
ProviderAPIKey.oauth_invalid_reason,
|
||||
ProviderAPIKey.api_formats,
|
||||
ProviderAPIKey.rate_multipliers,
|
||||
ProviderAPIKey.internal_priority,
|
||||
ProviderAPIKey.rpm_limit,
|
||||
ProviderAPIKey.cache_ttl_minutes,
|
||||
ProviderAPIKey.max_probe_interval_minutes,
|
||||
ProviderAPIKey.note,
|
||||
ProviderAPIKey.allowed_models,
|
||||
ProviderAPIKey.capabilities,
|
||||
ProviderAPIKey.auto_fetch_models,
|
||||
ProviderAPIKey.locked_models,
|
||||
ProviderAPIKey.model_include_patterns,
|
||||
ProviderAPIKey.model_exclude_patterns,
|
||||
ProviderAPIKey.proxy,
|
||||
ProviderAPIKey.fingerprint,
|
||||
ProviderAPIKey.health_by_format,
|
||||
ProviderAPIKey.circuit_breaker_by_format,
|
||||
ProviderAPIKey.request_count,
|
||||
ProviderAPIKey.total_tokens,
|
||||
ProviderAPIKey.total_cost_usd,
|
||||
ProviderAPIKey.last_used_at,
|
||||
ProviderAPIKey.created_at,
|
||||
ProviderAPIKey.upstream_metadata,
|
||||
)
|
||||
)
|
||||
.options(load_only(*_POOL_KEY_LOAD_ONLY_ATTRS))
|
||||
.filter(ProviderAPIKey.provider_id == provider_id)
|
||||
)
|
||||
|
||||
@@ -986,11 +1069,13 @@ async def _serialize_pool_key_details(
|
||||
{},
|
||||
)
|
||||
|
||||
cooldowns_map = cast(dict[str, str | None], cooldowns)
|
||||
|
||||
key_details: list[PoolKeyDetail] = []
|
||||
serialize_started_at = time.perf_counter()
|
||||
for k in keys:
|
||||
kid = str(k.id)
|
||||
cd_reason = cooldowns.get(kid)
|
||||
cd_reason = cooldowns_map.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),
|
||||
@@ -1021,7 +1106,9 @@ async def _serialize_pool_key_details(
|
||||
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),
|
||||
cost_soft_threshold_percent=(
|
||||
pcfg.cost_soft_threshold_percent if pcfg else 80
|
||||
),
|
||||
health_score=health_score,
|
||||
)
|
||||
|
||||
@@ -1077,10 +1164,12 @@ async def _serialize_pool_key_details(
|
||||
key_details.append(
|
||||
PoolKeyDetail(
|
||||
key_id=kid,
|
||||
key_name=k.name or "",
|
||||
key_name=str(getattr(k, "name", "") or ""),
|
||||
is_active=bool(k.is_active),
|
||||
auth_type=str(getattr(k, "auth_type", "api_key") or "api_key"),
|
||||
oauth_expires_at=_derive_oauth_expires_at(k, auth_config=oauth_auth_config),
|
||||
oauth_expires_at=_derive_oauth_expires_at(
|
||||
k, auth_config=oauth_auth_config
|
||||
),
|
||||
oauth_invalid_at=(
|
||||
int(k.oauth_invalid_at.timestamp())
|
||||
if getattr(k, "oauth_invalid_at", None)
|
||||
@@ -1091,6 +1180,7 @@ async def _serialize_pool_key_details(
|
||||
k, provider_type, auth_config=oauth_auth_config
|
||||
),
|
||||
oauth_account_id=_derive_oauth_account_id(oauth_auth_config),
|
||||
oauth_account_name=_derive_oauth_account_name(oauth_auth_config),
|
||||
oauth_account_user_id=_derive_oauth_account_user_id(oauth_auth_config),
|
||||
oauth_organizations=_derive_oauth_organizations(oauth_auth_config),
|
||||
quota_updated_at=_extract_quota_updated_at(
|
||||
@@ -1107,7 +1197,9 @@ async def _serialize_pool_key_details(
|
||||
v if (v := getattr(k, "cache_ttl_minutes", None)) is not None else 5
|
||||
),
|
||||
max_probe_interval_minutes=(
|
||||
v if (v := getattr(k, "max_probe_interval_minutes", None)) is not None else 32
|
||||
v
|
||||
if (v := getattr(k, "max_probe_interval_minutes", None)) is not None
|
||||
else 32
|
||||
),
|
||||
note=getattr(k, "note", None),
|
||||
allowed_models=allowed_models,
|
||||
@@ -1135,8 +1227,12 @@ async def _serialize_pool_key_details(
|
||||
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=(key_last_used_at.isoformat() if key_last_used_at else None),
|
||||
created_at=(
|
||||
k.created_at.isoformat() if getattr(k, "created_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,
|
||||
@@ -1208,12 +1304,18 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||
pcfg = parse_pool_config(getattr(provider, "config", None))
|
||||
pid = str(provider.id)
|
||||
provider_type = str(getattr(provider, "provider_type", "custom") or "custom")
|
||||
normalized_quick_selectors = _normalize_pool_quick_selectors(self.quick_selectors)
|
||||
normalized_quick_selectors = _normalize_pool_quick_selectors(
|
||||
self.quick_selectors
|
||||
)
|
||||
normalized_search_scope = _normalize_pool_search_scope(self.search_scope)
|
||||
|
||||
q = _build_pool_keys_base_query(db, pid)
|
||||
if self.search and normalized_search_scope != _FULL_SEARCH_SCOPE:
|
||||
escaped = self.search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
escaped = (
|
||||
self.search.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
q = q.filter(ProviderAPIKey.name.ilike(f"%{escaped}%"))
|
||||
|
||||
if self.status == "active":
|
||||
@@ -1227,17 +1329,20 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||
or self.status == "cooldown"
|
||||
or (bool(self.search) and normalized_search_scope == _FULL_SEARCH_SCOPE)
|
||||
):
|
||||
filtered_details, keys_query_ms, redis_state_ms, serialize_ms = (
|
||||
await _resolve_filtered_pool_key_details(
|
||||
query=q,
|
||||
pid=pid,
|
||||
provider_type=provider_type,
|
||||
pcfg=pcfg,
|
||||
search=self.search,
|
||||
quick_selectors=normalized_quick_selectors,
|
||||
search_scope=normalized_search_scope,
|
||||
require_cooldown=self.status == "cooldown",
|
||||
)
|
||||
(
|
||||
filtered_details,
|
||||
keys_query_ms,
|
||||
redis_state_ms,
|
||||
serialize_ms,
|
||||
) = await _resolve_filtered_pool_key_details(
|
||||
query=q,
|
||||
pid=pid,
|
||||
provider_type=provider_type,
|
||||
pcfg=pcfg,
|
||||
search=self.search,
|
||||
quick_selectors=normalized_quick_selectors,
|
||||
search_scope=normalized_search_scope,
|
||||
require_cooldown=self.status == "cooldown",
|
||||
)
|
||||
total = len(filtered_details)
|
||||
offset = (self.page - 1) * self.page_size
|
||||
@@ -1250,7 +1355,11 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||
keys_query_started_at = time.perf_counter()
|
||||
keys = _apply_pool_key_order(q).offset(offset).limit(self.page_size).all()
|
||||
keys_query_ms = (time.perf_counter() - keys_query_started_at) * 1000.0
|
||||
key_details, extra_redis_ms, serialize_ms = await _serialize_pool_key_details(
|
||||
(
|
||||
key_details,
|
||||
extra_redis_ms,
|
||||
serialize_ms,
|
||||
) = await _serialize_pool_key_details(
|
||||
keys=keys,
|
||||
pid=pid,
|
||||
provider_type=provider_type,
|
||||
@@ -1326,7 +1435,9 @@ class AdminResolvePoolKeySelectionAdapter(AdminApiAdapter):
|
||||
@dataclass
|
||||
class AdminBatchImportKeysAdapter(AdminApiAdapter):
|
||||
provider_id: str = ""
|
||||
body: BatchImportRequest = field(default_factory=lambda: BatchImportRequest(keys=[]))
|
||||
body: BatchImportRequest = field(
|
||||
default_factory=lambda: BatchImportRequest(keys=[])
|
||||
)
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
@@ -1350,7 +1461,8 @@ class AdminBatchImportKeysAdapter(AdminApiAdapter):
|
||||
try:
|
||||
encrypted_key = crypto_service.encrypt(item.api_key)
|
||||
new_key_id = str(uuid.uuid4())
|
||||
new_key = ProviderAPIKey(
|
||||
provider_api_key_cls = cast(Any, ProviderAPIKey)
|
||||
new_key = provider_api_key_cls(
|
||||
id=new_key_id,
|
||||
provider_id=self.provider_id,
|
||||
name=item.name or f"imported-{idx}",
|
||||
@@ -1457,13 +1569,14 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
|
||||
|
||||
for key in keys:
|
||||
kid = str(key.id)
|
||||
mutable_key = cast(Any, key)
|
||||
|
||||
if self.body.action == "enable":
|
||||
key.is_active = True
|
||||
mutable_key.is_active = True
|
||||
affected += 1
|
||||
|
||||
elif self.body.action == "disable":
|
||||
key.is_active = False
|
||||
mutable_key.is_active = False
|
||||
affected += 1
|
||||
|
||||
elif self.body.action == "clear_cooldown":
|
||||
@@ -1475,15 +1588,15 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
|
||||
affected += 1
|
||||
|
||||
elif self.body.action == "clear_proxy":
|
||||
key.proxy = None
|
||||
mutable_key.proxy = None
|
||||
affected += 1
|
||||
|
||||
elif self.body.action == "set_proxy":
|
||||
key.proxy = self.body.payload
|
||||
mutable_key.proxy = self.body.payload
|
||||
affected += 1
|
||||
|
||||
elif self.body.action == "regenerate_fingerprint":
|
||||
key.fingerprint = generate_fingerprint(seed=None)
|
||||
mutable_key.fingerprint = generate_fingerprint(seed=None)
|
||||
affected += 1
|
||||
|
||||
if self.body.action in {
|
||||
@@ -1498,7 +1611,9 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.error("batch action commit failed: {}", exc)
|
||||
return BatchActionResponse(affected=0, message=f"commit failed: {exc}")
|
||||
return BatchActionResponse(
|
||||
affected=0, message=f"commit failed: {exc}"
|
||||
)
|
||||
|
||||
admin_name = context.user.username if context.user else "admin"
|
||||
affected_ids = [str(k.id)[:8] for k in keys]
|
||||
@@ -1539,7 +1654,9 @@ class AdminCleanupBannedKeysAdapter(AdminApiAdapter):
|
||||
raise NotFoundException("Provider not found", "provider")
|
||||
|
||||
pid = str(provider.id)
|
||||
provider_type = str(getattr(provider, "provider_type", "") or "").strip().lower()
|
||||
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)]
|
||||
|
||||
@@ -85,6 +85,7 @@ class PoolKeyDetail(BaseModel):
|
||||
oauth_invalid_reason: str | None = None
|
||||
oauth_plan_type: str | None = None
|
||||
oauth_account_id: str | None = None
|
||||
oauth_account_name: str | None = None
|
||||
oauth_account_user_id: str | None = None
|
||||
oauth_organizations: list[OAuthOrganizationSummary] = Field(default_factory=list)
|
||||
quota_updated_at: int | None = None
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from typing import Any, Awaitable, Callable
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
|
||||
@@ -9,11 +10,14 @@ import httpx
|
||||
import jwt
|
||||
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
from src.core.logger import logger
|
||||
from src.core.logger import logger # pyright: ignore
|
||||
from src.core.provider_types import ProviderType
|
||||
|
||||
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
|
||||
_OPENAI_ACCOUNTS_CHECK_URL = (
|
||||
"https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
|
||||
)
|
||||
|
||||
|
||||
def _coerce_proxy_url(proxy_config: dict[str, Any] | None) -> str | None:
|
||||
@@ -54,11 +58,15 @@ def _inject_auth_into_url(url: str, username: str, password: str | None = None)
|
||||
if parsed.port:
|
||||
host_part = f"{host_part}:{parsed.port}"
|
||||
auth_part = (
|
||||
f"{encoded_username}:{encoded_password}" if encoded_password else encoded_username
|
||||
f"{encoded_username}:{encoded_password}"
|
||||
if encoded_password
|
||||
else encoded_username
|
||||
)
|
||||
netloc = f"{auth_part}@{host_part}"
|
||||
|
||||
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
||||
return urlunsplit(
|
||||
(parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)
|
||||
)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
@@ -189,7 +197,7 @@ def _tls_client_post_sync(
|
||||
timeout_seconds: float,
|
||||
) -> tuple[int, dict[str, str], str]:
|
||||
# tls-client is optional at runtime; import only when needed.
|
||||
import tls_client # type: ignore
|
||||
import tls_client # pyright: ignore[reportMissingImports]
|
||||
|
||||
session = tls_client.Session(
|
||||
client_identifier="firefox_120",
|
||||
@@ -425,7 +433,10 @@ async def fetch_google_email(
|
||||
try:
|
||||
resp = await client.get(
|
||||
_GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
@@ -439,6 +450,111 @@ async def fetch_google_email(
|
||||
return None
|
||||
|
||||
|
||||
def _extract_openai_account_name(payload: Any, account_id: str) -> str | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
accounts = payload.get("accounts")
|
||||
if isinstance(accounts, dict):
|
||||
direct = accounts.get(account_id)
|
||||
if isinstance(direct, dict):
|
||||
account = direct.get("account")
|
||||
account_info = account if isinstance(account, dict) else direct
|
||||
direct_name = _as_non_empty_str(account_info.get("name"))
|
||||
if direct_name:
|
||||
return direct_name
|
||||
|
||||
for item in accounts.values():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
account = item.get("account")
|
||||
account_info = account if isinstance(account, dict) else item
|
||||
matched_account_id = _first_non_empty_str(
|
||||
[
|
||||
account_info.get("id"),
|
||||
account_info.get("account_id"),
|
||||
account_info.get("accountId"),
|
||||
item.get("id"),
|
||||
item.get("account_id"),
|
||||
item.get("accountId"),
|
||||
]
|
||||
)
|
||||
if matched_account_id != account_id:
|
||||
continue
|
||||
matched_name = _as_non_empty_str(account_info.get("name"))
|
||||
if matched_name:
|
||||
return matched_name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_openai_account_name(
|
||||
access_token: str,
|
||||
account_id: str,
|
||||
*,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> str | None:
|
||||
if not access_token or not account_id:
|
||||
return None
|
||||
|
||||
proxy_url = _coerce_proxy_url(proxy_config)
|
||||
if not proxy_url and proxy_config:
|
||||
try:
|
||||
from src.services.proxy_node.resolver import build_proxy_url_async
|
||||
|
||||
proxy_url = await build_proxy_url_async(proxy_config)
|
||||
except Exception:
|
||||
proxy_url = None
|
||||
|
||||
try:
|
||||
from curl_cffi.requests import AsyncSession # pyright: ignore[reportMissingImports]
|
||||
|
||||
session = AsyncSession(
|
||||
impersonate="chrome110",
|
||||
proxies={"http": proxy_url, "https": proxy_url} if proxy_url else None,
|
||||
timeout=timeout_seconds,
|
||||
verify=False if proxy_url else True,
|
||||
)
|
||||
try:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://chatgpt.com/",
|
||||
"Origin": "https://chatgpt.com",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
for attempt in range(3):
|
||||
if attempt:
|
||||
await asyncio.sleep(
|
||||
[1.0, 2.0][attempt - 1] + random.uniform(0.5, 1.5)
|
||||
)
|
||||
resp = await session.get(_OPENAI_ACCOUNTS_CHECK_URL, headers=headers)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return _extract_openai_account_name(resp.json(), account_id)
|
||||
finally:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
client = await HTTPClientPool.get_proxy_client(proxy_config)
|
||||
try:
|
||||
resp = await client.get(
|
||||
_OPENAI_ACCOUNTS_CHECK_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
return None
|
||||
return _extract_openai_account_name(resp.json(), account_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract_claude_email_from_token_response(token: dict[str, Any]) -> str | None:
|
||||
# CLIProxyAPI expects: { account: { email_address: ... } }
|
||||
try:
|
||||
|
||||
@@ -130,7 +130,8 @@ def _validate_condition(condition: Any, rule_label: str) -> None:
|
||||
op = condition.get("op")
|
||||
if not isinstance(op, str) or op not in _CONDITION_OPS:
|
||||
raise ValueError(
|
||||
f"{rule_label}: condition.op 必须是 {sorted(_CONDITION_OPS)} 之一," f"当前值: {op!r}"
|
||||
f"{rule_label}: condition.op 必须是 {sorted(_CONDITION_OPS)} 之一,"
|
||||
f"当前值: {op!r}"
|
||||
)
|
||||
|
||||
path = condition.get("path")
|
||||
@@ -151,11 +152,15 @@ def _validate_condition(condition: Any, rule_label: str) -> None:
|
||||
# matches 正则校验
|
||||
if op == "matches":
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{rule_label}: condition op=matches 的 value 必须为非空字符串")
|
||||
raise ValueError(
|
||||
f"{rule_label}: condition op=matches 的 value 必须为非空字符串"
|
||||
)
|
||||
try:
|
||||
re.compile(value)
|
||||
except re.error as e:
|
||||
raise ValueError(f"{rule_label}: condition op=matches 的 value 不是合法正则: {e}")
|
||||
raise ValueError(
|
||||
f"{rule_label}: condition op=matches 的 value 不是合法正则: {e}"
|
||||
)
|
||||
|
||||
# in 校验
|
||||
if op == "in":
|
||||
@@ -183,7 +188,10 @@ def _validate_header_rules(rules: list[HeaderRule]) -> list[HeaderRule]:
|
||||
raise ValueError(f"header_rules[{idx}]: 规则必须是 JSON 对象")
|
||||
|
||||
action = rule.get("action")
|
||||
if not isinstance(action, str) or action.strip().lower() not in _HEADER_RULE_ACTIONS:
|
||||
if (
|
||||
not isinstance(action, str)
|
||||
or action.strip().lower() not in _HEADER_RULE_ACTIONS
|
||||
):
|
||||
raise ValueError(
|
||||
f"header_rules[{idx}]: action 必须是 {sorted(_HEADER_RULE_ACTIONS)} 之一,"
|
||||
f"当前值: {action!r}"
|
||||
@@ -233,7 +241,10 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
||||
raise ValueError(f"body_rules[{idx}]: 规则必须是 JSON 对象")
|
||||
|
||||
action = rule.get("action")
|
||||
if not isinstance(action, str) or action.strip().lower() not in _BODY_RULE_ACTIONS:
|
||||
if (
|
||||
not isinstance(action, str)
|
||||
or action.strip().lower() not in _BODY_RULE_ACTIONS
|
||||
):
|
||||
raise ValueError(
|
||||
f"body_rules[{idx}]: action 必须是 {sorted(_BODY_RULE_ACTIONS)} 之一,"
|
||||
f"当前值: {action!r}"
|
||||
@@ -244,7 +255,9 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
||||
if action in {"set", "drop", "append", "insert", "regex_replace", "name_style"}:
|
||||
path = rule.get("path")
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError(f"body_rules[{idx}]: action={action!r} 必须提供非空 path")
|
||||
raise ValueError(
|
||||
f"body_rules[{idx}]: action={action!r} 必须提供非空 path"
|
||||
)
|
||||
|
||||
# ---------- rename 校验 ----------
|
||||
if action == "rename":
|
||||
@@ -265,11 +278,15 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
||||
if action == "regex_replace":
|
||||
pattern = rule.get("pattern")
|
||||
if not isinstance(pattern, str) or not pattern:
|
||||
raise ValueError(f"body_rules[{idx}]: regex_replace 必须提供非空 pattern 字符串")
|
||||
raise ValueError(
|
||||
f"body_rules[{idx}]: regex_replace 必须提供非空 pattern 字符串"
|
||||
)
|
||||
|
||||
replacement = rule.get("replacement", "")
|
||||
if not isinstance(replacement, str):
|
||||
raise ValueError(f"body_rules[{idx}]: regex_replace 的 replacement 必须为字符串")
|
||||
raise ValueError(
|
||||
f"body_rules[{idx}]: regex_replace 的 replacement 必须为字符串"
|
||||
)
|
||||
|
||||
# 校验 flags
|
||||
flags_str = rule.get("flags", "")
|
||||
@@ -295,7 +312,9 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
||||
# 校验 count
|
||||
count = rule.get("count", 0)
|
||||
if not isinstance(count, int) or count < 0:
|
||||
raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数")
|
||||
raise ValueError(
|
||||
f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数"
|
||||
)
|
||||
|
||||
# ---------- name_style 校验 ----------
|
||||
if action == "name_style":
|
||||
@@ -328,7 +347,9 @@ class ProviderEndpointCreate(BaseModel):
|
||||
),
|
||||
)
|
||||
base_url: str = Field(..., min_length=1, max_length=500, description="API 基础 URL")
|
||||
custom_path: str | None = Field(default=None, max_length=200, description="自定义请求路径")
|
||||
custom_path: str | None = Field(
|
||||
default=None, max_length=200, description="自定义请求路径"
|
||||
)
|
||||
|
||||
# 请求头配置
|
||||
header_rules: list[HeaderRule] | None = Field(
|
||||
@@ -360,7 +381,10 @@ class ProviderEndpointCreate(BaseModel):
|
||||
@classmethod
|
||||
def validate_api_format(cls, v: str) -> str:
|
||||
"""验证 API 格式"""
|
||||
from src.core.api_format import list_endpoint_definitions, resolve_endpoint_definition
|
||||
from src.core.api_format import (
|
||||
list_endpoint_definitions,
|
||||
resolve_endpoint_definition,
|
||||
)
|
||||
from src.core.api_format.signature import normalize_signature_key
|
||||
|
||||
normalized = normalize_signature_key(v)
|
||||
@@ -387,7 +411,9 @@ class ProviderEndpointCreate(BaseModel):
|
||||
|
||||
@field_validator("header_rules")
|
||||
@classmethod
|
||||
def validate_header_rules(cls, v: list[HeaderRule] | None) -> list[HeaderRule] | None:
|
||||
def validate_header_rules(
|
||||
cls, v: list[HeaderRule] | None
|
||||
) -> list[HeaderRule] | None:
|
||||
"""校验 header_rules 结构和 condition 合法性"""
|
||||
if v is None:
|
||||
return v
|
||||
@@ -400,7 +426,9 @@ class ProviderEndpointUpdate(BaseModel):
|
||||
base_url: str | None = Field(
|
||||
default=None, min_length=1, max_length=500, description="API 基础 URL"
|
||||
)
|
||||
custom_path: str | None = Field(default=None, max_length=200, description="自定义请求路径")
|
||||
custom_path: str | None = Field(
|
||||
default=None, max_length=200, description="自定义请求路径"
|
||||
)
|
||||
|
||||
# 请求头配置
|
||||
header_rules: list[HeaderRule] | None = Field(
|
||||
@@ -414,7 +442,9 @@ class ProviderEndpointUpdate(BaseModel):
|
||||
description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作",
|
||||
)
|
||||
|
||||
max_retries: int | None = Field(default=None, ge=0, le=999, description="最大重试次数")
|
||||
max_retries: int | None = Field(
|
||||
default=None, ge=0, le=999, description="最大重试次数"
|
||||
)
|
||||
is_active: bool | None = Field(default=None, description="是否启用")
|
||||
config: dict[str, Any] | None = Field(default=None, description="额外配置")
|
||||
proxy: ProxyConfig | None = Field(default=None, description="代理配置")
|
||||
@@ -447,7 +477,9 @@ class ProviderEndpointUpdate(BaseModel):
|
||||
|
||||
@field_validator("header_rules")
|
||||
@classmethod
|
||||
def validate_header_rules(cls, v: list[HeaderRule] | None) -> list[HeaderRule] | None:
|
||||
def validate_header_rules(
|
||||
cls, v: list[HeaderRule] | None
|
||||
) -> list[HeaderRule] | None:
|
||||
"""校验 header_rules 结构和 condition 合法性"""
|
||||
if v is None:
|
||||
return v
|
||||
@@ -467,10 +499,14 @@ class ProviderEndpointResponse(BaseModel):
|
||||
custom_path: str | None = None
|
||||
|
||||
# 请求头配置
|
||||
header_rules: list[HeaderRule] | None = Field(default=None, description="请求头规则列表")
|
||||
header_rules: list[HeaderRule] | None = Field(
|
||||
default=None, description="请求头规则列表"
|
||||
)
|
||||
|
||||
# 请求体配置
|
||||
body_rules: list[BodyRule] | None = Field(default=None, description="请求体规则列表")
|
||||
body_rules: list[BodyRule] | None = Field(
|
||||
default=None, description="请求体规则列表"
|
||||
)
|
||||
|
||||
max_retries: int
|
||||
|
||||
@@ -481,7 +517,9 @@ class ProviderEndpointResponse(BaseModel):
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
# 代理配置(响应中密码已脱敏)
|
||||
proxy: dict[str, Any] | None = Field(default=None, description="代理配置(密码已脱敏)")
|
||||
proxy: dict[str, Any] | None = Field(
|
||||
default=None, description="代理配置(密码已脱敏)"
|
||||
)
|
||||
|
||||
# 格式转换配置
|
||||
format_acceptance_config: dict[str, Any] | None = Field(
|
||||
@@ -506,13 +544,19 @@ class ProviderEndpointResponse(BaseModel):
|
||||
class EndpointAPIKeyCreate(BaseModel):
|
||||
"""为 Provider 添加 API Key"""
|
||||
|
||||
provider_id: str | None = Field(default=None, description="Provider ID(从 URL 获取)")
|
||||
provider_id: str | None = Field(
|
||||
default=None, description="Provider ID(从 URL 获取)"
|
||||
)
|
||||
api_formats: list[str] | None = Field(
|
||||
default=None, min_length=1, description="支持的 endpoint signature 列表(必填,路由层校验)"
|
||||
default=None,
|
||||
min_length=1,
|
||||
description="支持的 endpoint signature 列表(必填,路由层校验)",
|
||||
)
|
||||
|
||||
api_key: str = Field(
|
||||
default="", max_length=10000, description="API Key(标准认证时必填,将自动加密)"
|
||||
default="",
|
||||
max_length=10000,
|
||||
description="API Key(标准认证时必填,将自动加密)",
|
||||
)
|
||||
auth_type: Literal["api_key", "service_account", "oauth"] = Field(
|
||||
default="api_key",
|
||||
@@ -525,7 +569,9 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
|
||||
),
|
||||
)
|
||||
name: str = Field(..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)")
|
||||
name: str = Field(
|
||||
..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)"
|
||||
)
|
||||
|
||||
# 成本计算
|
||||
rate_multipliers: dict[str, float] | None = Field(
|
||||
@@ -534,7 +580,9 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
)
|
||||
|
||||
# 优先级和限制(数字越小越优先)
|
||||
internal_priority: int = Field(default=50, description="Key 内部优先级(提供商优先模式)")
|
||||
internal_priority: int = Field(
|
||||
default=50, description="Key 内部优先级(提供商优先模式)"
|
||||
)
|
||||
# rpm_limit: NULL=自适应模式(系统自动学习),数字=固定限制模式
|
||||
rpm_limit: int | None = Field(
|
||||
default=None, ge=1, le=10000, description="RPM 限制(NULL=自适应模式)"
|
||||
@@ -546,7 +594,8 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
|
||||
# 能力标签
|
||||
capabilities: dict[str, bool] | None = Field(
|
||||
default=None, description="Key 能力标签,如 {'cache_1h': true, 'context_1m': true}"
|
||||
default=None,
|
||||
description="Key 能力标签,如 {'cache_1h': true, 'context_1m': true}",
|
||||
)
|
||||
|
||||
# 缓存与熔断配置
|
||||
@@ -558,11 +607,14 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
)
|
||||
|
||||
# 备注
|
||||
note: str | None = Field(default=None, max_length=500, description="备注说明(可选)")
|
||||
note: str | None = Field(
|
||||
default=None, max_length=500, description="备注说明(可选)"
|
||||
)
|
||||
|
||||
# 自动获取模型
|
||||
auto_fetch_models: bool = Field(
|
||||
default=False, description="是否启用自动获取模型(启用后系统定时从上游 API 获取可用模型)"
|
||||
default=False,
|
||||
description="是否启用自动获取模型(启用后系统定时从上游 API 获取可用模型)",
|
||||
)
|
||||
|
||||
# 锁定的模型列表
|
||||
@@ -585,7 +637,10 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
from src.core.api_format import list_endpoint_definitions, resolve_endpoint_definition
|
||||
from src.core.api_format import (
|
||||
list_endpoint_definitions,
|
||||
resolve_endpoint_definition,
|
||||
)
|
||||
from src.core.api_format.signature import normalize_signature_key
|
||||
|
||||
allowed = [d.signature_key for d in list_endpoint_definitions()]
|
||||
@@ -594,7 +649,9 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
for fmt in v:
|
||||
normalized = normalize_signature_key(fmt)
|
||||
if resolve_endpoint_definition(normalized) is None:
|
||||
raise ValueError(f"api_formats 必须是以下之一: {allowed},当前值: {fmt}")
|
||||
raise ValueError(
|
||||
f"api_formats 必须是以下之一: {allowed},当前值: {fmt}"
|
||||
)
|
||||
if normalized in seen:
|
||||
continue # 静默去重
|
||||
seen.add(normalized)
|
||||
@@ -680,7 +737,9 @@ class EndpointAPIKeyUpdate(BaseModel):
|
||||
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
|
||||
),
|
||||
)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100, description="密钥名称")
|
||||
name: str | None = Field(
|
||||
default=None, min_length=1, max_length=100, description="密钥名称"
|
||||
)
|
||||
rate_multipliers: dict[str, float] | None = Field(
|
||||
default=None,
|
||||
description="按 endpoint signature 的成本倍率,如 {'claude:cli': 1.0, 'openai:cli': 0.8}",
|
||||
@@ -704,7 +763,8 @@ class EndpointAPIKeyUpdate(BaseModel):
|
||||
description="允许使用的模型列表(null=不限制)",
|
||||
)
|
||||
capabilities: dict[str, bool] | None = Field(
|
||||
default=None, description="Key 能力标签,如 {'cache_1h': true, 'context_1m': true}"
|
||||
default=None,
|
||||
description="Key 能力标签,如 {'cache_1h': true, 'context_1m': true}",
|
||||
)
|
||||
cache_ttl_minutes: int | None = Field(
|
||||
default=None, ge=0, le=60, description="缓存 TTL(分钟),0=禁用"
|
||||
@@ -714,7 +774,9 @@ class EndpointAPIKeyUpdate(BaseModel):
|
||||
)
|
||||
is_active: bool | None = Field(default=None, description="是否启用")
|
||||
note: str | None = Field(default=None, max_length=500, description="备注说明")
|
||||
auto_fetch_models: bool | None = Field(default=None, description="是否启用自动获取模型")
|
||||
auto_fetch_models: bool | None = Field(
|
||||
default=None, description="是否启用自动获取模型"
|
||||
)
|
||||
locked_models: list[str] | None = Field(
|
||||
default=None, description="被锁定的模型列表(刷新时不会被删除)"
|
||||
)
|
||||
@@ -803,7 +865,8 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
|
||||
provider_id: str = Field(..., description="Provider ID")
|
||||
api_formats: list[str] = Field(
|
||||
default=[], description="支持的 endpoint signature 列表(如 openai:chat, claude:cli)"
|
||||
default=[],
|
||||
description="支持的 endpoint signature 列表(如 openai:chat, claude:cli)",
|
||||
)
|
||||
|
||||
# Key 信息(脱敏)
|
||||
@@ -828,7 +891,9 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
)
|
||||
rpm_limit: int | None = None
|
||||
allowed_models: list[str] | None = None
|
||||
capabilities: dict[str, bool] | None = Field(default=None, description="Key 能力标签")
|
||||
capabilities: dict[str, bool] | None = Field(
|
||||
default=None, description="Key 能力标签"
|
||||
)
|
||||
|
||||
# OAuth 相关
|
||||
oauth_expires_at: int | None = Field(
|
||||
@@ -839,6 +904,9 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
default=None, description="OAuth 账号套餐类型(如 free/plus/team/enterprise)"
|
||||
)
|
||||
oauth_account_id: str | None = Field(default=None, description="OAuth 账号 ID")
|
||||
oauth_account_name: str | None = Field(
|
||||
default=None, description="OAuth 当前工作区/账号名称"
|
||||
)
|
||||
oauth_account_user_id: str | None = Field(
|
||||
default=None,
|
||||
description="OAuth 账号-工作区联合 ID(如 Codex chatgpt_account_user_id)",
|
||||
@@ -848,13 +916,18 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
description="OAuth 关联的组织/工作区摘要列表",
|
||||
)
|
||||
oauth_invalid_at: int | None = Field(
|
||||
default=None, description="OAuth Token 失效时间(Unix 时间戳),如账号被封、授权撤销等"
|
||||
default=None,
|
||||
description="OAuth Token 失效时间(Unix 时间戳),如账号被封、授权撤销等",
|
||||
)
|
||||
oauth_invalid_reason: str | None = Field(
|
||||
default=None, description="OAuth Token 失效原因"
|
||||
)
|
||||
oauth_invalid_reason: str | None = Field(default=None, description="OAuth Token 失效原因")
|
||||
|
||||
# 缓存与熔断配置
|
||||
cache_ttl_minutes: int = Field(default=5, description="缓存 TTL(分钟),0=禁用")
|
||||
max_probe_interval_minutes: int = Field(default=32, description="熔断探测间隔(分钟)")
|
||||
max_probe_interval_minutes: int = Field(
|
||||
default=32, description="熔断探测间隔(分钟)"
|
||||
)
|
||||
|
||||
# 按 endpoint signature 的健康度数据
|
||||
health_by_format: dict[str, Any] | None = Field(
|
||||
@@ -870,13 +943,23 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
last_failure_at: datetime | None = None
|
||||
|
||||
# 聚合熔断器字段
|
||||
circuit_breaker_open: bool = Field(default=False, description="熔断器是否打开(任何格式)")
|
||||
circuit_breaker_open_at: datetime | None = Field(default=None, description="熔断器打开时间")
|
||||
next_probe_at: datetime | None = Field(default=None, description="下次进入半开状态时间")
|
||||
half_open_until: datetime | None = Field(default=None, description="半开状态结束时间")
|
||||
circuit_breaker_open: bool = Field(
|
||||
default=False, description="熔断器是否打开(任何格式)"
|
||||
)
|
||||
circuit_breaker_open_at: datetime | None = Field(
|
||||
default=None, description="熔断器打开时间"
|
||||
)
|
||||
next_probe_at: datetime | None = Field(
|
||||
default=None, description="下次进入半开状态时间"
|
||||
)
|
||||
half_open_until: datetime | None = Field(
|
||||
default=None, description="半开状态结束时间"
|
||||
)
|
||||
half_open_successes: int | None = Field(default=0, description="半开状态成功次数")
|
||||
half_open_failures: int | None = Field(default=0, description="半开状态失败次数")
|
||||
request_results_window: list[dict] | None = Field(None, description="请求结果滑动窗口")
|
||||
request_results_window: list[dict[str, Any]] | None = Field(
|
||||
None, description="请求结果滑动窗口"
|
||||
)
|
||||
|
||||
# 使用统计
|
||||
request_count: int
|
||||
@@ -889,12 +972,18 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
# 自适应 RPM 信息
|
||||
is_adaptive: bool = Field(default=False, description="是否为自适应模式(rpm_limit=NULL)")
|
||||
is_adaptive: bool = Field(
|
||||
default=False, description="是否为自适应模式(rpm_limit=NULL)"
|
||||
)
|
||||
learned_rpm_limit: int | None = Field(None, description="学习到的 RPM 限制")
|
||||
effective_limit: int | None = Field(None, description="当前有效限制")
|
||||
# 滑动窗口利用率采样
|
||||
utilization_samples: list[dict] | None = Field(None, description="利用率采样窗口")
|
||||
last_probe_increase_at: datetime | None = Field(None, description="上次探测性扩容时间")
|
||||
utilization_samples: list[dict[str, Any]] | None = Field(
|
||||
None, description="利用率采样窗口"
|
||||
)
|
||||
last_probe_increase_at: datetime | None = Field(
|
||||
None, description="上次探测性扩容时间"
|
||||
)
|
||||
concurrent_429_count: int | None = None
|
||||
rpm_429_count: int | None = None
|
||||
last_429_at: datetime | None = None
|
||||
@@ -906,7 +995,9 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
# 自动获取模型
|
||||
auto_fetch_models: bool = Field(default=False, description="是否启用自动获取模型")
|
||||
last_models_fetch_at: datetime | None = Field(None, description="最后获取模型时间")
|
||||
last_models_fetch_error: str | None = Field(None, description="最后获取模型错误信息")
|
||||
last_models_fetch_error: str | None = Field(
|
||||
None, description="最后获取模型错误信息"
|
||||
)
|
||||
locked_models: list[str] | None = Field(None, description="被锁定的模型列表")
|
||||
# 模型过滤规则
|
||||
model_include_patterns: list[str] | None = Field(None, description="模型包含规则")
|
||||
@@ -980,7 +1071,9 @@ class HealthStatusResponse(BaseModel):
|
||||
class HealthSummaryResponse(BaseModel):
|
||||
"""健康状态摘要"""
|
||||
|
||||
endpoints: dict[str, int] = Field(..., description="Endpoint 统计 (total, active, unhealthy)")
|
||||
endpoints: dict[str, int] = Field(
|
||||
..., description="Endpoint 统计 (total, active, unhealthy)"
|
||||
)
|
||||
keys: dict[str, int] = Field(..., description="Key 统计 (total, active, unhealthy)")
|
||||
|
||||
|
||||
@@ -999,13 +1092,17 @@ class KeyPriorityItem(BaseModel):
|
||||
"""单个 Key 优先级项"""
|
||||
|
||||
key_id: str = Field(..., description="Key ID")
|
||||
internal_priority: int = Field(..., ge=0, description="Key 内部优先级(数字越小越优先)")
|
||||
internal_priority: int = Field(
|
||||
..., ge=0, description="Key 内部优先级(数字越小越优先)"
|
||||
)
|
||||
|
||||
|
||||
class BatchUpdateKeyPriorityRequest(BaseModel):
|
||||
"""批量更新 Key 优先级请求"""
|
||||
|
||||
priorities: list[KeyPriorityItem] = Field(..., min_length=1, description="Key 优先级列表")
|
||||
priorities: list[KeyPriorityItem] = Field(
|
||||
..., min_length=1, description="Key 优先级列表"
|
||||
)
|
||||
|
||||
|
||||
# ========== 提供商摘要(增强版) ==========
|
||||
@@ -1017,7 +1114,9 @@ class ProviderUpdateRequest(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
website: str | None = Field(None, max_length=500, description="主站网站")
|
||||
provider_priority: int | None = Field(None, description="提供商优先级(数字越小越优先)")
|
||||
provider_priority: int | None = Field(
|
||||
None, description="提供商优先级(数字越小越优先)"
|
||||
)
|
||||
keep_priority_on_conversion: bool | None = Field(
|
||||
None,
|
||||
description="格式转换时是否保持优先级(True=保持原优先级,False=需要转换时降级)",
|
||||
@@ -1031,7 +1130,9 @@ class ProviderUpdateRequest(BaseModel):
|
||||
None, description="计费类型:monthly_quota/pay_as_you_go/free_tier"
|
||||
)
|
||||
monthly_quota_usd: float | None = Field(None, ge=0, description="订阅配额(美元)")
|
||||
quota_reset_day: int | None = Field(None, ge=1, le=31, description="配额重置日(1-31)")
|
||||
quota_reset_day: int | None = Field(
|
||||
None, ge=1, le=31, description="配额重置日(1-31)"
|
||||
)
|
||||
quota_expires_at: datetime | None = Field(None, description="配额过期时间")
|
||||
# 请求配置(从 Endpoint 迁移)
|
||||
max_retries: int | None = Field(None, ge=0, le=10, description="最大重试次数")
|
||||
@@ -1047,7 +1148,9 @@ class ProviderUpdateRequest(BaseModel):
|
||||
None, description="Claude Code 高级配置"
|
||||
)
|
||||
pool_advanced: PoolAdvancedConfig | None = Field(None, description="通用号池配置")
|
||||
failover_rules: FailoverRulesConfig | None = Field(None, description="故障转移规则配置")
|
||||
failover_rules: FailoverRulesConfig | None = Field(
|
||||
None, description="故障转移规则配置"
|
||||
)
|
||||
|
||||
|
||||
class ProviderWithEndpointsSummary(BaseModel):
|
||||
@@ -1057,11 +1160,14 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider_type: str | None = Field(
|
||||
default=None, description="Provider 类型(custom/claude_code/codex/gemini_cli/antigravity)"
|
||||
default=None,
|
||||
description="Provider 类型(custom/claude_code/codex/gemini_cli/antigravity)",
|
||||
)
|
||||
description: str | None = None
|
||||
website: str | None = None
|
||||
provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)")
|
||||
provider_priority: int = Field(
|
||||
default=100, description="提供商优先级(数字越小越优先)"
|
||||
)
|
||||
keep_priority_on_conversion: bool = Field(
|
||||
default=False,
|
||||
description="格式转换时是否保持优先级(True=保持原优先级,False=需要转换时降级)",
|
||||
@@ -1076,8 +1182,12 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
billing_type: str | None = None
|
||||
monthly_quota_usd: float | None = None
|
||||
monthly_used_usd: float | None = None
|
||||
quota_reset_day: int | None = Field(default=None, description="配额重置周期(天数)")
|
||||
quota_last_reset_at: datetime | None = Field(default=None, description="当前周期开始时间")
|
||||
quota_reset_day: int | None = Field(
|
||||
default=None, description="配额重置周期(天数)"
|
||||
)
|
||||
quota_last_reset_at: datetime | None = Field(
|
||||
default=None, description="当前周期开始时间"
|
||||
)
|
||||
quota_expires_at: datetime | None = Field(default=None, description="配额过期时间")
|
||||
|
||||
# 请求配置(从 Endpoint 迁移)
|
||||
@@ -1087,12 +1197,18 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
stream_first_byte_timeout: float | None = Field(
|
||||
default=None, description="流式请求首字节超时(秒)"
|
||||
)
|
||||
request_timeout: float | None = Field(default=None, description="非流式请求整体超时(秒)")
|
||||
request_timeout: float | None = Field(
|
||||
default=None, description="非流式请求整体超时(秒)"
|
||||
)
|
||||
claude_code_advanced: ClaudeCodeAdvancedConfig | None = Field(
|
||||
default=None, description="Claude Code 高级配置"
|
||||
)
|
||||
pool_advanced: PoolAdvancedConfig | None = Field(default=None, description="通用号池配置")
|
||||
failover_rules: FailoverRulesConfig | None = Field(default=None, description="故障转移规则配置")
|
||||
pool_advanced: PoolAdvancedConfig | None = Field(
|
||||
default=None, description="通用号池配置"
|
||||
)
|
||||
failover_rules: FailoverRulesConfig | None = Field(
|
||||
default=None, description="故障转移规则配置"
|
||||
)
|
||||
|
||||
# Endpoint 统计
|
||||
total_endpoints: int = Field(default=0, description="总 Endpoint 数量")
|
||||
@@ -1105,7 +1221,9 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
# Model 统计
|
||||
total_models: int = Field(default=0, description="总模型数量")
|
||||
active_models: int = Field(default=0, description="活跃模型数量")
|
||||
global_model_ids: list[str] = Field(default=[], description="活跃模型关联的全局模型 ID 列表")
|
||||
global_model_ids: list[str] = Field(
|
||||
default=[], description="活跃模型关联的全局模型 ID 列表"
|
||||
)
|
||||
|
||||
# API 格式列表
|
||||
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")
|
||||
@@ -1123,7 +1241,9 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
)
|
||||
|
||||
# Provider Ops 配置状态
|
||||
ops_configured: bool = Field(default=False, description="是否配置了扩展操作(余额监控等)")
|
||||
ops_configured: bool = Field(
|
||||
default=False, description="是否配置了扩展操作(余额监控等)"
|
||||
)
|
||||
ops_architecture_id: str | None = Field(
|
||||
default=None, description="扩展操作使用的架构 ID(如 cubence, anyrouter)"
|
||||
)
|
||||
@@ -1202,7 +1322,9 @@ class ApiFormatHealthMonitor(BaseModel):
|
||||
time_range_start: datetime | None = Field(
|
||||
default=None, description="时间线所覆盖区间的开始时间"
|
||||
)
|
||||
time_range_end: datetime | None = Field(default=None, description="时间线所覆盖区间的结束时间")
|
||||
time_range_end: datetime | None = Field(
|
||||
default=None, description="时间线所覆盖区间的结束时间"
|
||||
)
|
||||
|
||||
|
||||
class ApiFormatHealthMonitorResponse(BaseModel):
|
||||
@@ -1236,13 +1358,19 @@ class PublicApiFormatHealthMonitor(BaseModel):
|
||||
skipped_count: int = Field(default=0, description="跳过次数")
|
||||
success_rate: float = Field(default=1.0, description="成功率")
|
||||
last_event_at: datetime | None = None
|
||||
events: list[PublicHealthEvent] = Field(default_factory=list, description="事件列表")
|
||||
events: list[PublicHealthEvent] = Field(
|
||||
default_factory=list, description="事件列表"
|
||||
)
|
||||
timeline: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Usage 表生成的健康时间线(healthy/warning/unhealthy/unknown)",
|
||||
)
|
||||
time_range_start: datetime | None = Field(default=None, description="时间线覆盖区间开始时间")
|
||||
time_range_end: datetime | None = Field(default=None, description="时间线覆盖区间结束时间")
|
||||
time_range_start: datetime | None = Field(
|
||||
default=None, description="时间线覆盖区间开始时间"
|
||||
)
|
||||
time_range_end: datetime | None = Field(
|
||||
default=None, description="时间线覆盖区间结束时间"
|
||||
)
|
||||
|
||||
|
||||
class PublicApiFormatHealthMonitorResponse(BaseModel):
|
||||
|
||||
@@ -155,11 +155,14 @@ def build_codex_url(
|
||||
async def enrich_codex(
|
||||
auth_config: dict[str, Any],
|
||||
token_response: dict[str, Any],
|
||||
access_token: str, # noqa: ARG001
|
||||
proxy_config: dict[str, Any] | None, # noqa: ARG001
|
||||
access_token: str,
|
||||
proxy_config: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Codex auth_config enrichment: parse token claims -> account/team identity metadata."""
|
||||
from src.core.provider_oauth_utils import parse_codex_id_token
|
||||
from src.core.provider_oauth_utils import (
|
||||
fetch_openai_account_name,
|
||||
parse_codex_id_token,
|
||||
)
|
||||
|
||||
def _read_non_empty_str(*values: Any) -> str | None:
|
||||
for value in values:
|
||||
@@ -232,6 +235,17 @@ async def enrich_codex(
|
||||
if not auth_config.get(key):
|
||||
auth_config[key] = value
|
||||
|
||||
account_id = _read_non_empty_str(auth_config.get("account_id"))
|
||||
if account_id:
|
||||
account_name = await fetch_openai_account_name(
|
||||
access_token,
|
||||
account_id,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=10.0,
|
||||
)
|
||||
if account_name:
|
||||
auth_config["account_name"] = account_name
|
||||
|
||||
return auth_config
|
||||
|
||||
|
||||
@@ -260,7 +274,9 @@ def register_all() -> None:
|
||||
# Provider Format Capability:默认 body_rules
|
||||
from src.core.api_format.metadata import CODEX_DEFAULT_BODY_RULES
|
||||
|
||||
register_provider_default_body_rules("codex", "openai:cli", CODEX_DEFAULT_BODY_RULES)
|
||||
register_provider_default_body_rules(
|
||||
"codex", "openai:cli", CODEX_DEFAULT_BODY_RULES
|
||||
)
|
||||
|
||||
# Export: Codex uses the default export builder (strip null + temp fields)
|
||||
# No need to register a custom one — the default in export.py suffices.
|
||||
|
||||
@@ -5,6 +5,8 @@ Provider Key 响应对象构建器。
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
@@ -19,6 +21,11 @@ def build_key_response(
|
||||
) -> EndpointAPIKeyResponse:
|
||||
"""构建 Key 响应对象。"""
|
||||
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
|
||||
encrypted_api_key = str(getattr(key, "api_key", "") or "")
|
||||
request_count = int(getattr(key, "request_count", 0) or 0)
|
||||
success_count = int(getattr(key, "success_count", 0) or 0)
|
||||
total_response_time_ms = float(getattr(key, "total_response_time_ms", 0) or 0.0)
|
||||
rpm_limit = getattr(key, "rpm_limit", None)
|
||||
|
||||
if auth_type in ("service_account", "vertex_ai"):
|
||||
# Service Account 不显示占位符
|
||||
@@ -27,18 +34,18 @@ def build_key_response(
|
||||
masked_key = "[OAuth Token]"
|
||||
else:
|
||||
try:
|
||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||
decrypted_key = crypto_service.decrypt(encrypted_api_key)
|
||||
masked_key = f"{decrypted_key[:8]}***{decrypted_key[-4:]}"
|
||||
except Exception:
|
||||
masked_key = "***ERROR***"
|
||||
|
||||
success_rate = key.success_count / key.request_count if key.request_count > 0 else 0.0
|
||||
success_rate = success_count / request_count if request_count > 0 else 0.0
|
||||
avg_response_time_ms = (
|
||||
key.total_response_time_ms / key.success_count if key.success_count > 0 else 0.0
|
||||
total_response_time_ms / success_count if success_count > 0 else 0.0
|
||||
)
|
||||
|
||||
is_adaptive = key.rpm_limit is None
|
||||
key_dict = key.__dict__.copy()
|
||||
is_adaptive = rpm_limit is None
|
||||
key_dict: dict[str, Any] = dict(getattr(key, "__dict__", {}))
|
||||
key_dict.pop("_sa_instance_state", None)
|
||||
key_dict.pop("api_key", None) # 移除敏感字段,避免泄露
|
||||
key_dict["auth_type"] = auth_type
|
||||
@@ -48,43 +55,68 @@ def build_key_response(
|
||||
oauth_email = None
|
||||
oauth_plan_type = None
|
||||
oauth_account_id = None
|
||||
oauth_account_name = None
|
||||
oauth_account_user_id = None
|
||||
oauth_organizations: list[dict[str, object]] = []
|
||||
encrypted_auth_config = key_dict.pop("auth_config", None) # 移除敏感字段,避免泄露
|
||||
if auth_type == "oauth" and encrypted_auth_config:
|
||||
if (
|
||||
auth_type == "oauth"
|
||||
and isinstance(encrypted_auth_config, str)
|
||||
and encrypted_auth_config
|
||||
):
|
||||
try:
|
||||
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
|
||||
auth_config = json.loads(decrypted_config)
|
||||
oauth_expires_at = auth_config.get("expires_at")
|
||||
oauth_email = auth_config.get("email")
|
||||
oauth_plan_type = auth_config.get("plan_type") # Codex: plus/free/team/enterprise
|
||||
oauth_plan_type = auth_config.get(
|
||||
"plan_type"
|
||||
) # Codex: plus/free/team/enterprise
|
||||
# Antigravity 使用 "tier" 字段(如 "PAID"/"FREE"),做小写化 fallback
|
||||
if not oauth_plan_type:
|
||||
ag_tier = auth_config.get("tier")
|
||||
if ag_tier and isinstance(ag_tier, str):
|
||||
oauth_plan_type = ag_tier.lower()
|
||||
oauth_account_id = auth_config.get("account_id") # Codex: chatgpt_account_id
|
||||
oauth_account_id = auth_config.get(
|
||||
"account_id"
|
||||
) # Codex: chatgpt_account_id
|
||||
oauth_account_name = auth_config.get("account_name")
|
||||
oauth_account_user_id = auth_config.get("account_user_id")
|
||||
oauth_organizations = normalize_oauth_organizations(auth_config.get("organizations"))
|
||||
oauth_organizations = normalize_oauth_organizations(
|
||||
auth_config.get("organizations")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e)
|
||||
|
||||
# 从 health_by_format 计算汇总字段(便于列表展示)
|
||||
health_by_format = key.health_by_format or {}
|
||||
circuit_by_format = key.circuit_breaker_by_format or {}
|
||||
raw_health_by_format = getattr(key, "health_by_format", None)
|
||||
health_by_format = (
|
||||
raw_health_by_format if isinstance(raw_health_by_format, dict) else {}
|
||||
)
|
||||
raw_circuit_by_format = getattr(key, "circuit_breaker_by_format", None)
|
||||
circuit_by_format = (
|
||||
raw_circuit_by_format if isinstance(raw_circuit_by_format, dict) else {}
|
||||
)
|
||||
|
||||
# 计算整体健康度(取所有格式中的最低值)
|
||||
if health_by_format:
|
||||
health_scores = [float(h.get("health_score") or 1.0) for h in health_by_format.values()]
|
||||
health_scores = [
|
||||
float(h.get("health_score") or 1.0) for h in health_by_format.values()
|
||||
]
|
||||
min_health_score = min(health_scores) if health_scores else 1.0
|
||||
# 取最大的连续失败次数
|
||||
max_consecutive = max(
|
||||
(int(h.get("consecutive_failures") or 0) for h in health_by_format.values()),
|
||||
(
|
||||
int(h.get("consecutive_failures") or 0)
|
||||
for h in health_by_format.values()
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
# 取最近的失败时间
|
||||
failure_times = [
|
||||
h.get("last_failure_at") for h in health_by_format.values() if h.get("last_failure_at")
|
||||
h.get("last_failure_at")
|
||||
for h in health_by_format.values()
|
||||
if h.get("last_failure_at")
|
||||
]
|
||||
last_failure = max(failure_times) if failure_times else None
|
||||
else:
|
||||
@@ -103,9 +135,11 @@ def build_key_response(
|
||||
"avg_response_time_ms": round(avg_response_time_ms, 2),
|
||||
"is_adaptive": is_adaptive,
|
||||
"effective_limit": (
|
||||
key.learned_rpm_limit # 自适应模式:使用学习值,未学习时为 None(不限制)
|
||||
getattr(
|
||||
key, "learned_rpm_limit", None
|
||||
) # 自适应模式:使用学习值,未学习时为 None(不限制)
|
||||
if is_adaptive
|
||||
else key.rpm_limit
|
||||
else rpm_limit
|
||||
),
|
||||
# 汇总字段
|
||||
"health_score": min_health_score,
|
||||
@@ -117,12 +151,18 @@ def build_key_response(
|
||||
"oauth_email": oauth_email,
|
||||
"oauth_plan_type": oauth_plan_type,
|
||||
"oauth_account_id": oauth_account_id,
|
||||
"oauth_account_name": oauth_account_name,
|
||||
"oauth_account_user_id": oauth_account_user_id,
|
||||
"oauth_organizations": oauth_organizations,
|
||||
"oauth_invalid_at": (
|
||||
int(key.oauth_invalid_at.timestamp()) if key.oauth_invalid_at else None
|
||||
int(oauth_invalid_at.timestamp())
|
||||
if isinstance(
|
||||
(oauth_invalid_at := getattr(key, "oauth_invalid_at", None)),
|
||||
datetime,
|
||||
)
|
||||
else None
|
||||
),
|
||||
"oauth_invalid_reason": key.oauth_invalid_reason,
|
||||
"oauth_invalid_reason": getattr(key, "oauth_invalid_reason", None),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user