feat: Codex account_name 透传并优化池列表 OAuth 标识与额度重置倒计时展示

(cherry picked from commit 1b9176fc4a)
This commit is contained in:
kayphoon
2026-03-18 20:58:33 +08:00
committed by fawney19
parent 8d8cddcef6
commit 6e55968487
16 changed files with 888 additions and 251 deletions

View File

@@ -104,6 +104,7 @@ export interface PoolKeyDetail {
oauth_plan_type?: string | null oauth_plan_type?: string | null
oauth_account_id?: string | null oauth_account_id?: string | null
oauth_account_user_id?: string | null oauth_account_user_id?: string | null
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null oauth_organizations?: OAuthOrganizationInfo[] | null
quota_updated_at?: number | null quota_updated_at?: number | null
health_score?: number health_score?: number

View File

@@ -288,6 +288,7 @@ export interface EndpointAPIKey {
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
oauth_account_id?: string | null // Codex ChatGPT 账号 ID oauth_account_id?: string | null // Codex ChatGPT 账号 ID
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要 oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
oauth_invalid_at?: number | null // OAuth Token 失效时间Unix 时间戳) oauth_invalid_at?: number | null // OAuth Token 失效时间Unix 时间戳)
oauth_invalid_reason?: string | null // OAuth Token 失效原因 oauth_invalid_reason?: string | null // OAuth Token 失效原因

View File

@@ -1,5 +1,10 @@
import type { OAuthOrganizationInfo } from '@/api/endpoints/types/provider' import type { OAuthOrganizationInfo } from '@/api/endpoints/types/provider'
type OAuthIdentityDisplayValue = {
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null
} | null | undefined
function formatOAuthIdentityShort( function formatOAuthIdentityShort(
value: string | null | undefined, value: string | null | undefined,
head = 8, head = 8,
@@ -11,24 +16,44 @@ function formatOAuthIdentityShort(
return `${normalized.slice(0, head)}...${normalized.slice(-tail)}` return `${normalized.slice(0, head)}...${normalized.slice(-tail)}`
} }
function getPrimaryOAuthOrganizationId( function getPrimaryOAuthOrganization(
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined, value: OAuthIdentityDisplayValue,
): string | null { ): { id: string; title: string } | null {
const organizations = Array.isArray(value?.oauth_organizations) ? value.oauth_organizations : [] const organizations: OAuthOrganizationInfo[] = Array.isArray(value?.oauth_organizations)
const defaultOrg = organizations.find( ? value.oauth_organizations
(org) => org?.is_default && typeof org?.id === 'string' && org.id.trim(), : []
) let firstWithId: OAuthOrganizationInfo | null = null
if (defaultOrg?.id) return defaultOrg.id.trim()
const firstWithId = organizations.find( for (let index = 0; index < organizations.length; index += 1) {
(org) => typeof org?.id === 'string' && org.id.trim(), const org = organizations[index]
) if (typeof org?.id !== 'string' || !org.id.trim()) continue
return firstWithId?.id?.trim() || null if (!firstWithId) firstWithId = org
if (org.is_default) {
firstWithId = org
break
}
}
if (!firstWithId?.id) return null
return {
id: firstWithId.id.trim(),
title: typeof firstWithId.title === 'string' ? firstWithId.title.trim() : '',
}
} }
export function getOAuthOrgBadge( export function getOAuthOrgBadge(
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined, value: OAuthIdentityDisplayValue,
): { id: string; label: string } | null { ): { id: string; label: string } | null {
const id = getPrimaryOAuthOrganizationId(value) const org = getPrimaryOAuthOrganization(value)
if (!id) return null if (!org) return null
return { id, label: formatOAuthIdentityShort(id) }
const accountName = typeof value?.oauth_account_name === 'string'
? value.oauth_account_name.trim()
: ''
return {
id: org.id,
label: accountName || org.title || formatOAuthIdentityShort(org.id),
}
} }

View File

@@ -516,7 +516,7 @@
:key="`${key.key_id}-quota-${idx}`" :key="`${key.key_id}-quota-${idx}`"
class="w-full" class="w-full"
> >
<div class="min-h-4 grid grid-cols-[20px_minmax(0,1fr)_42px] items-center gap-1 text-[10px] leading-tight"> <div class="min-h-4 grid grid-cols-[20px_minmax(0,1fr)_80px] items-center gap-1 text-[10px] leading-tight">
<span <span
class="text-muted-foreground whitespace-nowrap text-right tabular-nums" class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
:title="getQuotaProgressTooltip(item)" :title="getQuotaProgressTooltip(item)"
@@ -530,12 +530,21 @@
:style="{ width: `${item.remainingPercent}%` }" :style="{ width: `${item.remainingPercent}%` }"
/> />
</div> </div>
<span <div class="flex flex-col items-end justify-center gap-0.5 text-right leading-tight">
class="tabular-nums text-right whitespace-nowrap" <span
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)" class="tabular-nums whitespace-nowrap"
> :class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
{{ item.remainingPercent.toFixed(1) }}% >
</span> {{ item.remainingPercent.toFixed(1) }}%
</span>
<span
v-if="getQuotaProgressCountdownText(item)"
class="text-[9px] text-muted-foreground whitespace-nowrap"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressCountdownText(item) }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -971,7 +980,7 @@
:key="`${key.key_id}-quota-mobile-${idx}`" :key="`${key.key_id}-quota-mobile-${idx}`"
class="w-full" class="w-full"
> >
<div class="grid grid-cols-[20px_minmax(0,1fr)_42px] items-center gap-1 text-[10px] leading-tight"> <div class="grid grid-cols-[20px_minmax(0,1fr)_80px] items-center gap-1 text-[10px] leading-tight">
<span <span
class="text-muted-foreground whitespace-nowrap text-right tabular-nums" class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
:title="getQuotaProgressTooltip(item)" :title="getQuotaProgressTooltip(item)"
@@ -985,12 +994,21 @@
:style="{ width: `${item.remainingPercent}%` }" :style="{ width: `${item.remainingPercent}%` }"
/> />
</div> </div>
<span <div class="flex flex-col items-end justify-center gap-0.5 text-right leading-tight">
class="tabular-nums text-right whitespace-nowrap" <span
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)" class="tabular-nums whitespace-nowrap"
> :class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
{{ item.remainingPercent.toFixed(1) }}% >
</span> {{ item.remainingPercent.toFixed(1) }}%
</span>
<span
v-if="getQuotaProgressCountdownText(item)"
class="text-[9px] text-muted-foreground whitespace-nowrap"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressCountdownText(item) }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1743,6 +1761,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
oauth_plan_type: key.oauth_plan_type ?? null, oauth_plan_type: key.oauth_plan_type ?? null,
oauth_account_id: key.oauth_account_id ?? null, oauth_account_id: key.oauth_account_id ?? null,
oauth_account_user_id: key.oauth_account_user_id ?? null, oauth_account_user_id: key.oauth_account_user_id ?? null,
oauth_account_name: key.oauth_account_name ?? null,
oauth_organizations: key.oauth_organizations ?? [], oauth_organizations: key.oauth_organizations ?? [],
oauth_invalid_at: key.oauth_invalid_at ?? null, oauth_invalid_at: key.oauth_invalid_at ?? null,
oauth_invalid_reason: key.oauth_invalid_reason ?? null, oauth_invalid_reason: key.oauth_invalid_reason ?? null,

View File

