mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: Codex account_name 透传并优化池列表 OAuth 标识与额度重置倒计时展示
(cherry picked from commit 1b9176fc4a)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 失效原因
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user