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_account_id?: string | null
oauth_account_user_id?: string | null
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null
quota_updated_at?: number | null
health_score?: number

View File

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

View File

@@ -1,5 +1,10 @@
import type { OAuthOrganizationInfo } from '@/api/endpoints/types/provider'
type OAuthIdentityDisplayValue = {
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null
} | null | undefined
function formatOAuthIdentityShort(
value: string | null | undefined,
head = 8,
@@ -11,24 +16,44 @@ function formatOAuthIdentityShort(
return `${normalized.slice(0, head)}...${normalized.slice(-tail)}`
}
function getPrimaryOAuthOrganizationId(
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined,
): string | null {
const organizations = Array.isArray(value?.oauth_organizations) ? value.oauth_organizations : []
const defaultOrg = organizations.find(
(org) => org?.is_default && typeof org?.id === 'string' && org.id.trim(),
)
if (defaultOrg?.id) return defaultOrg.id.trim()
const firstWithId = organizations.find(
(org) => typeof org?.id === 'string' && org.id.trim(),
)
return firstWithId?.id?.trim() || null
function getPrimaryOAuthOrganization(
value: OAuthIdentityDisplayValue,
): { id: string; title: string } | null {
const organizations: OAuthOrganizationInfo[] = Array.isArray(value?.oauth_organizations)
? value.oauth_organizations
: []
let firstWithId: OAuthOrganizationInfo | null = null
for (let index = 0; index < organizations.length; index += 1) {
const org = organizations[index]
if (typeof org?.id !== 'string' || !org.id.trim()) continue
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(
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined,
value: OAuthIdentityDisplayValue,
): { id: string; label: string } | null {
const id = getPrimaryOAuthOrganizationId(value)
if (!id) return null
return { id, label: formatOAuthIdentityShort(id) }
const org = getPrimaryOAuthOrganization(value)
if (!org) return null
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}`"
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
class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
:title="getQuotaProgressTooltip(item)"
@@ -530,12 +530,21 @@
:style="{ width: `${item.remainingPercent}%` }"
/>
</div>
<span
class="tabular-nums text-right whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ item.remainingPercent.toFixed(1) }}%
</span>
<div class="flex flex-col items-end justify-center gap-0.5 text-right leading-tight">
<span
class="tabular-nums whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ 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>
@@ -971,7 +980,7 @@
:key="`${key.key_id}-quota-mobile-${idx}`"
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
class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
:title="getQuotaProgressTooltip(item)"
@@ -985,12 +994,21 @@
:style="{ width: `${item.remainingPercent}%` }"
/>
</div>
<span
class="tabular-nums text-right whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ item.remainingPercent.toFixed(1) }}%
</span>
<div class="flex flex-col items-end justify-center gap-0.5 text-right leading-tight">
<span
class="tabular-nums whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ 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>
@@ -1743,6 +1761,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
oauth_plan_type: key.oauth_plan_type ?? null,
oauth_account_id: key.oauth_account_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_invalid_at: key.oauth_invalid_at ?? null,
oauth_invalid_reason: key.oauth_invalid_reason ?? null,

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import json
import random
from typing import Any, Awaitable, Callable
from urllib.parse import quote, urlsplit, urlunsplit
@@ -9,11 +10,14 @@ import httpx
import jwt
from src.clients.http_client import HTTPClientPool
from src.core.logger import logger
from src.core.logger import logger # pyright: ignore
from src.core.provider_types import ProviderType
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
_OPENAI_ACCOUNTS_CHECK_URL = (
"https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
)
def _coerce_proxy_url(proxy_config: dict[str, Any] | None) -> str | None:
@@ -54,11 +58,15 @@ def _inject_auth_into_url(url: str, username: str, password: str | None = None)
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
auth_part = (
f"{encoded_username}:{encoded_password}" if encoded_password else encoded_username
f"{encoded_username}:{encoded_password}"
if encoded_password
else encoded_username
)
netloc = f"{auth_part}@{host_part}"
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
return urlunsplit(
(parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)
)
except Exception:
return url
@@ -189,7 +197,7 @@ def _tls_client_post_sync(
timeout_seconds: float,
) -> tuple[int, dict[str, str], str]:
# tls-client is optional at runtime; import only when needed.
import tls_client # type: ignore
import tls_client # pyright: ignore[reportMissingImports]
session = tls_client.Session(
client_identifier="firefox_120",
@@ -425,7 +433,10 @@ async def fetch_google_email(
try:
resp = await client.get(
_GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
timeout=timeout_seconds,
)
if resp.status_code < 200 or resp.status_code >= 300:
@@ -439,6 +450,111 @@ async def fetch_google_email(
return None
def _extract_openai_account_name(payload: Any, account_id: str) -> str | None:
if not isinstance(payload, dict):
return None
accounts = payload.get("accounts")
if isinstance(accounts, dict):
direct = accounts.get(account_id)
if isinstance(direct, dict):
account = direct.get("account")
account_info = account if isinstance(account, dict) else direct
direct_name = _as_non_empty_str(account_info.get("name"))
if direct_name:
return direct_name
for item in accounts.values():
if not isinstance(item, dict):
continue
account = item.get("account")
account_info = account if isinstance(account, dict) else item
matched_account_id = _first_non_empty_str(
[
account_info.get("id"),
account_info.get("account_id"),
account_info.get("accountId"),
item.get("id"),
item.get("account_id"),
item.get("accountId"),
]
)
if matched_account_id != account_id:
continue
matched_name = _as_non_empty_str(account_info.get("name"))
if matched_name:
return matched_name
return None
async def fetch_openai_account_name(
access_token: str,
account_id: str,
*,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 10.0,
) -> str | None:
if not access_token or not account_id:
return None
proxy_url = _coerce_proxy_url(proxy_config)
if not proxy_url and proxy_config:
try:
from src.services.proxy_node.resolver import build_proxy_url_async
proxy_url = await build_proxy_url_async(proxy_config)
except Exception:
proxy_url = None
try:
from curl_cffi.requests import AsyncSession # pyright: ignore[reportMissingImports]
session = AsyncSession(
impersonate="chrome110",
proxies={"http": proxy_url, "https": proxy_url} if proxy_url else None,
timeout=timeout_seconds,
verify=False if proxy_url else True,
)
try:
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://chatgpt.com/",
"Origin": "https://chatgpt.com",
"Connection": "keep-alive",
}
for attempt in range(3):
if attempt:
await asyncio.sleep(
[1.0, 2.0][attempt - 1] + random.uniform(0.5, 1.5)
)
resp = await session.get(_OPENAI_ACCOUNTS_CHECK_URL, headers=headers)
if 200 <= resp.status_code < 300:
return _extract_openai_account_name(resp.json(), account_id)
finally:
await session.close()
except Exception:
pass
client = await HTTPClientPool.get_proxy_client(proxy_config)
try:
resp = await client.get(
_OPENAI_ACCOUNTS_CHECK_URL,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
timeout=timeout_seconds,
)
if resp.status_code < 200 or resp.status_code >= 300:
return None
return _extract_openai_account_name(resp.json(), account_id)
except Exception:
return None
def extract_claude_email_from_token_response(token: dict[str, Any]) -> str | None:
# CLIProxyAPI expects: { account: { email_address: ... } }
try:

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,7 @@ async def test_standard_batch_import_commits_successes_in_chunks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
key_ids = count(1)
created_auth_configs: list[dict[str, object]] = []
monkeypatch.setattr(
oauthmod,
@@ -91,7 +92,9 @@ async def test_standard_batch_import_commits_successes_in_chunks(
"_parse_standard_oauth_import_entries",
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(
oauthmod,
"_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]:
auth_config = dict(kwargs["auth_config"]) # type: ignore[call-overload]
auth_config["email"] = f"user-{next(key_ids)}@example.com"
auth_config["account_name"] = "Workspace Alpha"
return auth_config
created_ids = count(1)
monkeypatch.setattr(oauthmod, "post_oauth_token", _fake_post_oauth_token)
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(
oauthmod,
"_create_oauth_key",
lambda *_args, **_kwargs: SimpleNamespace(id=f"key-{next(created_ids)}"),
_fake_create_oauth_key,
)
db = MagicMock()
@@ -140,6 +151,7 @@ async def test_standard_batch_import_commits_successes_in_chunks(
assert result.success == 3
assert result.failed == 0
assert db.commit.call_count == 2
assert created_auth_configs[0]["account_name"] == "Workspace Alpha"
@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:
self._data = dict(data)
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 = (
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 "")
@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
@classmethod
@@ -185,7 +203,9 @@ async def test_kiro_batch_import_releases_db_connection_before_refresh(
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")
monkeypatch.setattr(

View File

@@ -1,10 +1,15 @@
# pyright: reportMissingImports=false
from __future__ import annotations
import json
from unittest.mock import AsyncMock
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:
@@ -21,7 +26,9 @@ def test_parse_codex_id_token_extracts_auth_claim_fields() -> None:
"chatgpt_account_user_id": "user-1__acc-1",
"chatgpt_plan_type": "team",
"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",
"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 types import SimpleNamespace
from unittest.mock import AsyncMock
import jwt
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
def test_openai_cli_normalizer_request_from_internal_codex_variant_preserves_store() -> None:
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
def test_openai_cli_normalizer_request_from_internal_codex_variant_preserves_store() -> (
None
):
from src.core.api_format.conversion.normalizers.openai_cli import (
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")
assert out["store"] is True
def test_openai_cli_normalizer_request_from_internal_codex_variant_does_not_inject_store() -> None:
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
def test_openai_cli_normalizer_request_from_internal_codex_variant_does_not_inject_store() -> (
None
):
from src.core.api_format.conversion.normalizers.openai_cli import (
OpenAICliNormalizer,
)
normalizer = OpenAICliNormalizer()
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
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.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
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:
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
from src.core.api_format.conversion.normalizers.openai_cli import (
OpenAICliNormalizer,
)
normalizer = OpenAICliNormalizer()
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:
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")
headers = builder.build_headers(
@@ -139,7 +158,9 @@ def _encode_unsigned_jwt(payload: dict[str, object]) -> str:
@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
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] = {}
monkeypatch.setattr(
"src.core.provider_oauth_utils.fetch_openai_account_name",
AsyncMock(return_value="Workspace Alpha"),
)
out = await enrich_codex(
auth_config=auth_config,
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["account_id"] == "acc-access"
assert out["account_name"] == "Workspace Alpha"
assert out["plan_type"] == "team"
assert out["user_id"] == "user-access"

View File

@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any
import httpx
import pytest
from src.services.provider import auth as module
@@ -46,7 +47,9 @@ class _FakeSessionCtx:
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)
for key, value in attrs.items():
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())
)
_install_module(
monkeypatch, "src.database", {"create_session": lambda: _FakeSessionCtx(fake_db)}
monkeypatch,
"src.database",
{"create_session": lambda: _FakeSessionCtx(fake_db)},
)
_install_module(
monkeypatch,
@@ -105,7 +110,9 @@ def test_mark_refresh_token_invalid_persists_detached_key(
assert fake_db.committed is True
assert key.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)
@@ -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.oauth_invalid_at 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
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"],
auth_type="oauth",
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",
)
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_plan_type == "team"
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 len(result.oauth_organizations) == 1
assert result.oauth_organizations[0].title == "Personal"