@@ -15,7 +15,7 @@ import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any, cast
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import case from sqlalchemy import case
@@ -53,6 +53,7 @@ from .schemas import (
BatchImportError, BatchImportError,
BatchImportRequest, BatchImportRequest,
BatchImportResponse, BatchImportResponse,
OAuthOrganizationSummary,
PoolKeyDetail, PoolKeyDetail,
PoolKeySelectionItem, PoolKeySelectionItem,
PoolKeySelectionRequest, PoolKeySelectionRequest,
@@ -81,7 +82,9 @@ async def pool_overview(
) -> PoolOverviewResponse: ) -> PoolOverviewResponse:
"""Return all pool-enabled providers with summary stats.""" """Return all pool-enabled providers with summary stats."""
adapter = AdminPoolOverviewAdapter() 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.""" """Return scheduling preset definitions for frontend rendering."""
adapter = AdminListSchedulingPresetsAdapter() 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 [], quick_selectors=quick_selectors.split(",") if quick_selectors else [],
search_scope=search_scope, 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: ) -> BatchImportResponse:
"""Batch import keys into a provider's pool.""" """Batch import keys into a provider's pool."""
adapter = AdminBatchImportKeysAdapter(provider_id=provider_id, body=body) 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: def _resolve_delete_batch_size(db: Session) -> int:
try: try:
bind = db.get_bind() 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: except Exception:
dialect_name = "" dialect_name = ""
@@ -302,7 +313,11 @@ def _derive_oauth_expires_at(
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth": if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
return None 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: if cfg:
for field in ("expires_at", "expiresAt", "expiry", "exp"): for field in ("expires_at", "expiresAt", "expiry", "exp"):
expires_at = _normalize_oauth_expires_at(cfg.get(field)) 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, auth_config: dict[str, Any] | None = None,
) -> str | None: ) -> str | None:
# Prefer persisted normalized field # 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: if persisted:
return persisted return persisted
@@ -330,7 +347,11 @@ def _derive_oauth_plan_type(
return None return None
# Fallback 1: encrypted auth_config (common for Codex/Antigravity) # 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: if cfg:
for plan_key in ("plan_type", "tier", "plan", "subscription_plan"): for plan_key in ("plan_type", "tier", "plan", "subscription_plan"):
normalized = _normalize_oauth_plan_type(cfg.get(plan_key), provider_type) normalized = _normalize_oauth_plan_type(cfg.get(plan_key), provider_type)
@@ -349,7 +370,12 @@ def _derive_oauth_plan_type(
candidates.append(upstream_metadata) candidates.append(upstream_metadata)
for source in candidates: 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) normalized = _normalize_oauth_plan_type(source.get(plan_key), provider_type)
if normalized: if normalized:
return normalized return normalized
@@ -366,7 +392,19 @@ def _derive_oauth_account_id(auth_config: dict[str, Any] | None = None) -> str |
return normalized or None 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): if not isinstance(auth_config, dict):
return None return None
raw = auth_config.get("account_user_id") 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 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): if not isinstance(auth_config, dict):
return [] 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( def _compute_health_aggregate(
@@ -387,7 +430,9 @@ def _compute_health_aggregate(
) -> tuple[float, bool]: ) -> tuple[float, bool]:
"""从按格式健康数据聚合出列表展示字段。""" """从按格式健康数据聚合出列表展示字段。"""
health_map = health_by_format if isinstance(health_by_format, dict) else {} 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: if health_map:
scores = [ scores = [
@@ -400,7 +445,9 @@ def _compute_health_aggregate(
health_score = 1.0 health_score = 1.0
any_circuit_open = any( 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 return health_score, any_circuit_open
@@ -489,10 +536,14 @@ async def batch_action_keys(
) -> BatchActionResponse: ) -> BatchActionResponse:
"""Batch enable/disable/delete/clear_cooldown/reset_cost/regenerate_fingerprint on pool keys.""" """Batch enable/disable/delete/clear_cooldown/reset_cost/regenerate_fingerprint on pool keys."""
adapter = AdminBatchActionKeysAdapter(provider_id=provider_id, body=body) 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( async def resolve_pool_key_selection(
provider_id: str, provider_id: str,
body: PoolKeySelectionRequest, body: PoolKeySelectionRequest,
@@ -501,7 +552,9 @@ async def resolve_pool_key_selection(
) -> PoolKeySelectionResponse: ) -> PoolKeySelectionResponse:
"""Resolve all key ids matching the current batch dialog filters.""" """Resolve all key ids matching the current batch dialog filters."""
adapter = AdminResolvePoolKeySelectionAdapter(provider_id=provider_id, body=body) 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( @router.get(
@@ -515,8 +568,12 @@ async def get_batch_delete_task_status(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> BatchDeleteTaskResponse: ) -> BatchDeleteTaskResponse:
"""Query the progress of an async batch-delete task.""" """Query the progress of an async batch-delete task."""
adapter = AdminBatchDeleteTaskStatusAdapter(provider_id=provider_id, task_id=task_id) adapter = AdminBatchDeleteTaskStatusAdapter(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) 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) @router.post("/{provider_id}/keys/cleanup-banned", response_model=BatchActionResponse)
@@ -527,7 +584,9 @@ async def cleanup_banned_keys(
) -> BatchActionResponse: ) -> BatchActionResponse:
"""Delete known banned/suspended accounts for the provider.""" """Delete known banned/suspended accounts for the provider."""
adapter = AdminCleanupBannedKeysAdapter(provider_id=provider_id) 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 = ( providers = (
db.query(Provider) db.query(Provider)
.options( .options(
load_only( load_only(*cast(tuple[Any, ...], _PROVIDER_OVERVIEW_LOAD_ONLY_ATTRS))
Provider.id,
Provider.name,
Provider.provider_type,
Provider.provider_priority,
Provider.config,
)
) )
.order_by(Provider.provider_priority.asc()) .order_by(Provider.provider_priority.asc())
.all() .all()
@@ -628,7 +681,9 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
ProviderAPIKey.provider_id, ProviderAPIKey.provider_id,
func.count(ProviderAPIKey.id).label("total"), func.count(ProviderAPIKey.id).label("total"),
func.coalesce( 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, 0,
).label("active"), ).label("active"),
) )
@@ -651,8 +706,8 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
if key_stats_by_provider.get(pid, {}).get("total", 0) > 0 if key_stats_by_provider.get(pid, {}).get("total", 0) > 0
] ]
if cooldown_targets: if cooldown_targets:
cooldown_count_by_provider = await pool_redis.batch_count_provider_cooldowns( cooldown_count_by_provider = (
cooldown_targets await pool_redis.batch_count_provider_cooldowns(cooldown_targets)
) )
items: list[PoolOverviewItem] = [] items: list[PoolOverviewItem] = []
@@ -663,8 +718,10 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
items.append( items.append(
PoolOverviewItem( PoolOverviewItem(
provider_id=pid, provider_id=pid,
provider_name=p.name, provider_name=str(getattr(p, "name", "") or ""),
provider_type=str(getattr(p, "provider_type", "custom") or "custom"), provider_type=str(
getattr(p, "provider_type", "custom") or "custom"
),
total_keys=key_stats["total"], total_keys=key_stats["total"],
active_keys=key_stats["active"], active_keys=key_stats["active"],
cooldown_count=cooldown_count_by_provider.get(pid, 0), cooldown_count=cooldown_count_by_provider.get(pid, 0),
@@ -690,16 +747,65 @@ _ALLOWED_POOL_KEY_QUICK_SELECTORS = frozenset(
"enabled", "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|封|禁|受限)") _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: def _normalize_batch_text(value: Any) -> str:
return str(value or "").strip().lower() return str(value or "").strip().lower()
def _normalize_pool_search_scope(value: Any) -> str: 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]: def _normalize_pool_quick_selectors(values: Any) -> list[str]:
@@ -731,7 +837,8 @@ def _get_quota_segments(account_quota: Any) -> list[str]:
return [ return [
segment segment
for segment in ( 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 if segment
] ]
@@ -739,7 +846,9 @@ def _get_quota_segments(account_quota: Any) -> list[str]:
def _quota_segment_has_depleted_keyword(segment: str) -> bool: def _quota_segment_has_depleted_keyword(segment: str) -> bool:
return 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: def _detail_is_oauth_invalid(detail: PoolKeyDetail) -> bool:
if _normalize_batch_text(detail.auth_type) != "oauth": if _normalize_batch_text(detail.auth_type) != "oauth":
return False 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 return True
expires_at = detail.oauth_expires_at 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: def _detail_is_banned(detail: PoolKeyDetail) -> bool:
@@ -875,10 +990,13 @@ def _filter_pool_key_details(
for detail in details: for detail in details:
if require_cooldown and not detail.cooldown_reason: if require_cooldown and not detail.cooldown_reason:
continue 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 continue
if normalized_selectors and not any( 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 continue
filtered.append(detail) filtered.append(detail)
@@ -888,42 +1006,7 @@ def _filter_pool_key_details(
def _build_pool_keys_base_query(db: Session, provider_id: str) -> Any: def _build_pool_keys_base_query(db: Session, provider_id: str) -> Any:
return ( return (
db.query(ProviderAPIKey) db.query(ProviderAPIKey)
.options( .options(load_only(*_POOL_KEY_LOAD_ONLY_ATTRS))
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,
)
)
.filter(ProviderAPIKey.provider_id == provider_id) .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] = [] key_details: list[PoolKeyDetail] = []
serialize_started_at = time.perf_counter() serialize_started_at = time.perf_counter()
for k in keys: for k in keys:
kid = str(k.id) 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 cd_ttl = cooldown_ttls.get(kid) if cd_reason else None
health_score, any_circuit_open = _compute_health_aggregate( health_score, any_circuit_open = _compute_health_aggregate(
getattr(k, "health_by_format", None), getattr(k, "health_by_format", None),
@@ -1021,7 +1106,9 @@ async def _serialize_pool_key_details(
circuit_breaker_open=any_circuit_open, circuit_breaker_open=any_circuit_open,
cost_window_usage=cost_usage, cost_window_usage=cost_usage,
cost_limit=cost_limit, 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, health_score=health_score,
) )
@@ -1077,10 +1164,12 @@ async def _serialize_pool_key_details(
key_details.append( key_details.append(
PoolKeyDetail( PoolKeyDetail(
key_id=kid, key_id=kid,
key_name=k.name or "", key_name=str(getattr(k, "name", "") or ""),
is_active=bool(k.is_active), is_active=bool(k.is_active),
auth_type=str(getattr(k, "auth_type", "api_key") or "api_key"), 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=( oauth_invalid_at=(
int(k.oauth_invalid_at.timestamp()) int(k.oauth_invalid_at.timestamp())
if getattr(k, "oauth_invalid_at", None) 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 k, provider_type, auth_config=oauth_auth_config
), ),
oauth_account_id=_derive_oauth_account_id(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_account_user_id=_derive_oauth_account_user_id(oauth_auth_config),
oauth_organizations=_derive_oauth_organizations(oauth_auth_config), oauth_organizations=_derive_oauth_organizations(oauth_auth_config),
quota_updated_at=_extract_quota_updated_at( 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 v if (v := getattr(k, "cache_ttl_minutes", None)) is not None else 5
), ),
max_probe_interval_minutes=( 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), note=getattr(k, "note", None),
allowed_models=allowed_models, allowed_models=allowed_models,
@@ -1135,8 +1227,12 @@ async def _serialize_pool_key_details(
total_cost_usd=key_total_cost_usd, total_cost_usd=key_total_cost_usd,
sticky_sessions=sticky_counts.get(kid, 0), sticky_sessions=sticky_counts.get(kid, 0),
lru_score=lru_scores.get(kid), lru_score=lru_scores.get(kid),
created_at=(k.created_at.isoformat() if getattr(k, "created_at", None) else None), created_at=(
last_used_at=(key_last_used_at.isoformat() if key_last_used_at else None), 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_status=scheduling_status,
scheduling_reason=scheduling_reason, scheduling_reason=scheduling_reason,
scheduling_label=scheduling_label, scheduling_label=scheduling_label,
@@ -1208,12 +1304,18 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
pcfg = parse_pool_config(getattr(provider, "config", None)) pcfg = parse_pool_config(getattr(provider, "config", None))
pid = str(provider.id) pid = str(provider.id)
provider_type = str(getattr(provider, "provider_type", "custom") or "custom") 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) normalized_search_scope = _normalize_pool_search_scope(self.search_scope)
q = _build_pool_keys_base_query(db, pid) q = _build_pool_keys_base_query(db, pid)
if self.search and normalized_search_scope != _FULL_SEARCH_SCOPE: 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}%")) q = q.filter(ProviderAPIKey.name.ilike(f"%{escaped}%"))
if self.status == "active": if self.status == "active":
@@ -1227,17 +1329,20 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
or self.status == "cooldown" or self.status == "cooldown"
or (bool(self.search) and normalized_search_scope == _FULL_SEARCH_SCOPE) 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( filtered_details,
query=q, keys_query_ms,
pid=pid, redis_state_ms,
provider_type=provider_type, serialize_ms,
pcfg=pcfg, ) = await _resolve_filtered_pool_key_details(
search=self.search, query=q,
quick_selectors=normalized_quick_selectors, pid=pid,
search_scope=normalized_search_scope, provider_type=provider_type,
require_cooldown=self.status == "cooldown", pcfg=pcfg,
) search=self.search,
quick_selectors=normalized_quick_selectors,
search_scope=normalized_search_scope,
require_cooldown=self.status == "cooldown",
) )
total = len(filtered_details) total = len(filtered_details)
offset = (self.page - 1) * self.page_size offset = (self.page - 1) * self.page_size
@@ -1250,7 +1355,11 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
keys_query_started_at = time.perf_counter() keys_query_started_at = time.perf_counter()
keys = _apply_pool_key_order(q).offset(offset).limit(self.page_size).all() 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 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, keys=keys,
pid=pid, pid=pid,
provider_type=provider_type, provider_type=provider_type,
@@ -1326,7 +1435,9 @@ class AdminResolvePoolKeySelectionAdapter(AdminApiAdapter):
@dataclass @dataclass
class AdminBatchImportKeysAdapter(AdminApiAdapter): class AdminBatchImportKeysAdapter(AdminApiAdapter):
provider_id: str = "" 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] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
@@ -1350,7 +1461,8 @@ class AdminBatchImportKeysAdapter(AdminApiAdapter):
try: try:
encrypted_key = crypto_service.encrypt(item.api_key) encrypted_key = crypto_service.encrypt(item.api_key)
new_key_id = str(uuid.uuid4()) 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, id=new_key_id,
provider_id=self.provider_id, provider_id=self.provider_id,
name=item.name or f"imported-{idx}", name=item.name or f"imported-{idx}",
@@ -1457,13 +1569,14 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
for key in keys: for key in keys:
kid = str(key.id) kid = str(key.id)
mutable_key = cast(Any, key)
if self.body.action == "enable": if self.body.action == "enable":
key.is_active = True mutable_key.is_active = True
affected += 1 affected += 1
elif self.body.action == "disable": elif self.body.action == "disable":
key.is_active = False mutable_key.is_active = False
affected += 1 affected += 1
elif self.body.action == "clear_cooldown": elif self.body.action == "clear_cooldown":
@@ -1475,15 +1588,15 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
affected += 1 affected += 1
elif self.body.action == "clear_proxy": elif self.body.action == "clear_proxy":
key.proxy = None mutable_key.proxy = None
affected += 1 affected += 1
elif self.body.action == "set_proxy": elif self.body.action == "set_proxy":
key.proxy = self.body.payload mutable_key.proxy = self.body.payload
affected += 1 affected += 1
elif self.body.action == "regenerate_fingerprint": elif self.body.action == "regenerate_fingerprint":
key.fingerprint = generate_fingerprint(seed=None) mutable_key.fingerprint = generate_fingerprint(seed=None)
affected += 1 affected += 1
if self.body.action in { if self.body.action in {
@@ -1498,7 +1611,9 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
except Exception as exc: except Exception as exc:
db.rollback() db.rollback()
logger.error("batch action commit failed: {}", exc) 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" admin_name = context.user.username if context.user else "admin"
affected_ids = [str(k.id)[:8] for k in keys] affected_ids = [str(k.id)[:8] for k in keys]
@@ -1539,7 +1654,9 @@ class AdminCleanupBannedKeysAdapter(AdminApiAdapter):
raise NotFoundException("Provider not found", "provider") raise NotFoundException("Provider not found", "provider")
pid = str(provider.id) 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() 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)] banned_keys = [key for key in keys if _is_known_banned_key(key, provider_type)]

View File

@@ -85,6 +85,7 @@ class PoolKeyDetail(BaseModel):
oauth_invalid_reason: str | None = None oauth_invalid_reason: str | None = None
oauth_plan_type: str | None = None oauth_plan_type: str | None = None
oauth_account_id: str | None = None oauth_account_id: str | None = None
oauth_account_name: str | None = None
oauth_account_user_id: str | None = None oauth_account_user_id: str | None = None
oauth_organizations: list[OAuthOrganizationSummary] = Field(default_factory=list) oauth_organizations: list[OAuthOrganizationSummary] = Field(default_factory=list)
quota_updated_at: int | None = None quota_updated_at: int | None = None

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import random
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from urllib.parse import quote, urlsplit, urlunsplit from urllib.parse import quote, urlsplit, urlunsplit
@@ -9,11 +10,14 @@ import httpx
import jwt import jwt
from src.clients.http_client import HTTPClientPool 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 from src.core.provider_types import ProviderType
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token" _ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json" _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: 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: if parsed.port:
host_part = f"{host_part}:{parsed.port}" host_part = f"{host_part}:{parsed.port}"
auth_part = ( 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}" 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: except Exception:
return url return url
@@ -189,7 +197,7 @@ def _tls_client_post_sync(
timeout_seconds: float, timeout_seconds: float,
) -> tuple[int, dict[str, str], str]: ) -> tuple[int, dict[str, str], str]:
# tls-client is optional at runtime; import only when needed. # 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( session = tls_client.Session(
client_identifier="firefox_120", client_identifier="firefox_120",
@@ -425,7 +433,10 @@ async def fetch_google_email(
try: try:
resp = await client.get( resp = await client.get(
_GOOGLE_USERINFO_URL, _GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"}, headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
timeout=timeout_seconds, timeout=timeout_seconds,
) )
if resp.status_code < 200 or resp.status_code >= 300: if resp.status_code < 200 or resp.status_code >= 300:
@@ -439,6 +450,111 @@ async def fetch_google_email(
return None 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: def extract_claude_email_from_token_response(token: dict[str, Any]) -> str | None:
# CLIProxyAPI expects: { account: { email_address: ... } } # CLIProxyAPI expects: { account: { email_address: ... } }
try: try:

View File

@@ -130,7 +130,8 @@ def _validate_condition(condition: Any, rule_label: str) -> None:
op = condition.get("op") op = condition.get("op")
if not isinstance(op, str) or op not in _CONDITION_OPS: if not isinstance(op, str) or op not in _CONDITION_OPS:
raise ValueError( 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") path = condition.get("path")
@@ -151,11 +152,15 @@ def _validate_condition(condition: Any, rule_label: str) -> None:
# matches 正则校验 # matches 正则校验
if op == "matches": if op == "matches":
if not isinstance(value, str) or not value: 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: try:
re.compile(value) re.compile(value)
except re.error as e: 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 校验 # in 校验
if op == "in": if op == "in":
@@ -183,7 +188,10 @@ def _validate_header_rules(rules: list[HeaderRule]) -> list[HeaderRule]:
raise ValueError(f"header_rules[{idx}]: 规则必须是 JSON 对象") raise ValueError(f"header_rules[{idx}]: 规则必须是 JSON 对象")
action = rule.get("action") 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( raise ValueError(
f"header_rules[{idx}]: action 必须是 {sorted(_HEADER_RULE_ACTIONS)} 之一," f"header_rules[{idx}]: action 必须是 {sorted(_HEADER_RULE_ACTIONS)} 之一,"
f"当前值: {action!r}" f"当前值: {action!r}"
@@ -233,7 +241,10 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
raise ValueError(f"body_rules[{idx}]: 规则必须是 JSON 对象") raise ValueError(f"body_rules[{idx}]: 规则必须是 JSON 对象")
action = rule.get("action") 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( raise ValueError(
f"body_rules[{idx}]: action 必须是 {sorted(_BODY_RULE_ACTIONS)} 之一," f"body_rules[{idx}]: action 必须是 {sorted(_BODY_RULE_ACTIONS)} 之一,"
f"当前值: {action!r}" 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"}: if action in {"set", "drop", "append", "insert", "regex_replace", "name_style"}:
path = rule.get("path") path = rule.get("path")
if not isinstance(path, str) or not path.strip(): 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 校验 ---------- # ---------- rename 校验 ----------
if action == "rename": if action == "rename":
@@ -265,11 +278,15 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
if action == "regex_replace": if action == "regex_replace":
pattern = rule.get("pattern") pattern = rule.get("pattern")
if not isinstance(pattern, str) or not 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", "") replacement = rule.get("replacement", "")
if not isinstance(replacement, str): 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
flags_str = rule.get("flags", "") flags_str = rule.get("flags", "")
@@ -295,7 +312,9 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
# 校验 count # 校验 count
count = rule.get("count", 0) count = rule.get("count", 0)
if not isinstance(count, int) or 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 校验 ---------- # ---------- name_style 校验 ----------
if action == "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") 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( header_rules: list[HeaderRule] | None = Field(
@@ -360,7 +381,10 @@ class ProviderEndpointCreate(BaseModel):
@classmethod @classmethod
def validate_api_format(cls, v: str) -> str: def validate_api_format(cls, v: str) -> str:
"""验证 API 格式""" """验证 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 from src.core.api_format.signature import normalize_signature_key
normalized = normalize_signature_key(v) normalized = normalize_signature_key(v)
@@ -387,7 +411,9 @@ class ProviderEndpointCreate(BaseModel):
@field_validator("header_rules") @field_validator("header_rules")
@classmethod @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 合法性""" """校验 header_rules 结构和 condition 合法性"""
if v is None: if v is None:
return v return v
@@ -400,7 +426,9 @@ class ProviderEndpointUpdate(BaseModel):
base_url: str | None = Field( base_url: str | None = Field(
default=None, min_length=1, max_length=500, description="API 基础 URL" 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( header_rules: list[HeaderRule] | None = Field(
@@ -414,7 +442,9 @@ class ProviderEndpointUpdate(BaseModel):
description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作", 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="是否启用") is_active: bool | None = Field(default=None, description="是否启用")
config: dict[str, Any] | None = Field(default=None, description="额外配置") config: dict[str, Any] | None = Field(default=None, description="额外配置")
proxy: ProxyConfig | None = Field(default=None, description="代理配置") proxy: ProxyConfig | None = Field(default=None, description="代理配置")
@@ -447,7 +477,9 @@ class ProviderEndpointUpdate(BaseModel):
@field_validator("header_rules") @field_validator("header_rules")
@classmethod @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 合法性""" """校验 header_rules 结构和 condition 合法性"""
if v is None: if v is None:
return v return v
@@ -467,10 +499,14 @@ class ProviderEndpointResponse(BaseModel):
custom_path: str | None = None 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 max_retries: int
@@ -481,7 +517,9 @@ class ProviderEndpointResponse(BaseModel):
config: dict[str, Any] | None = None 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( format_acceptance_config: dict[str, Any] | None = Field(
@@ -506,13 +544,19 @@ class ProviderEndpointResponse(BaseModel):
class EndpointAPIKeyCreate(BaseModel): class EndpointAPIKeyCreate(BaseModel):
"""为 Provider 添加 API Key""" """为 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( 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( 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( auth_type: Literal["api_key", "service_account", "oauth"] = Field(
default="api_key", default="api_key",
@@ -525,7 +569,9 @@ class EndpointAPIKeyCreate(BaseModel):
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)" "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( 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: NULL=自适应模式(系统自动学习),数字=固定限制模式
rpm_limit: int | None = Field( rpm_limit: int | None = Field(
default=None, ge=1, le=10000, description="RPM 限制NULL=自适应模式)" default=None, ge=1, le=10000, description="RPM 限制NULL=自适应模式)"
@@ -546,7 +594,8 @@ class EndpointAPIKeyCreate(BaseModel):
# 能力标签 # 能力标签
capabilities: dict[str, bool] | None = Field( 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( auto_fetch_models: bool = Field(
default=False, description="是否启用自动获取模型(启用后系统定时从上游 API 获取可用模型)" default=False,
description="是否启用自动获取模型(启用后系统定时从上游 API 获取可用模型)",
) )
# 锁定的模型列表 # 锁定的模型列表
@@ -585,7 +637,10 @@ class EndpointAPIKeyCreate(BaseModel):
if v is None: if v is None:
return v 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 from src.core.api_format.signature import normalize_signature_key
allowed = [d.signature_key for d in list_endpoint_definitions()] allowed = [d.signature_key for d in list_endpoint_definitions()]
@@ -594,7 +649,9 @@ class EndpointAPIKeyCreate(BaseModel):
for fmt in v: for fmt in v:
normalized = normalize_signature_key(fmt) normalized = normalize_signature_key(fmt)
if resolve_endpoint_definition(normalized) is None: 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: if normalized in seen:
continue # 静默去重 continue # 静默去重
seen.add(normalized) seen.add(normalized)
@@ -680,7 +737,9 @@ class EndpointAPIKeyUpdate(BaseModel):
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)" "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( rate_multipliers: dict[str, float] | None = Field(
default=None, default=None,
description="按 endpoint signature 的成本倍率,如 {'claude:cli': 1.0, 'openai:cli': 0.8}", description="按 endpoint signature 的成本倍率,如 {'claude:cli': 1.0, 'openai:cli': 0.8}",
@@ -704,7 +763,8 @@ class EndpointAPIKeyUpdate(BaseModel):
description="允许使用的模型列表null=不限制)", description="允许使用的模型列表null=不限制)",
) )
capabilities: dict[str, bool] | None = Field( 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( cache_ttl_minutes: int | None = Field(
default=None, ge=0, le=60, description="缓存 TTL分钟0=禁用" default=None, ge=0, le=60, description="缓存 TTL分钟0=禁用"
@@ -714,7 +774,9 @@ class EndpointAPIKeyUpdate(BaseModel):
) )
is_active: bool | None = Field(default=None, description="是否启用") is_active: bool | None = Field(default=None, description="是否启用")
note: str | None = Field(default=None, max_length=500, 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( locked_models: list[str] | None = Field(
default=None, description="被锁定的模型列表(刷新时不会被删除)" default=None, description="被锁定的模型列表(刷新时不会被删除)"
) )
@@ -803,7 +865,8 @@ class EndpointAPIKeyResponse(BaseModel):
provider_id: str = Field(..., description="Provider ID") provider_id: str = Field(..., description="Provider ID")
api_formats: list[str] = Field( api_formats: list[str] = Field(
default=[], description="支持的 endpoint signature 列表(如 openai:chat, claude:cli" default=[],
description="支持的 endpoint signature 列表(如 openai:chat, claude:cli",
) )
# Key 信息(脱敏) # Key 信息(脱敏)
@@ -828,7 +891,9 @@ class EndpointAPIKeyResponse(BaseModel):
) )
rpm_limit: int | None = None rpm_limit: int | None = None
allowed_models: list[str] | 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 相关
oauth_expires_at: int | None = Field( oauth_expires_at: int | None = Field(
@@ -839,6 +904,9 @@ class EndpointAPIKeyResponse(BaseModel):
default=None, description="OAuth 账号套餐类型(如 free/plus/team/enterprise" default=None, description="OAuth 账号套餐类型(如 free/plus/team/enterprise"
) )
oauth_account_id: str | None = Field(default=None, description="OAuth 账号 ID") 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( oauth_account_user_id: str | None = Field(
default=None, default=None,
description="OAuth 账号-工作区联合 ID如 Codex chatgpt_account_user_id", description="OAuth 账号-工作区联合 ID如 Codex chatgpt_account_user_id",
@@ -848,13 +916,18 @@ class EndpointAPIKeyResponse(BaseModel):
description="OAuth 关联的组织/工作区摘要列表", description="OAuth 关联的组织/工作区摘要列表",
) )
oauth_invalid_at: int | None = Field( 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=禁用") 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 的健康度数据 # 按 endpoint signature 的健康度数据
health_by_format: dict[str, Any] | None = Field( health_by_format: dict[str, Any] | None = Field(
@@ -870,13 +943,23 @@ class EndpointAPIKeyResponse(BaseModel):
last_failure_at: datetime | None = None last_failure_at: datetime | None = None
# 聚合熔断器字段 # 聚合熔断器字段
circuit_breaker_open: bool = Field(default=False, description="熔断器是否打开(任何格式)") circuit_breaker_open: bool = Field(
circuit_breaker_open_at: datetime | None = Field(default=None, description="熔断器打开时间") default=False, description="熔断器是否打开(任何格式)"
next_probe_at: datetime | None = Field(default=None, description="下次进入半开状态时间") )
half_open_until: datetime | None = Field(default=None, 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_successes: int | None = Field(default=0, description="半开状态成功次数")
half_open_failures: 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 request_count: int
@@ -889,12 +972,18 @@ class EndpointAPIKeyResponse(BaseModel):
is_active: bool is_active: bool
# 自适应 RPM 信息 # 自适应 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 限制") learned_rpm_limit: int | None = Field(None, description="学习到的 RPM 限制")
effective_limit: int | None = Field(None, description="当前有效限制") effective_limit: int | None = Field(None, description="当前有效限制")
# 滑动窗口利用率采样 # 滑动窗口利用率采样
utilization_samples: list[dict] | None = Field(None, description="利用率采样窗口") utilization_samples: list[dict[str, Any]] | None = Field(
last_probe_increase_at: datetime | None = Field(None, description="上次探测性扩容时间") None, description="利用率采样窗口"
)
last_probe_increase_at: datetime | None = Field(
None, description="上次探测性扩容时间"
)
concurrent_429_count: int | None = None concurrent_429_count: int | None = None
rpm_429_count: int | None = None rpm_429_count: int | None = None
last_429_at: datetime | None = None last_429_at: datetime | None = None
@@ -906,7 +995,9 @@ class EndpointAPIKeyResponse(BaseModel):
# 自动获取模型 # 自动获取模型
auto_fetch_models: bool = Field(default=False, description="是否启用自动获取模型") auto_fetch_models: bool = Field(default=False, description="是否启用自动获取模型")
last_models_fetch_at: datetime | None = Field(None, 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="被锁定的模型列表") locked_models: list[str] | None = Field(None, description="被锁定的模型列表")
# 模型过滤规则 # 模型过滤规则
model_include_patterns: 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): 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)") keys: dict[str, int] = Field(..., description="Key 统计 (total, active, unhealthy)")
@@ -999,13 +1092,17 @@ class KeyPriorityItem(BaseModel):
"""单个 Key 优先级项""" """单个 Key 优先级项"""
key_id: str = Field(..., description="Key ID") 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): class BatchUpdateKeyPriorityRequest(BaseModel):
"""批量更新 Key 优先级请求""" """批量更新 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) name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None description: str | None = None
website: str | None = Field(None, max_length=500, description="主站网站") 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( keep_priority_on_conversion: bool | None = Field(
None, None,
description="格式转换时是否保持优先级True=保持原优先级False=需要转换时降级)", description="格式转换时是否保持优先级True=保持原优先级False=需要转换时降级)",
@@ -1031,7 +1130,9 @@ class ProviderUpdateRequest(BaseModel):
None, description="计费类型monthly_quota/pay_as_you_go/free_tier" None, description="计费类型monthly_quota/pay_as_you_go/free_tier"
) )
monthly_quota_usd: float | None = Field(None, ge=0, description="订阅配额(美元)") 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="配额过期时间") quota_expires_at: datetime | None = Field(None, description="配额过期时间")
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
max_retries: int | None = Field(None, ge=0, le=10, description="最大重试次数") max_retries: int | None = Field(None, ge=0, le=10, description="最大重试次数")
@@ -1047,7 +1148,9 @@ class ProviderUpdateRequest(BaseModel):
None, description="Claude Code 高级配置" None, description="Claude Code 高级配置"
) )
pool_advanced: PoolAdvancedConfig | None = Field(None, description="通用号池配置") pool_advanced: PoolAdvancedConfig | None = Field(None, description="通用号池配置")
failover_rules: FailoverRulesConfig | None = Field(None, description="故障转移规则配置") failover_rules: FailoverRulesConfig | None = Field(
None, description="故障转移规则配置"
)
class ProviderWithEndpointsSummary(BaseModel): class ProviderWithEndpointsSummary(BaseModel):
@@ -1057,11 +1160,14 @@ class ProviderWithEndpointsSummary(BaseModel):
id: str id: str
name: str name: str
provider_type: str | None = Field( 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 description: str | None = None
website: 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( keep_priority_on_conversion: bool = Field(
default=False, default=False,
description="格式转换时是否保持优先级True=保持原优先级False=需要转换时降级)", description="格式转换时是否保持优先级True=保持原优先级False=需要转换时降级)",
@@ -1076,8 +1182,12 @@ class ProviderWithEndpointsSummary(BaseModel):
billing_type: str | None = None billing_type: str | None = None
monthly_quota_usd: float | None = None monthly_quota_usd: float | None = None
monthly_used_usd: float | None = None monthly_used_usd: float | None = None
quota_reset_day: int | None = Field(default=None, description="配额重置周期(天数)") quota_reset_day: int | None = Field(
quota_last_reset_at: datetime | None = Field(default=None, description="当前周期开始时间") default=None, description="配额重置周期(天数)"
)
quota_last_reset_at: datetime | None = Field(
default=None, description="当前周期开始时间"
)
quota_expires_at: datetime | None = Field(default=None, description="配额过期时间") quota_expires_at: datetime | None = Field(default=None, description="配额过期时间")
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
@@ -1087,12 +1197,18 @@ class ProviderWithEndpointsSummary(BaseModel):
stream_first_byte_timeout: float | None = Field( stream_first_byte_timeout: float | None = Field(
default=None, description="流式请求首字节超时(秒)" 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( claude_code_advanced: ClaudeCodeAdvancedConfig | None = Field(
default=None, description="Claude Code 高级配置" default=None, description="Claude Code 高级配置"
) )
pool_advanced: PoolAdvancedConfig | None = Field(default=None, description="通用号池配置") pool_advanced: PoolAdvancedConfig | None = Field(
failover_rules: FailoverRulesConfig | None = Field(default=None, description="故障转移规则配置") default=None, description="通用号池配置"
)
failover_rules: FailoverRulesConfig | None = Field(
default=None, description="故障转移规则配置"
)
# Endpoint 统计 # Endpoint 统计
total_endpoints: int = Field(default=0, description="总 Endpoint 数量") total_endpoints: int = Field(default=0, description="总 Endpoint 数量")
@@ -1105,7 +1221,9 @@ class ProviderWithEndpointsSummary(BaseModel):
# Model 统计 # Model 统计
total_models: int = Field(default=0, description="总模型数量") total_models: int = Field(default=0, description="总模型数量")
active_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 格式列表
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表") api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")
@@ -1123,7 +1241,9 @@ class ProviderWithEndpointsSummary(BaseModel):
) )
# Provider Ops 配置状态 # Provider Ops 配置状态
ops_configured: bool = Field(default=False, description="是否配置了扩展操作(余额监控等)") ops_configured: bool = Field(
default=False, description="是否配置了扩展操作(余额监控等)"
)
ops_architecture_id: str | None = Field( ops_architecture_id: str | None = Field(
default=None, description="扩展操作使用的架构 ID如 cubence, anyrouter" default=None, description="扩展操作使用的架构 ID如 cubence, anyrouter"
) )
@@ -1202,7 +1322,9 @@ class ApiFormatHealthMonitor(BaseModel):
time_range_start: datetime | None = Field( time_range_start: datetime | None = Field(
default=None, description="时间线所覆盖区间的开始时间" default=None, description="时间线所覆盖区间的开始时间"
) )
time_range_end: datetime | None = Field(default=None, description="时间线所覆盖区间的结束时间") time_range_end: datetime | None = Field(
default=None, description="时间线所覆盖区间的结束时间"
)
class ApiFormatHealthMonitorResponse(BaseModel): class ApiFormatHealthMonitorResponse(BaseModel):
@@ -1236,13 +1358,19 @@ class PublicApiFormatHealthMonitor(BaseModel):
skipped_count: int = Field(default=0, description="跳过次数") skipped_count: int = Field(default=0, description="跳过次数")
success_rate: float = Field(default=1.0, description="成功率") success_rate: float = Field(default=1.0, description="成功率")
last_event_at: datetime | None = None 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( timeline: list[str] = Field(
default_factory=list, default_factory=list,
description="Usage 表生成的健康时间线healthy/warning/unhealthy/unknown", description="Usage 表生成的健康时间线healthy/warning/unhealthy/unknown",
) )
time_range_start: datetime | None = Field(default=None, description="时间线覆盖区间开始时间") time_range_start: datetime | None = Field(
time_range_end: datetime | None = Field(default=None, description="时间线覆盖区间结束时间") default=None, description="时间线覆盖区间开始时间"
)
time_range_end: datetime | None = Field(
default=None, description="时间线覆盖区间结束时间"
)
class PublicApiFormatHealthMonitorResponse(BaseModel): class PublicApiFormatHealthMonitorResponse(BaseModel):

View File

@@ -155,11 +155,14 @@ def build_codex_url(
async def enrich_codex( async def enrich_codex(
auth_config: dict[str, Any], auth_config: dict[str, Any],
token_response: dict[str, Any], token_response: dict[str, Any],
access_token: str, # noqa: ARG001 access_token: str,
proxy_config: dict[str, Any] | None, # noqa: ARG001 proxy_config: dict[str, Any] | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Codex auth_config enrichment: parse token claims -> account/team identity metadata.""" """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: def _read_non_empty_str(*values: Any) -> str | None:
for value in values: for value in values:
@@ -232,6 +235,17 @@ async def enrich_codex(
if not auth_config.get(key): if not auth_config.get(key):
auth_config[key] = value 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 return auth_config
@@ -260,7 +274,9 @@ def register_all() -> None:
# Provider Format Capability默认 body_rules # Provider Format Capability默认 body_rules
from src.core.api_format.metadata import CODEX_DEFAULT_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) # Export: Codex uses the default export builder (strip null + temp fields)
# No need to register a custom one — the default in export.py suffices. # No need to register a custom one — the default in export.py suffices.

View File

@@ -5,6 +5,8 @@ Provider Key 响应对象构建器。
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from typing import Any
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.logger import logger from src.core.logger import logger
@@ -19,6 +21,11 @@ def build_key_response(
) -> EndpointAPIKeyResponse: ) -> EndpointAPIKeyResponse:
"""构建 Key 响应对象。""" """构建 Key 响应对象。"""
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_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"): if auth_type in ("service_account", "vertex_ai"):
# Service Account 不显示占位符 # Service Account 不显示占位符
@@ -27,18 +34,18 @@ def build_key_response(
masked_key = "[OAuth Token]" masked_key = "[OAuth Token]"
else: else:
try: 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:]}" masked_key = f"{decrypted_key[:8]}***{decrypted_key[-4:]}"
except Exception: except Exception:
masked_key = "***ERROR***" 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 = ( 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 is_adaptive = rpm_limit is None
key_dict = key.__dict__.copy() key_dict: dict[str, Any] = dict(getattr(key, "__dict__", {}))
key_dict.pop("_sa_instance_state", None) key_dict.pop("_sa_instance_state", None)
key_dict.pop("api_key", None) # 移除敏感字段,避免泄露 key_dict.pop("api_key", None) # 移除敏感字段,避免泄露
key_dict["auth_type"] = auth_type key_dict["auth_type"] = auth_type
@@ -48,43 +55,68 @@ def build_key_response(
oauth_email = None oauth_email = None
oauth_plan_type = None oauth_plan_type = None
oauth_account_id = None oauth_account_id = None
oauth_account_name = None
oauth_account_user_id = None oauth_account_user_id = None
oauth_organizations: list[dict[str, object]] = [] oauth_organizations: list[dict[str, object]] = []
encrypted_auth_config = key_dict.pop("auth_config", None) # 移除敏感字段,避免泄露 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: try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config) decrypted_config = crypto_service.decrypt(encrypted_auth_config)
auth_config = json.loads(decrypted_config) auth_config = json.loads(decrypted_config)
oauth_expires_at = auth_config.get("expires_at") oauth_expires_at = auth_config.get("expires_at")
oauth_email = auth_config.get("email") 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 # Antigravity 使用 "tier" 字段(如 "PAID"/"FREE"),做小写化 fallback
if not oauth_plan_type: if not oauth_plan_type:
ag_tier = auth_config.get("tier") ag_tier = auth_config.get("tier")
if ag_tier and isinstance(ag_tier, str): if ag_tier and isinstance(ag_tier, str):
oauth_plan_type = ag_tier.lower() 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_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: except Exception as e:
logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e) logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e)
# 从 health_by_format 计算汇总字段(便于列表展示) # 从 health_by_format 计算汇总字段(便于列表展示)
health_by_format = key.health_by_format or {} raw_health_by_format = getattr(key, "health_by_format", None)
circuit_by_format = key.circuit_breaker_by_format or {} 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: 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 min_health_score = min(health_scores) if health_scores else 1.0
# 取最大的连续失败次数 # 取最大的连续失败次数
max_consecutive = max( 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, default=0,
) )
# 取最近的失败时间 # 取最近的失败时间
failure_times = [ 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 last_failure = max(failure_times) if failure_times else None
else: else:
@@ -103,9 +135,11 @@ def build_key_response(
"avg_response_time_ms": round(avg_response_time_ms, 2), "avg_response_time_ms": round(avg_response_time_ms, 2),
"is_adaptive": is_adaptive, "is_adaptive": is_adaptive,
"effective_limit": ( "effective_limit": (
key.learned_rpm_limit # 自适应模式:使用学习值,未学习时为 None不限制 getattr(
key, "learned_rpm_limit", None
) # 自适应模式:使用学习值,未学习时为 None不限制
if is_adaptive if is_adaptive
else key.rpm_limit else rpm_limit
), ),
# 汇总字段 # 汇总字段
"health_score": min_health_score, "health_score": min_health_score,
@@ -117,12 +151,18 @@ def build_key_response(
"oauth_email": oauth_email, "oauth_email": oauth_email,
"oauth_plan_type": oauth_plan_type, "oauth_plan_type": oauth_plan_type,
"oauth_account_id": oauth_account_id, "oauth_account_id": oauth_account_id,
"oauth_account_name": oauth_account_name,
"oauth_account_user_id": oauth_account_user_id, "oauth_account_user_id": oauth_account_user_id,
"oauth_organizations": oauth_organizations, "oauth_organizations": oauth_organizations,
"oauth_invalid_at": ( "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),
} }
) )

View File

@@ -68,6 +68,7 @@ async def test_standard_batch_import_commits_successes_in_chunks(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
key_ids = count(1) key_ids = count(1)
created_auth_configs: list[dict[str, object]] = []
monkeypatch.setattr( monkeypatch.setattr(
oauthmod, oauthmod,
@@ -91,7 +92,9 @@ async def test_standard_batch_import_commits_successes_in_chunks(
"_parse_standard_oauth_import_entries", "_parse_standard_oauth_import_entries",
lambda _raw: [{"refresh_token": f"r-{idx}" + ("x" * 120)} for idx in range(3)], lambda _raw: [{"refresh_token": f"r-{idx}" + ("x" * 120)} for idx in range(3)],
) )
monkeypatch.setattr(oauthmod, "_get_provider_api_formats", lambda _provider: ["responses"]) monkeypatch.setattr(
oauthmod, "_get_provider_api_formats", lambda _provider: ["responses"]
)
monkeypatch.setattr( monkeypatch.setattr(
oauthmod, oauthmod,
"_release_batch_import_db_connection_before_await", "_release_batch_import_db_connection_before_await",
@@ -113,16 +116,24 @@ async def test_standard_batch_import_commits_successes_in_chunks(
async def _fake_enrich_auth_config(**kwargs: object) -> dict[str, object]: async def _fake_enrich_auth_config(**kwargs: object) -> dict[str, object]:
auth_config = dict(kwargs["auth_config"]) # type: ignore[call-overload] auth_config = dict(kwargs["auth_config"]) # type: ignore[call-overload]
auth_config["email"] = f"user-{next(key_ids)}@example.com" auth_config["email"] = f"user-{next(key_ids)}@example.com"
auth_config["account_name"] = "Workspace Alpha"
return auth_config return auth_config
created_ids = count(1) created_ids = count(1)
monkeypatch.setattr(oauthmod, "post_oauth_token", _fake_post_oauth_token) monkeypatch.setattr(oauthmod, "post_oauth_token", _fake_post_oauth_token)
monkeypatch.setattr(oauthmod, "enrich_auth_config", _fake_enrich_auth_config) monkeypatch.setattr(oauthmod, "enrich_auth_config", _fake_enrich_auth_config)
monkeypatch.setattr(oauthmod, "_check_duplicate_oauth_account", lambda *_args, **_kwargs: None) monkeypatch.setattr(
oauthmod, "_check_duplicate_oauth_account", lambda *_args, **_kwargs: None
)
def _fake_create_oauth_key(*_args: object, **kwargs: object) -> SimpleNamespace:
created_auth_configs.append(dict(kwargs["auth_config"]))
return SimpleNamespace(id=f"key-{next(created_ids)}")
monkeypatch.setattr( monkeypatch.setattr(
oauthmod, oauthmod,
"_create_oauth_key", "_create_oauth_key",
lambda *_args, **_kwargs: SimpleNamespace(id=f"key-{next(created_ids)}"), _fake_create_oauth_key,
) )
db = MagicMock() db = MagicMock()
@@ -140,6 +151,7 @@ async def test_standard_batch_import_commits_successes_in_chunks(
assert result.success == 3 assert result.success == 3
assert result.failed == 0 assert result.failed == 0
assert db.commit.call_count == 2 assert db.commit.call_count == 2
assert created_auth_configs[0]["account_name"] == "Workspace Alpha"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -152,14 +164,20 @@ async def test_kiro_batch_import_releases_db_connection_before_refresh(
def __init__(self, data: dict[str, object]) -> None: def __init__(self, data: dict[str, object]) -> None:
self._data = dict(data) self._data = dict(data)
self.provider_type = str(data.get("provider_type") or "") self.provider_type = str(data.get("provider_type") or "")
self.email = data.get("email") if isinstance(data.get("email"), str) else None self.email = (
data.get("email") if isinstance(data.get("email"), str) else None
)
self.auth_method = ( self.auth_method = (
data.get("auth_method") if isinstance(data.get("auth_method"), str) else "social" data.get("auth_method")
if isinstance(data.get("auth_method"), str)
else "social"
) )
self.refresh_token = str(data.get("refresh_token") or "") self.refresh_token = str(data.get("refresh_token") or "")
@staticmethod @staticmethod
def validate_required_fields(_cred: dict[str, object]) -> tuple[bool, str | None]: def validate_required_fields(
_cred: dict[str, object],
) -> tuple[bool, str | None]:
return True, None return True, None
@classmethod @classmethod
@@ -185,7 +203,9 @@ async def test_kiro_batch_import_releases_db_connection_before_refresh(
FakeKiroAuthConfig, FakeKiroAuthConfig,
) )
async def _fake_refresh_access_token(*_args: object, **_kwargs: object) -> tuple[str, object]: async def _fake_refresh_access_token(
*_args: object, **_kwargs: object
) -> tuple[str, object]:
raise RuntimeError("refresh token reused") raise RuntimeError("refresh token reused")
monkeypatch.setattr( monkeypatch.setattr(

View File

@@ -1,10 +1,15 @@
# pyright: reportMissingImports=false
from __future__ import annotations from __future__ import annotations
import json import json
from unittest.mock import AsyncMock
import jwt import jwt
import pytest
from src.core.provider_oauth_utils import parse_codex_id_token from src.core import provider_oauth_utils as module
from src.core.provider_oauth_utils import enrich_auth_config, parse_codex_id_token
def _encode_unsigned_jwt(payload: dict[str, object]) -> str: def _encode_unsigned_jwt(payload: dict[str, object]) -> str:
@@ -21,7 +26,9 @@ def test_parse_codex_id_token_extracts_auth_claim_fields() -> None:
"chatgpt_account_user_id": "user-1__acc-1", "chatgpt_account_user_id": "user-1__acc-1",
"chatgpt_plan_type": "team", "chatgpt_plan_type": "team",
"chatgpt_user_id": "user-1", "chatgpt_user_id": "user-1",
"organizations": [{"id": "org-1", "title": "Personal", "is_default": True}], "organizations": [
{"id": "org-1", "title": "Personal", "is_default": True}
],
}, },
} }
) )
@@ -72,3 +79,45 @@ def test_parse_codex_id_token_accepts_dict_payload() -> None:
"plan_type": "enterprise", "plan_type": "enterprise",
"user_id": "user-3", "user_id": "user-3",
} }
@pytest.mark.asyncio
async def test_enrich_auth_config_codex_adds_current_account_name() -> None:
from src.services.provider.envelope import ensure_providers_bootstrapped
access_token = _encode_unsigned_jwt(
{
"email": "u@example.com",
"https://api.openai.com/auth": {
"chatgpt_account_id": "acc-1",
"chatgpt_account_user_id": "user-1__acc-1",
"chatgpt_plan_type": "team",
"chatgpt_user_id": "user-1",
},
}
)
ensure_providers_bootstrapped()
fetch_account_name = AsyncMock(return_value="Workspace Alpha")
original = module.fetch_openai_account_name
module.fetch_openai_account_name = fetch_account_name
try:
out = await enrich_auth_config(
provider_type="codex",
auth_config={},
token_response={"access_token": access_token},
access_token=access_token,
proxy_config=None,
)
finally:
module.fetch_openai_account_name = original
fetch_account_name.assert_awaited_once_with(
access_token,
"acc-1",
proxy_config=None,
timeout_seconds=10.0,
)
assert out["account_id"] == "acc-1"
assert out["account_name"] == "Workspace Alpha"

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock
import jwt import jwt
import pytest import pytest
@@ -17,18 +18,28 @@ def test_codex_provider_behavior_has_no_runtime_envelope_or_variants() -> None:
assert behavior.cross_format_variant is None assert behavior.cross_format_variant is None
def test_openai_cli_normalizer_request_from_internal_codex_variant_preserves_store() -> None: def test_openai_cli_normalizer_request_from_internal_codex_variant_preserves_store() -> (
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer None
):
from src.core.api_format.conversion.normalizers.openai_cli import (
OpenAICliNormalizer,
)
normalizer = OpenAICliNormalizer() normalizer = OpenAICliNormalizer()
internal = normalizer.request_to_internal({"model": "gpt-test", "input": [], "store": True}) internal = normalizer.request_to_internal(
{"model": "gpt-test", "input": [], "store": True}
)
out = normalizer.request_from_internal(internal, target_variant="codex") out = normalizer.request_from_internal(internal, target_variant="codex")
assert out["store"] is True assert out["store"] is True
def test_openai_cli_normalizer_request_from_internal_codex_variant_does_not_inject_store() -> None: def test_openai_cli_normalizer_request_from_internal_codex_variant_does_not_inject_store() -> (
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer None
):
from src.core.api_format.conversion.normalizers.openai_cli import (
OpenAICliNormalizer,
)
normalizer = OpenAICliNormalizer() normalizer = OpenAICliNormalizer()
internal = normalizer.request_to_internal({"model": "gpt-test", "input": []}) internal = normalizer.request_to_internal({"model": "gpt-test", "input": []})
@@ -37,9 +48,13 @@ def test_openai_cli_normalizer_request_from_internal_codex_variant_does_not_inje
assert "store" not in out assert "store" not in out
def test_openai_cli_normalizer_codex_variant_keeps_instructions_missing_for_default_rule() -> None: def test_openai_cli_normalizer_codex_variant_keeps_instructions_missing_for_default_rule() -> (
None
):
from src.api.handlers.base.request_builder import apply_body_rules from src.api.handlers.base.request_builder import apply_body_rules
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer from src.core.api_format.conversion.normalizers.openai_cli import (
OpenAICliNormalizer,
)
from src.core.api_format.metadata import CODEX_DEFAULT_BODY_RULES from src.core.api_format.metadata import CODEX_DEFAULT_BODY_RULES
normalizer = OpenAICliNormalizer() normalizer = OpenAICliNormalizer()
@@ -53,7 +68,9 @@ def test_openai_cli_normalizer_codex_variant_keeps_instructions_missing_for_defa
def test_openai_cli_normalizer_patch_for_codex_is_noop() -> None: def test_openai_cli_normalizer_patch_for_codex_is_noop() -> None:
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer from src.core.api_format.conversion.normalizers.openai_cli import (
OpenAICliNormalizer,
)
normalizer = OpenAICliNormalizer() normalizer = OpenAICliNormalizer()
out = normalizer.patch_for_variant( out = normalizer.patch_for_variant(
@@ -73,7 +90,9 @@ def test_openai_cli_normalizer_patch_for_codex_is_noop() -> None:
def test_codex_passthrough_builder_preserves_real_codex_headers() -> None: def test_codex_passthrough_builder_preserves_real_codex_headers() -> None:
builder = PassthroughRequestBuilder() builder = PassthroughRequestBuilder()
endpoint = SimpleNamespace(api_family="openai", endpoint_kind="cli", header_rules=None) endpoint = SimpleNamespace(
api_family="openai", endpoint_kind="cli", header_rules=None
)
key = SimpleNamespace(api_key="unused") key = SimpleNamespace(api_key="unused")
headers = builder.build_headers( headers = builder.build_headers(
@@ -139,7 +158,9 @@ def _encode_unsigned_jwt(payload: dict[str, object]) -> str:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enrich_codex_uses_access_token_when_id_token_missing() -> None: async def test_enrich_codex_uses_access_token_when_id_token_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.services.provider.adapters.codex.plugin import enrich_codex from src.services.provider.adapters.codex.plugin import enrich_codex
access_token = _encode_unsigned_jwt( access_token = _encode_unsigned_jwt(
@@ -154,6 +175,10 @@ async def test_enrich_codex_uses_access_token_when_id_token_missing() -> None:
) )
auth_config: dict[str, object] = {} auth_config: dict[str, object] = {}
monkeypatch.setattr(
"src.core.provider_oauth_utils.fetch_openai_account_name",
AsyncMock(return_value="Workspace Alpha"),
)
out = await enrich_codex( out = await enrich_codex(
auth_config=auth_config, auth_config=auth_config,
token_response={"access_token": access_token}, token_response={"access_token": access_token},
@@ -163,5 +188,6 @@ async def test_enrich_codex_uses_access_token_when_id_token_missing() -> None:
assert out["email"] == "u@example.com" assert out["email"] == "u@example.com"
assert out["account_id"] == "acc-access" assert out["account_id"] == "acc-access"
assert out["account_name"] == "Workspace Alpha"
assert out["plan_type"] == "team" assert out["plan_type"] == "team"
assert out["user_id"] == "user-access" assert out["user_id"] == "user-access"

View File

@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
import httpx
import pytest import pytest
from src.services.provider import auth as module from src.services.provider import auth as module
@@ -46,7 +47,9 @@ class _FakeSessionCtx:
return False return False
def _install_module(monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str, Any]) -> None: def _install_module(
monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str, Any]
) -> None:
fake_module = types.ModuleType(name) fake_module = types.ModuleType(name)
for key, value in attrs.items(): for key, value in attrs.items():
setattr(fake_module, key, value) setattr(fake_module, key, value)
@@ -88,7 +91,9 @@ def test_mark_refresh_token_invalid_persists_detached_key(
module, "object_session", lambda _key: (_ for _ in ()).throw(RuntimeError()) module, "object_session", lambda _key: (_ for _ in ()).throw(RuntimeError())
) )
_install_module( _install_module(
monkeypatch, "src.database", {"create_session": lambda: _FakeSessionCtx(fake_db)} monkeypatch,
"src.database",
{"create_session": lambda: _FakeSessionCtx(fake_db)},
) )
_install_module( _install_module(
monkeypatch, monkeypatch,
@@ -105,7 +110,9 @@ def test_mark_refresh_token_invalid_persists_detached_key(
assert fake_db.committed is True assert fake_db.committed is True
assert key.oauth_invalid_at is not None assert key.oauth_invalid_at is not None
assert row.oauth_invalid_at is not None assert row.oauth_invalid_at is not None
assert str(key.oauth_invalid_reason).startswith("[REFRESH_FAILED] Token 续期失败 (401)") assert str(key.oauth_invalid_reason).startswith(
"[REFRESH_FAILED] Token 续期失败 (401)"
)
assert "refresh_token_reused" in str(row.oauth_invalid_reason) assert "refresh_token_reused" in str(row.oauth_invalid_reason)
@@ -144,3 +151,66 @@ def test_persist_refreshed_token_clears_legacy_token_invalidated_account_block(
assert key.auth_config == 'enc:{"refresh_token": "rt-2"}' assert key.auth_config == 'enc:{"refresh_token": "rt-2"}'
assert key.oauth_invalid_at is None assert key.oauth_invalid_at is None
assert key.oauth_invalid_reason is None assert key.oauth_invalid_reason is None
@pytest.mark.asyncio
async def test_refresh_generic_oauth_token_persists_enriched_account_name(
monkeypatch: pytest.MonkeyPatch,
) -> None:
key = SimpleNamespace(id="key-1")
endpoint = SimpleNamespace()
template = SimpleNamespace(
oauth=SimpleNamespace(
token_url="https://example.com/oauth/token",
client_id="client-id",
client_secret=None,
scopes=[],
)
)
persisted: dict[str, Any] = {}
async def _fake_post_oauth_token(**_kwargs: Any) -> httpx.Response:
return httpx.Response(
200,
json={
"access_token": "new-token",
"refresh_token": "rt-2",
"expires_in": 3600,
"token_type": "Bearer",
},
request=httpx.Request("POST", "https://example.com/oauth/token"),
)
async def _fake_enrich_auth_config(**kwargs: Any) -> dict[str, Any]:
auth_config = dict(kwargs["auth_config"])
auth_config["account_name"] = "Workspace Alpha"
return auth_config
monkeypatch.setattr(module, "_get_proxy_config", lambda *_args: None)
monkeypatch.setattr(module, "post_oauth_token", _fake_post_oauth_token)
monkeypatch.setattr(module, "enrich_auth_config", _fake_enrich_auth_config)
monkeypatch.setattr(
module,
"_persist_refreshed_token",
lambda _key, _access_token, token_meta: persisted.update(
{"access_token": _access_token, "token_meta": dict(token_meta)}
),
)
token_meta = {
"provider_type": "codex",
"refresh_token": "rt-1",
}
refreshed = await module._refresh_generic_oauth_token(
key,
endpoint,
template,
"codex",
"rt-1",
token_meta,
)
assert refreshed["account_name"] == "Workspace Alpha"
assert persisted["access_token"] == "new-token"
assert persisted["token_meta"]["account_name"] == "Workspace Alpha"

View File

@@ -38,3 +38,10 @@ def test_derive_oauth_expires_at_fallback_to_legacy_datetime() -> None:
) )
assert pool_routes._derive_oauth_expires_at(key) == 1772586123 assert pool_routes._derive_oauth_expires_at(key) == 1772586123
def test_derive_oauth_account_name_from_auth_config() -> None:
assert (
pool_routes._derive_oauth_account_name({"account_name": " Workspace Alpha "})
== "Workspace Alpha"
)

View File

@@ -18,7 +18,7 @@ def test_build_key_response_includes_codex_identity_metadata(
api_formats=["openai:chat"], api_formats=["openai:chat"],
auth_type="oauth", auth_type="oauth",
api_key="enc-access-token", api_key="enc-access-token",
auth_config='{"email":"u@example.com","plan_type":"team","account_id":"acc-1","account_user_id":"user-1__acc-1","organizations":[{"id":"org-1","title":"Personal","is_default":true,"role":"owner"}],"expires_at":123456}', auth_config='{"email":"u@example.com","plan_type":"team","account_id":"acc-1","account_name":"Workspace Alpha","account_user_id":"user-1__acc-1","organizations":[{"id":"org-1","title":"Personal","is_default":true,"role":"owner"}],"expires_at":123456}',
name="codex-user", name="codex-user",
) )
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -49,6 +49,7 @@ def test_build_key_response_includes_codex_identity_metadata(
assert result.oauth_email == "u@example.com" assert result.oauth_email == "u@example.com"
assert result.oauth_plan_type == "team" assert result.oauth_plan_type == "team"
assert result.oauth_account_id == "acc-1" assert result.oauth_account_id == "acc-1"
assert result.oauth_account_name == "Workspace Alpha"
assert result.oauth_account_user_id == "user-1__acc-1" assert result.oauth_account_user_id == "user-1__acc-1"
assert len(result.oauth_organizations) == 1 assert len(result.oauth_organizations) == 1
assert result.oauth_organizations[0].title == "Personal" assert result.oauth_organizations[0].title == "Personal"