mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 引入 status_snapshot 统一 provider key 状态管理
- 新增 StatusSnapshot 模型,聚合 OAuth / 账号 / 配额三维状态 - 新增 StatusSnapshotStore 负责快照的持久化与查询 - 重构 response_builder / endpoint_models,基于 snapshot 输出状态字段 - 前端抽取 providerKeyStatus / oauthRefreshFeedback 工具函数, 统一 PoolManagement、ProviderDetailDrawer、BatchDialog 的状态展示 - errorParser 增加已知 OAuth 错误的友好提示 - refresher 适配 snapshot 写入,account_state 扩展状态分类 - 新增 alembic 迁移及存量数据回填脚本 - 补充前后端单元测试
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
"""add status_snapshot column to provider_api_keys
|
||||||
|
|
||||||
|
Revision ID: c9d8e7f6a5b4
|
||||||
|
Revises: f6e7d8c9b0a1
|
||||||
|
Create Date: 2026-03-20 12:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c9d8e7f6a5b4"
|
||||||
|
down_revision: str | None = "f6e7d8c9b0a1"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("provider_api_keys", "status_snapshot"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("status_snapshot", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("provider_api_keys", "status_snapshot"):
|
||||||
|
op.drop_column("provider_api_keys", "status_snapshot")
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import { dedupedRequest } from '@/utils/cache'
|
import { dedupedRequest } from '@/utils/cache'
|
||||||
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
|
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
|
||||||
|
import type { ProviderKeyStatusSnapshot } from './types/statusSnapshot'
|
||||||
|
|
||||||
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
|
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
@@ -99,19 +100,20 @@ export interface PoolKeyDetail {
|
|||||||
is_active: boolean
|
is_active: boolean
|
||||||
auth_type: string
|
auth_type: string
|
||||||
oauth_expires_at?: number | null
|
oauth_expires_at?: number | null
|
||||||
oauth_invalid_at?: number | null
|
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||||
oauth_invalid_reason?: string | null
|
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||||
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_account_name?: string | null
|
||||||
oauth_organizations?: OAuthOrganizationInfo[] | null
|
oauth_organizations?: OAuthOrganizationInfo[] | null
|
||||||
account_status_code?: string | null
|
account_status_code?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||||
account_status_label?: string | null
|
account_status_label?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||||
account_status_reason?: string | null
|
account_status_reason?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||||
account_status_blocked?: boolean
|
account_status_blocked?: boolean // 兼容字段;优先使用 status_snapshot.account
|
||||||
account_status_recoverable?: boolean
|
account_status_recoverable?: boolean // 兼容字段;优先使用 status_snapshot.account
|
||||||
account_status_source?: string | null
|
account_status_source?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||||
|
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||||
quota_updated_at?: number | null
|
quota_updated_at?: number | null
|
||||||
health_score?: number
|
health_score?: number
|
||||||
circuit_breaker_open?: boolean
|
circuit_breaker_open?: boolean
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ProviderKeyStatusSnapshot } from './statusSnapshot'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理配置类型
|
* 代理配置类型
|
||||||
* 支持两种模式:
|
* 支持两种模式:
|
||||||
@@ -290,8 +292,9 @@ export interface EndpointAPIKey {
|
|||||||
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_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 // 兼容字段;优先使用 status_snapshot.oauth
|
||||||
oauth_invalid_reason?: string | null // OAuth Token 失效原因
|
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||||
|
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||||
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
|
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
|
||||||
upstream_metadata?: UpstreamMetadata | null
|
upstream_metadata?: UpstreamMetadata | null
|
||||||
// Key 级别代理配置(覆盖 Provider 级别代理)
|
// Key 级别代理配置(覆盖 Provider 级别代理)
|
||||||
|
|||||||
36
frontend/src/api/endpoints/types/statusSnapshot.ts
Normal file
36
frontend/src/api/endpoints/types/statusSnapshot.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface OAuthStatusSnapshot {
|
||||||
|
code: 'none' | 'valid' | 'expiring' | 'expired' | 'invalid' | 'check_failed'
|
||||||
|
label?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
expires_at?: number | null
|
||||||
|
invalid_at?: number | null
|
||||||
|
source?: string | null
|
||||||
|
requires_reauth?: boolean
|
||||||
|
expiring_soon?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountStatusSnapshot {
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
blocked: boolean
|
||||||
|
source?: string | null
|
||||||
|
recoverable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaStatusSnapshot {
|
||||||
|
code: 'unknown' | 'ok' | 'exhausted'
|
||||||
|
label?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
exhausted: boolean
|
||||||
|
usage_ratio?: number | null
|
||||||
|
updated_at?: number | null
|
||||||
|
reset_seconds?: number | null
|
||||||
|
plan_type?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderKeyStatusSnapshot {
|
||||||
|
oauth: OAuthStatusSnapshot
|
||||||
|
account: AccountStatusSnapshot
|
||||||
|
quota: QuotaStatusSnapshot
|
||||||
|
}
|
||||||
@@ -174,15 +174,18 @@ export function getOAuthExpiresCountdown(
|
|||||||
invalidReason?: string | null
|
invalidReason?: string | null
|
||||||
): OAuthStatusInfo | null {
|
): OAuthStatusInfo | null {
|
||||||
void _tick
|
void _tick
|
||||||
|
const normalizedInvalidReason = typeof invalidReason === 'string'
|
||||||
|
? invalidReason.trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
// 优先检查失效状态(失效比过期更严重)
|
// 优先检查失效状态(失效比过期更严重)
|
||||||
if (invalidAt != null) {
|
if (invalidAt != null || normalizedInvalidReason) {
|
||||||
return {
|
return {
|
||||||
text: '已失效',
|
text: '已失效',
|
||||||
isExpired: false,
|
isExpired: false,
|
||||||
isExpiringSoon: false,
|
isExpiringSoon: false,
|
||||||
isInvalid: true,
|
isInvalid: true,
|
||||||
invalidReason: invalidReason || undefined
|
invalidReason: normalizedInvalidReason || undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,8 +278,13 @@ import {
|
|||||||
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
||||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
import { classifyAccountBlockLabel, cleanAccountBlockReason, isAccountLevelBlockReason, isRefreshFailedReason } from '@/utils/accountBlock'
|
|
||||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||||
|
import {
|
||||||
|
getAccountStatusDisplay,
|
||||||
|
getAccountStatusTitle,
|
||||||
|
getOAuthStatusDisplay,
|
||||||
|
getOAuthStatusTitle,
|
||||||
|
} from '@/utils/providerKeyStatus'
|
||||||
|
|
||||||
type QuickSelectorValue =
|
type QuickSelectorValue =
|
||||||
| 'banned'
|
| 'banned'
|
||||||
@@ -423,21 +428,12 @@ function normalizeAuthTypeLabel(authType: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStatusBadgeLabel(key: PoolKeyDetail): string | null {
|
function getStatusBadgeLabel(key: PoolKeyDetail): string | null {
|
||||||
const explicitLabel = String(key.account_status_label || '').trim()
|
const account = getAccountStatusDisplay(key)
|
||||||
if (explicitLabel) return explicitLabel
|
if (account.blocked && account.label) return account.label
|
||||||
|
|
||||||
const reason = String(key.oauth_invalid_reason || '').trim()
|
const oauth = getOAuthStatusDisplay(key, 0)
|
||||||
if (isAccountLevelBlockReason(reason)) {
|
if (oauth?.isInvalid) return 'Token 失效'
|
||||||
const cleaned = cleanAccountBlockReason(reason)
|
if (oauth?.isExpired) return 'Token 过期'
|
||||||
return classifyAccountBlockLabel(cleaned || reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizeText(key.auth_type) !== 'oauth') return null
|
|
||||||
if (isRefreshFailedReason(reason)) return '续期失败'
|
|
||||||
if (key.oauth_invalid_at != null || normalizeText(reason)) return 'Token 失效'
|
|
||||||
if (typeof key.oauth_expires_at === 'number' && key.oauth_expires_at > 0) {
|
|
||||||
return key.oauth_expires_at * 1000 <= Date.now() ? 'Token 过期' : null
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,20 +441,11 @@ function getStatusBadgeTitle(key: PoolKeyDetail): string {
|
|||||||
const label = getStatusBadgeLabel(key)
|
const label = getStatusBadgeLabel(key)
|
||||||
if (!label) return ''
|
if (!label) return ''
|
||||||
|
|
||||||
const explicitReason = String(key.account_status_reason || '').trim()
|
const accountTitle = getAccountStatusTitle(key)
|
||||||
if (explicitReason) return `${label}: ${explicitReason}`
|
if (accountTitle) return accountTitle
|
||||||
|
|
||||||
const reason = String(key.oauth_invalid_reason || '').trim()
|
const oauthTitle = getOAuthStatusTitle(key, 0)
|
||||||
if (!reason) return label
|
return oauthTitle || label
|
||||||
if (isAccountLevelBlockReason(reason)) {
|
|
||||||
const cleaned = cleanAccountBlockReason(reason)
|
|
||||||
return cleaned ? `${label}: ${cleaned}` : label
|
|
||||||
}
|
|
||||||
if (isRefreshFailedReason(reason)) {
|
|
||||||
const cleaned = reason.replace(/^\[REFRESH_FAILED\]\s*/i, '').trim()
|
|
||||||
return cleaned ? `${label}: ${cleaned}` : label
|
|
||||||
}
|
|
||||||
return `${label}: ${reason}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(value: string): string {
|
function formatRelativeTime(value: string): string {
|
||||||
|
|||||||
@@ -334,7 +334,7 @@
|
|||||||
<!-- OAuth 状态(失效/过期/倒计时)和刷新按钮 -->
|
<!-- OAuth 状态(失效/过期/倒计时)和刷新按钮 -->
|
||||||
<template v-if="getKeyOAuthExpires(key)">
|
<template v-if="getKeyOAuthExpires(key)">
|
||||||
<!-- 账号级别异常:醒目提示 + 清除按钮 -->
|
<!-- 账号级别异常:醒目提示 + 清除按钮 -->
|
||||||
<template v-if="getKeyOAuthExpires(key)?.isInvalid && isAccountLevelBlock(key)">
|
<template v-if="isAccountLevelBlock(key)">
|
||||||
<Badge
|
<Badge
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
|
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
|
||||||
@@ -375,7 +375,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-4 w-4 shrink-0"
|
class="h-4 w-4 shrink-0"
|
||||||
:disabled="refreshingOAuthKeyId === key.id"
|
:disabled="refreshingOAuthKeyId === key.id"
|
||||||
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
|
:title="getOAuthRefreshButtonTitle(key)"
|
||||||
@click.stop="handleRefreshOAuth(key)"
|
@click.stop="handleRefreshOAuth(key)"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
@@ -1089,7 +1089,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useCountdownTimer, formatCountdown, getOAuthExpiresCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
import { useCountdownTimer, formatCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
||||||
import {
|
import {
|
||||||
getProvider,
|
getProvider,
|
||||||
getProviderEndpoints,
|
getProviderEndpoints,
|
||||||
@@ -1136,8 +1136,15 @@ import {
|
|||||||
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||||
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
|
||||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||||
|
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||||||
|
import {
|
||||||
|
getAccountStatusDisplay,
|
||||||
|
getAccountStatusTitle,
|
||||||
|
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
|
||||||
|
getOAuthStatusDisplay,
|
||||||
|
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||||||
|
} from '@/utils/providerKeyStatus'
|
||||||
|
|
||||||
// 扩展端点类型,包含密钥列表
|
// 扩展端点类型,包含密钥列表
|
||||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||||
@@ -1643,15 +1650,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
|||||||
refreshingOAuthKeyId.value = key.id
|
refreshingOAuthKeyId.value = key.id
|
||||||
try {
|
try {
|
||||||
const result = await refreshProviderOAuth(key.id)
|
const result = await refreshProviderOAuth(key.id)
|
||||||
if (result.account_state_recheck_attempted) {
|
let refreshedKey: EndpointAPIKey | null = null
|
||||||
if (result.account_state_recheck_error) {
|
|
||||||
showWarning('Token 刷新成功,但账号状态复检失败')
|
|
||||||
} else {
|
|
||||||
showSuccess('Token 刷新成功,已复检账号状态')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showSuccess('Token 刷新成功')
|
|
||||||
}
|
|
||||||
// 更新本地数据
|
// 更新本地数据
|
||||||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||||||
if (keyInList) {
|
if (keyInList) {
|
||||||
@@ -1663,8 +1662,19 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
|||||||
if (freshKeys) {
|
if (freshKeys) {
|
||||||
providerKeys.value = freshKeys
|
providerKeys.value = freshKeys
|
||||||
syncCurrentSelections(endpoints.value, freshKeys)
|
syncCurrentSelections(endpoints.value, freshKeys)
|
||||||
|
refreshedKey = freshKeys.find(item => item.id === key.id) ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const feedback = getOAuthRefreshFeedback({
|
||||||
|
accountStateRecheckAttempted: result.account_state_recheck_attempted,
|
||||||
|
accountStateRecheckError: result.account_state_recheck_error,
|
||||||
|
snapshot: refreshedKey,
|
||||||
|
})
|
||||||
|
if (feedback.tone === 'warning') {
|
||||||
|
showWarning(feedback.message)
|
||||||
|
} else {
|
||||||
|
showSuccess(feedback.message)
|
||||||
|
}
|
||||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
||||||
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
||||||
void autoRefreshQuotaInBackground()
|
void autoRefreshQuotaInBackground()
|
||||||
@@ -1677,7 +1687,9 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
|||||||
|
|
||||||
// 判断是否为账号级别的封禁(刷新 token 无法修复)
|
// 判断是否为账号级别的封禁(刷新 token 无法修复)
|
||||||
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
|
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
|
||||||
return isAccountLevelBlockReason(key.oauth_invalid_reason)
|
const account = getAccountStatusDisplay(key)
|
||||||
|
const oauth = getOAuthStatusDisplay(key, countdownTick.value)
|
||||||
|
return account.blocked && !oauth?.isInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除 OAuth 失效标记
|
// 清除 OAuth 失效标记
|
||||||
@@ -1701,6 +1713,27 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
|||||||
if (keyInList) {
|
if (keyInList) {
|
||||||
keyInList.oauth_invalid_at = null
|
keyInList.oauth_invalid_at = null
|
||||||
keyInList.oauth_invalid_reason = null
|
keyInList.oauth_invalid_reason = null
|
||||||
|
if (keyInList.status_snapshot) {
|
||||||
|
keyInList.status_snapshot = {
|
||||||
|
...keyInList.status_snapshot,
|
||||||
|
oauth: {
|
||||||
|
...keyInList.status_snapshot.oauth,
|
||||||
|
code: 'none',
|
||||||
|
label: null,
|
||||||
|
reason: null,
|
||||||
|
invalid_at: null,
|
||||||
|
requires_reauth: false,
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
...keyInList.status_snapshot.account,
|
||||||
|
code: 'ok',
|
||||||
|
label: null,
|
||||||
|
reason: null,
|
||||||
|
blocked: false,
|
||||||
|
recoverable: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await loadEndpoints()
|
await loadEndpoints()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -1832,8 +1865,11 @@ function shouldAutoRefreshCodexQuota(): boolean {
|
|||||||
|
|
||||||
// 检查 OAuth Token 是否即将过期(Codex / Antigravity / Kiro)
|
// 检查 OAuth Token 是否即将过期(Codex / Antigravity / Kiro)
|
||||||
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
||||||
return key.oauth_invalid_at == null
|
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
|
||||||
&& typeof key.oauth_expires_at === 'number'
|
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return typeof key.oauth_expires_at === 'number'
|
||||||
&& (key.oauth_expires_at - now) <= AUTO_TOKEN_REFRESH_SKEW_SECONDS
|
&& (key.oauth_expires_at - now) <= AUTO_TOKEN_REFRESH_SKEW_SECONDS
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2641,31 +2677,20 @@ function getOAuthPlanTypeClass(planType: string): string {
|
|||||||
|
|
||||||
// OAuth 状态信息(包括失效和过期)
|
// OAuth 状态信息(包括失效和过期)
|
||||||
function getKeyOAuthExpires(key: EndpointAPIKey) {
|
function getKeyOAuthExpires(key: EndpointAPIKey) {
|
||||||
if (key.auth_type !== 'oauth') return null
|
return getOAuthStatusDisplay(key, countdownTick.value)
|
||||||
// 即使没有 expires_at,也要检查 invalid_at
|
}
|
||||||
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
|
|
||||||
return getOAuthExpiresCountdown(
|
function getOAuthRefreshButtonTitle(key: EndpointAPIKey): string {
|
||||||
key.oauth_expires_at,
|
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
|
||||||
countdownTick.value,
|
|
||||||
key.oauth_invalid_at,
|
|
||||||
key.oauth_invalid_reason
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth 状态的 title 提示
|
// OAuth 状态的 title 提示
|
||||||
function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
||||||
const status = getKeyOAuthExpires(key)
|
const accountTitle = getAccountStatusTitle(key)
|
||||||
if (!status) return ''
|
if (accountTitle && isAccountLevelBlock(key)) {
|
||||||
if (status.isInvalid) {
|
return accountTitle
|
||||||
const cleaned = status.invalidReason && isAccountLevelBlockReason(status.invalidReason)
|
|
||||||
? cleanAccountBlockReason(status.invalidReason)
|
|
||||||
: status.invalidReason
|
|
||||||
return cleaned ? `Token 已失效: ${cleaned}` : 'Token 已失效'
|
|
||||||
}
|
}
|
||||||
if (status.isExpired) {
|
return resolveOAuthStatusTitle(key, countdownTick.value)
|
||||||
return 'Token 已过期,请重新授权'
|
|
||||||
}
|
|
||||||
return `Token 剩余有效期: ${status.text}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 健康度颜色
|
// 健康度颜色
|
||||||
|
|||||||
@@ -1374,6 +1374,13 @@ function generateMockKeysForProvider(providerId: string, count: number = 2) {
|
|||||||
oauth_expires_at: markInvalid ? null : nowSec + 6 * 3600,
|
oauth_expires_at: markInvalid ? null : nowSec + 6 * 3600,
|
||||||
oauth_invalid_at: markInvalid ? nowSec - 3600 : null,
|
oauth_invalid_at: markInvalid ? nowSec - 3600 : null,
|
||||||
oauth_invalid_reason: markInvalid ? '[ACCOUNT_BLOCK] Demo verification required' : null,
|
oauth_invalid_reason: markInvalid ? '[ACCOUNT_BLOCK] Demo verification required' : null,
|
||||||
|
status_snapshot: {
|
||||||
|
oauth: { code: 'valid', label: '有效', reason: null, expires_at: nowSec + 6 * 3600, invalid_at: null, requires_reauth: false, expiring_soon: false },
|
||||||
|
account: markInvalid
|
||||||
|
? { code: 'account_verification', label: '需要验证', reason: 'Demo verification required', blocked: true, source: 'oauth_invalid', recoverable: false }
|
||||||
|
: { code: 'ok', label: null, reason: null, blocked: false, source: null, recoverable: false },
|
||||||
|
quota: { code: 'unknown', label: null, reason: null, exhausted: false, usage_ratio: null, updated_at: null, reset_seconds: null, plan_type: null }
|
||||||
|
},
|
||||||
oauth_plan_type: 'pro',
|
oauth_plan_type: 'pro',
|
||||||
oauth_account_id: `acct-${providerId}`
|
oauth_account_id: `acct-${providerId}`
|
||||||
} : { auth_type: 'api_key' }
|
} : { auth_type: 'api_key' }
|
||||||
|
|||||||
21
frontend/src/utils/__tests__/accountBlock.spec.ts
Normal file
21
frontend/src/utils/__tests__/accountBlock.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { cleanAccountBlockReason, isRefreshFailedReason } from '@/utils/accountBlock'
|
||||||
|
|
||||||
|
describe('accountBlock helpers', () => {
|
||||||
|
it('detects refresh failure markers even when account block is also present', () => {
|
||||||
|
expect(
|
||||||
|
isRefreshFailedReason(
|
||||||
|
'[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n[REFRESH_FAILED] Token 续期失败',
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps account block reason clean when refresh failure is appended', () => {
|
||||||
|
expect(
|
||||||
|
cleanAccountBlockReason(
|
||||||
|
'[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n[REFRESH_FAILED] Token 续期失败',
|
||||||
|
),
|
||||||
|
).toBe('工作区已停用 (deactivated_workspace)')
|
||||||
|
})
|
||||||
|
})
|
||||||
33
frontend/src/utils/__tests__/errorParser.spec.ts
Normal file
33
frontend/src/utils/__tests__/errorParser.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
|
||||||
|
describe('errorParser', () => {
|
||||||
|
it('normalizes reused refresh token errors from legacy string details', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
detail: "token refresh 失败: {'message': 'Your refresh token has already been used to generate a new access token. Please try signing in again.', 'type': 'invalid_request_error', 'param': None, 'code': 'refresh_token_reused'}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parseApiError(error, 'Token 刷新失败')).toBe(
|
||||||
|
'Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps normalized Chinese refresh failures intact', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
detail: 'Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parseApiError(error, 'Token 刷新失败')).toBe(
|
||||||
|
'Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
20
frontend/src/utils/__tests__/oauthCountdown.spec.ts
Normal file
20
frontend/src/utils/__tests__/oauthCountdown.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { getOAuthExpiresCountdown } from '@/composables/useCountdownTimer'
|
||||||
|
|
||||||
|
describe('getOAuthExpiresCountdown', () => {
|
||||||
|
it('treats invalid reason as invalid even without invalid timestamp', () => {
|
||||||
|
expect(
|
||||||
|
getOAuthExpiresCountdown(
|
||||||
|
Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
'[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
text: '已失效',
|
||||||
|
isInvalid: true,
|
||||||
|
invalidReason: '[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
85
frontend/src/utils/__tests__/oauthRefreshFeedback.spec.ts
Normal file
85
frontend/src/utils/__tests__/oauthRefreshFeedback.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getOAuthRefreshFeedback,
|
||||||
|
resolveOAuthAccountBlockDisplay,
|
||||||
|
} from '@/utils/oauthRefreshFeedback'
|
||||||
|
|
||||||
|
describe('oauthRefreshFeedback', () => {
|
||||||
|
it('prefers explicit blocked account status from pool data', () => {
|
||||||
|
expect(
|
||||||
|
resolveOAuthAccountBlockDisplay({
|
||||||
|
status_snapshot: {
|
||||||
|
oauth: { code: 'valid' },
|
||||||
|
account: {
|
||||||
|
code: 'account_disabled',
|
||||||
|
label: '账号停用',
|
||||||
|
reason: 'account has been deactivated',
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
quota: { code: 'unknown', exhausted: false },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
label: '账号停用',
|
||||||
|
reason: 'account has been deactivated',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies account-level OAuth invalid reasons', () => {
|
||||||
|
expect(
|
||||||
|
resolveOAuthAccountBlockDisplay({
|
||||||
|
oauth_invalid_reason: '[ACCOUNT_BLOCK] account has been deactivated',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
label: '账号停用',
|
||||||
|
reason: 'account has been deactivated',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores recoverable refresh failures', () => {
|
||||||
|
expect(
|
||||||
|
resolveOAuthAccountBlockDisplay({
|
||||||
|
oauth_invalid_reason: '[REFRESH_FAILED] Token 续期失败',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
label: null,
|
||||||
|
reason: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports blocked result after successful recheck', () => {
|
||||||
|
expect(
|
||||||
|
getOAuthRefreshFeedback({
|
||||||
|
accountStateRecheckAttempted: true,
|
||||||
|
snapshot: {
|
||||||
|
status_snapshot: {
|
||||||
|
oauth: { code: 'valid' },
|
||||||
|
account: {
|
||||||
|
code: 'account_disabled',
|
||||||
|
label: '账号停用',
|
||||||
|
reason: 'account has been deactivated',
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
quota: { code: 'unknown', exhausted: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
tone: 'warning',
|
||||||
|
message: 'Token 刷新成功,已重新检查额度/账号状态;当前状态仍是账号停用',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports plain success when no blocked state remains', () => {
|
||||||
|
expect(
|
||||||
|
getOAuthRefreshFeedback({
|
||||||
|
accountStateRecheckAttempted: true,
|
||||||
|
accountStateRecheckError: null,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
tone: 'success',
|
||||||
|
message: 'Token 刷新成功,已重新检查额度/账号状态',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
107
frontend/src/utils/__tests__/providerKeyStatus.spec.ts
Normal file
107
frontend/src/utils/__tests__/providerKeyStatus.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAccountStatusDisplay,
|
||||||
|
getOAuthStatusDisplay,
|
||||||
|
getOAuthStatusTitle,
|
||||||
|
} from '@/utils/providerKeyStatus'
|
||||||
|
|
||||||
|
describe('providerKeyStatus', () => {
|
||||||
|
it('uses status_snapshot account state as the primary source', () => {
|
||||||
|
expect(
|
||||||
|
getAccountStatusDisplay({
|
||||||
|
status_snapshot: {
|
||||||
|
oauth: { code: 'valid' },
|
||||||
|
account: {
|
||||||
|
code: 'workspace_deactivated',
|
||||||
|
label: '工作区停用',
|
||||||
|
reason: 'deactivated_workspace',
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
quota: { code: 'ok', exhausted: false },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
code: 'workspace_deactivated',
|
||||||
|
label: '工作区停用',
|
||||||
|
reason: 'deactivated_workspace',
|
||||||
|
blocked: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows oauth invalid when refresh failure exists beside account block', () => {
|
||||||
|
const status = getOAuthStatusDisplay(
|
||||||
|
{
|
||||||
|
auth_type: 'oauth',
|
||||||
|
oauth_expires_at: 2_000_000_000,
|
||||||
|
status_snapshot: {
|
||||||
|
oauth: {
|
||||||
|
code: 'invalid',
|
||||||
|
reason: 'Token 续期失败 (400): refresh_token_reused',
|
||||||
|
expires_at: 2_000_000_000,
|
||||||
|
requires_reauth: true,
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
code: 'workspace_deactivated',
|
||||||
|
label: '工作区停用',
|
||||||
|
reason: 'deactivated_workspace',
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
quota: { code: 'ok', exhausted: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(status).toEqual({
|
||||||
|
text: '已失效',
|
||||||
|
isExpired: false,
|
||||||
|
isExpiringSoon: false,
|
||||||
|
isInvalid: true,
|
||||||
|
invalidReason: 'Token 续期失败 (400): refresh_token_reused',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to countdown for account block without oauth invalidation', () => {
|
||||||
|
const status = getOAuthStatusDisplay(
|
||||||
|
{
|
||||||
|
auth_type: 'oauth',
|
||||||
|
oauth_expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
|
||||||
|
status_snapshot: {
|
||||||
|
oauth: {
|
||||||
|
code: 'valid',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
code: 'account_disabled',
|
||||||
|
label: '账号停用',
|
||||||
|
reason: 'account has been deactivated',
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
quota: { code: 'ok', exhausted: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(status?.isInvalid).toBe(false)
|
||||||
|
expect(status?.isExpired).toBe(false)
|
||||||
|
expect(getOAuthStatusTitle({
|
||||||
|
auth_type: 'oauth',
|
||||||
|
oauth_expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
|
||||||
|
status_snapshot: {
|
||||||
|
oauth: {
|
||||||
|
code: 'valid',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
code: 'account_disabled',
|
||||||
|
label: '账号停用',
|
||||||
|
reason: 'account has been deactivated',
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
quota: { code: 'ok', exhausted: false },
|
||||||
|
},
|
||||||
|
}, 0)).toContain('Token 剩余有效期:')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -72,12 +72,15 @@ export function classifyAccountBlockLabel(reason: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cleanAccountBlockReason(reason: string): string {
|
export function cleanAccountBlockReason(reason: string): string {
|
||||||
return reason.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '').trim()
|
return reason
|
||||||
|
.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '')
|
||||||
|
.replace(/\s*\[REFRESH_FAILED\][\s\S]*$/i, '')
|
||||||
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRefreshFailedReason(reason: string | null | undefined): boolean {
|
export function isRefreshFailedReason(reason: string | null | undefined): boolean {
|
||||||
if (!reason) return false
|
if (!reason) return false
|
||||||
return reason.trim().startsWith('[REFRESH_FAILED]')
|
return reason.includes('[REFRESH_FAILED]')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isOAuthExpiredReason(reason: string | null | undefined): boolean {
|
export function isOAuthExpiredReason(reason: string | null | undefined): boolean {
|
||||||
|
|||||||
@@ -133,6 +133,30 @@ function formatValidationError(error: ValidationError): string {
|
|||||||
return `${fieldName}: ${error.msg}`
|
return `${fieldName}: ${error.msg}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeKnownApiErrorMessage(message: string): string {
|
||||||
|
const text = message.trim()
|
||||||
|
if (!text) return text
|
||||||
|
|
||||||
|
const lowered = text.toLowerCase()
|
||||||
|
if (
|
||||||
|
lowered.includes('refresh_token_reused')
|
||||||
|
|| lowered.includes('already been used to generate a new access token')
|
||||||
|
) {
|
||||||
|
return 'Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lowered.includes('token refresh 失败:')
|
||||||
|
|| lowered.includes('token refresh failed:')
|
||||||
|
) {
|
||||||
|
return text
|
||||||
|
.replace(/^token refresh 失败:\s*/i, 'Token 刷新失败:')
|
||||||
|
.replace(/^token refresh failed:\s*/i, 'Token 刷新失败:')
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 API 错误响应
|
* 解析 API 错误响应
|
||||||
* @param err 错误对象
|
* @param err 错误对象
|
||||||
@@ -145,7 +169,7 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
|
|||||||
// 处理网络错误
|
// 处理网络错误
|
||||||
if (!isApiError(err) || !err.response) {
|
if (!isApiError(err) || !err.response) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return err.message || defaultMessage
|
return normalizeKnownApiErrorMessage(err.message || defaultMessage)
|
||||||
}
|
}
|
||||||
return '无法连接到服务器,请检查网络连接'
|
return '无法连接到服务器,请检查网络连接'
|
||||||
}
|
}
|
||||||
@@ -154,14 +178,14 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
|
|||||||
|
|
||||||
// 1. 处理 {error: {type, message}} 格式(ProxyException 返回格式)
|
// 1. 处理 {error: {type, message}} 格式(ProxyException 返回格式)
|
||||||
if (data?.error?.message) {
|
if (data?.error?.message) {
|
||||||
return data.error.message
|
return normalizeKnownApiErrorMessage(data.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = data?.detail
|
const detail = data?.detail
|
||||||
|
|
||||||
// 如果没有 detail 字段
|
// 如果没有 detail 字段
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
return data?.message || err.message || defaultMessage
|
return normalizeKnownApiErrorMessage(data?.message || err.message || defaultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 处理 Pydantic 验证错误(数组格式)
|
// 1. 处理 Pydantic 验证错误(数组格式)
|
||||||
@@ -174,14 +198,14 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
|
|||||||
|
|
||||||
// 2. 处理字符串错误
|
// 2. 处理字符串错误
|
||||||
if (typeof detail === 'string') {
|
if (typeof detail === 'string') {
|
||||||
return detail
|
return normalizeKnownApiErrorMessage(detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 处理对象错误
|
// 3. 处理对象错误
|
||||||
if (typeof detail === 'object') {
|
if (typeof detail === 'object') {
|
||||||
// 可能是自定义错误对象
|
// 可能是自定义错误对象
|
||||||
if ((detail as Record<string, unknown>).message) {
|
if ((detail as Record<string, unknown>).message) {
|
||||||
return String((detail as Record<string, unknown>).message)
|
return normalizeKnownApiErrorMessage(String((detail as Record<string, unknown>).message))
|
||||||
}
|
}
|
||||||
// 尝试 JSON 序列化
|
// 尝试 JSON 序列化
|
||||||
try {
|
try {
|
||||||
|
|||||||
74
frontend/src/utils/oauthRefreshFeedback.ts
Normal file
74
frontend/src/utils/oauthRefreshFeedback.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
getAccountStatusDisplay,
|
||||||
|
type ProviderKeyStatusCarrier,
|
||||||
|
} from './providerKeyStatus'
|
||||||
|
|
||||||
|
export interface OAuthAccountBlockDisplay {
|
||||||
|
label: string | null
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthRefreshFeedbackInput {
|
||||||
|
accountStateRecheckAttempted?: boolean | null
|
||||||
|
accountStateRecheckError?: string | null
|
||||||
|
snapshot?: ProviderKeyStatusCarrier | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const text = value.trim()
|
||||||
|
return text || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOAuthAccountBlockDisplay(
|
||||||
|
snapshot: ProviderKeyStatusCarrier,
|
||||||
|
): OAuthAccountBlockDisplay {
|
||||||
|
const account = getAccountStatusDisplay(snapshot)
|
||||||
|
if (!account.blocked || !account.label) {
|
||||||
|
return { label: null, reason: null }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: account.label,
|
||||||
|
reason: account.reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthRefreshFeedback(
|
||||||
|
input: OAuthRefreshFeedbackInput,
|
||||||
|
): { tone: 'success' | 'warning'; message: string } {
|
||||||
|
const blockedLabel = normalizeText(
|
||||||
|
input.snapshot ? getAccountStatusDisplay(input.snapshot).label : null,
|
||||||
|
)
|
||||||
|
const recheckError = normalizeText(input.accountStateRecheckError)
|
||||||
|
|
||||||
|
if (input.accountStateRecheckAttempted) {
|
||||||
|
if (recheckError) {
|
||||||
|
return {
|
||||||
|
tone: 'warning',
|
||||||
|
message: 'Token 刷新成功,但额度/账号状态复检失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blockedLabel) {
|
||||||
|
return {
|
||||||
|
tone: 'warning',
|
||||||
|
message: `Token 刷新成功,已重新检查额度/账号状态;当前状态仍是${blockedLabel}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tone: 'success',
|
||||||
|
message: 'Token 刷新成功,已重新检查额度/账号状态',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockedLabel) {
|
||||||
|
return {
|
||||||
|
tone: 'warning',
|
||||||
|
message: `Token 刷新成功,但当前状态仍是${blockedLabel}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tone: 'success',
|
||||||
|
message: 'Token 刷新成功',
|
||||||
|
}
|
||||||
|
}
|
||||||
165
frontend/src/utils/providerKeyStatus.ts
Normal file
165
frontend/src/utils/providerKeyStatus.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import type { ProviderKeyStatusSnapshot } from '@/api/endpoints/types/statusSnapshot'
|
||||||
|
import { getOAuthExpiresCountdown, type OAuthStatusInfo } from '@/composables/useCountdownTimer'
|
||||||
|
import {
|
||||||
|
classifyAccountBlockLabel,
|
||||||
|
cleanAccountBlockReason,
|
||||||
|
isAccountLevelBlockReason,
|
||||||
|
isRefreshFailedReason,
|
||||||
|
} from './accountBlock'
|
||||||
|
|
||||||
|
export interface ProviderKeyStatusCarrier {
|
||||||
|
auth_type?: string | null
|
||||||
|
oauth_expires_at?: number | null
|
||||||
|
oauth_invalid_at?: number | null // compatibility only
|
||||||
|
oauth_invalid_reason?: string | null // compatibility only
|
||||||
|
account_status_label?: string | null // compatibility only
|
||||||
|
account_status_reason?: string | null // compatibility only
|
||||||
|
account_status_blocked?: boolean | null // compatibility only
|
||||||
|
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountStatusDisplay {
|
||||||
|
code: string
|
||||||
|
label: string | null
|
||||||
|
reason: string | null
|
||||||
|
blocked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const text = value.trim()
|
||||||
|
return text || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegacyAccountStatus(input: ProviderKeyStatusCarrier): AccountStatusDisplay {
|
||||||
|
const explicitLabel = normalizeText(input.account_status_label)
|
||||||
|
if (input.account_status_blocked && explicitLabel) {
|
||||||
|
return {
|
||||||
|
code: 'legacy',
|
||||||
|
label: explicitLabel,
|
||||||
|
reason: normalizeText(input.account_status_reason),
|
||||||
|
blocked: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidReason = normalizeText(input.oauth_invalid_reason)
|
||||||
|
if (!invalidReason || !isAccountLevelBlockReason(invalidReason)) {
|
||||||
|
return { code: 'ok', label: null, reason: null, blocked: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = cleanAccountBlockReason(invalidReason) || invalidReason
|
||||||
|
return {
|
||||||
|
code: 'legacy',
|
||||||
|
label: classifyAccountBlockLabel(cleaned || invalidReason),
|
||||||
|
reason: normalizeText(cleaned),
|
||||||
|
blocked: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccountStatusDisplay(input: ProviderKeyStatusCarrier): AccountStatusDisplay {
|
||||||
|
const snapshot = input.status_snapshot?.account
|
||||||
|
if (snapshot) {
|
||||||
|
return {
|
||||||
|
code: normalizeText(snapshot.code) || 'ok',
|
||||||
|
label: normalizeText(snapshot.label),
|
||||||
|
reason: normalizeText(snapshot.reason),
|
||||||
|
blocked: Boolean(snapshot.blocked),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildLegacyAccountStatus(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshotOAuthState(
|
||||||
|
input: ProviderKeyStatusCarrier,
|
||||||
|
tick: number,
|
||||||
|
): OAuthStatusInfo | null {
|
||||||
|
const oauth = input.status_snapshot?.oauth
|
||||||
|
if (!oauth) return null
|
||||||
|
|
||||||
|
const code = normalizeText(oauth.code) || 'none'
|
||||||
|
const expiresAt = oauth.expires_at ?? input.oauth_expires_at ?? null
|
||||||
|
const reason = normalizeText(oauth.reason)
|
||||||
|
|
||||||
|
if (code === 'invalid') {
|
||||||
|
return {
|
||||||
|
text: '已失效',
|
||||||
|
isExpired: false,
|
||||||
|
isExpiringSoon: false,
|
||||||
|
isInvalid: true,
|
||||||
|
invalidReason: reason || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'expired') {
|
||||||
|
return { text: '已过期', isExpired: true, isExpiringSoon: false, isInvalid: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'check_failed') {
|
||||||
|
if (expiresAt == null) return null
|
||||||
|
return getOAuthExpiresCountdown(expiresAt, tick, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt == null) return null
|
||||||
|
return getOAuthExpiresCountdown(expiresAt, tick, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyOAuthState(
|
||||||
|
input: ProviderKeyStatusCarrier,
|
||||||
|
tick: number,
|
||||||
|
): OAuthStatusInfo | null {
|
||||||
|
if (normalizeText(input.auth_type) !== 'oauth') return null
|
||||||
|
if (!input.oauth_expires_at && !input.oauth_invalid_at && !input.oauth_invalid_reason) return null
|
||||||
|
|
||||||
|
const rawReason = normalizeText(input.oauth_invalid_reason)
|
||||||
|
if (rawReason && isAccountLevelBlockReason(rawReason) && !isRefreshFailedReason(rawReason)) {
|
||||||
|
if (!input.oauth_expires_at) return null
|
||||||
|
return getOAuthExpiresCountdown(input.oauth_expires_at, tick, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOAuthExpiresCountdown(
|
||||||
|
input.oauth_expires_at,
|
||||||
|
tick,
|
||||||
|
input.oauth_invalid_at,
|
||||||
|
input.oauth_invalid_reason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthStatusDisplay(
|
||||||
|
input: ProviderKeyStatusCarrier,
|
||||||
|
tick: number,
|
||||||
|
): OAuthStatusInfo | null {
|
||||||
|
return getSnapshotOAuthState(input, tick) ?? getLegacyOAuthState(input, tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthStatusTitle(
|
||||||
|
input: ProviderKeyStatusCarrier,
|
||||||
|
tick: number,
|
||||||
|
): string {
|
||||||
|
const status = getOAuthStatusDisplay(input, tick)
|
||||||
|
if (!status) return ''
|
||||||
|
if (status.isInvalid) {
|
||||||
|
const reason = normalizeText(status.invalidReason)
|
||||||
|
return reason ? `Token 已失效: ${reason}` : 'Token 已失效'
|
||||||
|
}
|
||||||
|
if (status.isExpired) {
|
||||||
|
return 'Token 已过期,请重新授权'
|
||||||
|
}
|
||||||
|
return `Token 剩余有效期: ${status.text}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthRefreshButtonTitle(
|
||||||
|
input: ProviderKeyStatusCarrier,
|
||||||
|
tick: number,
|
||||||
|
): string {
|
||||||
|
const status = getOAuthStatusDisplay(input, tick)
|
||||||
|
if (status?.isInvalid || status?.isExpired) {
|
||||||
|
return '重新授权'
|
||||||
|
}
|
||||||
|
return '刷新 Token'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccountStatusTitle(input: ProviderKeyStatusCarrier): string {
|
||||||
|
const account = getAccountStatusDisplay(input)
|
||||||
|
if (!account.blocked || !account.label) return ''
|
||||||
|
return account.reason ? `${account.label}: ${account.reason}` : account.label
|
||||||
|
}
|
||||||
@@ -463,7 +463,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-4 w-4 shrink-0"
|
class="h-4 w-4 shrink-0"
|
||||||
:disabled="refreshingOAuthKeyId === key.key_id"
|
:disabled="refreshingOAuthKeyId === key.key_id"
|
||||||
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
|
:title="getOAuthRefreshButtonTitle(key)"
|
||||||
@click.stop="handleRefreshOAuth(key)"
|
@click.stop="handleRefreshOAuth(key)"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
@@ -472,16 +472,16 @@
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<span
|
<span
|
||||||
v-if="getKeyOAuthExpires(key)"
|
v-if="getVisibleOAuthState(key)"
|
||||||
class="text-[10px]"
|
class="text-[10px]"
|
||||||
:class="{
|
:class="{
|
||||||
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
'text-destructive': getVisibleOAuthState(key)?.isInvalid || getVisibleOAuthState(key)?.isExpired,
|
||||||
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
'text-warning': getVisibleOAuthState(key)?.isExpiringSoon && !getVisibleOAuthState(key)?.isExpired && !getVisibleOAuthState(key)?.isInvalid,
|
||||||
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
'text-muted-foreground': !getVisibleOAuthState(key)?.isExpired && !getVisibleOAuthState(key)?.isExpiringSoon && !getVisibleOAuthState(key)?.isInvalid
|
||||||
}"
|
}"
|
||||||
:title="getOAuthStatusTitle(key)"
|
:title="getOAuthStatusTitle(key)"
|
||||||
>
|
>
|
||||||
{{ getKeyOAuthExpires(key)?.text }}
|
{{ getVisibleOAuthState(key)?.text }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -773,7 +773,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-4 w-4 shrink-0"
|
class="h-4 w-4 shrink-0"
|
||||||
:disabled="refreshingOAuthKeyId === key.key_id"
|
:disabled="refreshingOAuthKeyId === key.key_id"
|
||||||
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
|
:title="getOAuthRefreshButtonTitle(key)"
|
||||||
@click.stop="handleRefreshOAuth(key)"
|
@click.stop="handleRefreshOAuth(key)"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
@@ -782,16 +782,16 @@
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<span
|
<span
|
||||||
v-if="getKeyOAuthExpires(key)"
|
v-if="getVisibleOAuthState(key)"
|
||||||
class="text-[10px]"
|
class="text-[10px]"
|
||||||
:class="{
|
:class="{
|
||||||
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
'text-destructive': getVisibleOAuthState(key)?.isInvalid || getVisibleOAuthState(key)?.isExpired,
|
||||||
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
'text-warning': getVisibleOAuthState(key)?.isExpiringSoon && !getVisibleOAuthState(key)?.isExpired && !getVisibleOAuthState(key)?.isInvalid,
|
||||||
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
'text-muted-foreground': !getVisibleOAuthState(key)?.isExpired && !getVisibleOAuthState(key)?.isExpiringSoon && !getVisibleOAuthState(key)?.isInvalid
|
||||||
}"
|
}"
|
||||||
:title="getOAuthStatusTitle(key)"
|
:title="getOAuthStatusTitle(key)"
|
||||||
>
|
>
|
||||||
{{ getKeyOAuthExpires(key)?.text }}
|
{{ getVisibleOAuthState(key)?.text }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -1153,7 +1153,7 @@ import {
|
|||||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useCountdownTimer, getOAuthExpiresCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
@@ -1194,8 +1194,15 @@ import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
|
|||||||
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
|
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
|
||||||
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
||||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||||
import { isAccountLevelBlockReason, classifyAccountBlockLabel, cleanAccountBlockReason } from '@/utils/accountBlock'
|
|
||||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||||
|
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||||||
|
import {
|
||||||
|
getAccountStatusDisplay,
|
||||||
|
getAccountStatusTitle,
|
||||||
|
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
|
||||||
|
getOAuthStatusDisplay,
|
||||||
|
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||||||
|
} from '@/utils/providerKeyStatus'
|
||||||
|
|
||||||
const { success, error: showError, warning: showWarning } = useToast()
|
const { success, error: showError, warning: showWarning } = useToast()
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
@@ -1747,6 +1754,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
|
|||||||
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,
|
||||||
|
status_snapshot: key.status_snapshot ?? null,
|
||||||
proxy: key.proxy ?? null,
|
proxy: key.proxy ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2007,16 +2015,18 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
|
|||||||
if (target) {
|
if (target) {
|
||||||
target.oauth_expires_at = result.expires_at ?? null
|
target.oauth_expires_at = result.expires_at ?? null
|
||||||
}
|
}
|
||||||
if (result.account_state_recheck_attempted) {
|
|
||||||
if (result.account_state_recheck_error) {
|
|
||||||
showWarning('Token 刷新成功,但账号状态复检失败')
|
|
||||||
} else {
|
|
||||||
success('Token 刷新成功,已复检账号状态')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
success('Token 刷新成功')
|
|
||||||
}
|
|
||||||
await loadKeys()
|
await loadKeys()
|
||||||
|
const refreshedKey = keyPage.value.keys.find(k => k.key_id === key.key_id) ?? null
|
||||||
|
const feedback = getOAuthRefreshFeedback({
|
||||||
|
accountStateRecheckAttempted: result.account_state_recheck_attempted,
|
||||||
|
accountStateRecheckError: result.account_state_recheck_error,
|
||||||
|
snapshot: refreshedKey,
|
||||||
|
})
|
||||||
|
if (feedback.tone === 'warning') {
|
||||||
|
showWarning(feedback.message)
|
||||||
|
} else {
|
||||||
|
success(feedback.message)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(parseApiError(err, 'Token 刷新失败'))
|
showError(parseApiError(err, 'Token 刷新失败'))
|
||||||
await loadKeys()
|
await loadKeys()
|
||||||
@@ -2350,35 +2360,16 @@ function getOAuthPlanTypeClass(planType: string): string {
|
|||||||
return classes[planType.toLowerCase()] || ''
|
return classes[planType.toLowerCase()] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeyOAuthExpires(key: PoolKeyDetail) {
|
function getVisibleOAuthState(key: PoolKeyDetail) {
|
||||||
if (key.auth_type !== 'oauth') return null
|
return getOAuthStatusDisplay(key, countdownTick.value)
|
||||||
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
|
}
|
||||||
return getOAuthExpiresCountdown(
|
|
||||||
key.oauth_expires_at,
|
function getOAuthRefreshButtonTitle(key: PoolKeyDetail): string {
|
||||||
countdownTick.value,
|
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
|
||||||
key.oauth_invalid_at,
|
|
||||||
key.oauth_invalid_reason
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOAuthStatusTitle(key: PoolKeyDetail): string {
|
function getOAuthStatusTitle(key: PoolKeyDetail): string {
|
||||||
const status = getKeyOAuthExpires(key)
|
return resolveOAuthStatusTitle(key, countdownTick.value)
|
||||||
if (!status) return ''
|
|
||||||
if (status.isInvalid) {
|
|
||||||
const accountLabel = String(key.account_status_label || '').trim()
|
|
||||||
const accountReason = String(key.account_status_reason || '').trim()
|
|
||||||
if (accountLabel) {
|
|
||||||
return accountReason ? `${accountLabel}: ${accountReason}` : accountLabel
|
|
||||||
}
|
|
||||||
const cleaned = status.invalidReason && isAccountLevelBlockReason(status.invalidReason)
|
|
||||||
? cleanAccountBlockReason(status.invalidReason)
|
|
||||||
: status.invalidReason
|
|
||||||
return cleaned ? `Token 已失效: ${cleaned}` : 'Token 已失效'
|
|
||||||
}
|
|
||||||
if (status.isExpired) {
|
|
||||||
return 'Token 已过期,请重新授权'
|
|
||||||
}
|
|
||||||
return `Token 剩余有效期: ${status.text}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const _accountAlertCache = new WeakMap<PoolKeyDetail, string | null>()
|
const _accountAlertCache = new WeakMap<PoolKeyDetail, string | null>()
|
||||||
@@ -2387,20 +2378,11 @@ function getAccountAlertLabel(key: PoolKeyDetail): string | null {
|
|||||||
const cached = _accountAlertCache.get(key)
|
const cached = _accountAlertCache.get(key)
|
||||||
if (cached !== undefined) return cached
|
if (cached !== undefined) return cached
|
||||||
|
|
||||||
let result: string | null = null
|
let result: string | null = getAccountStatusDisplay(key).label
|
||||||
const explicitLabel = String(key.account_status_label || '').trim()
|
|
||||||
if (key.account_status_blocked && explicitLabel) {
|
|
||||||
result = explicitLabel
|
|
||||||
}
|
|
||||||
const quotaText = String(key.account_quota || '').trim()
|
const quotaText = String(key.account_quota || '').trim()
|
||||||
// 后端 _build_account_quota 返回的确切文本: "账号已封禁" / "访问受限"
|
// 后端 _build_account_quota 返回的确切文本: "账号已封禁" / "访问受限"
|
||||||
if (!result && (quotaText === '账号已封禁' || quotaText === '封禁')) result = '账号封禁'
|
if (!result && (quotaText === '账号已封禁' || quotaText === '封禁')) result = '账号封禁'
|
||||||
else if (!result && quotaText === '访问受限') result = '访问受限'
|
else if (!result && quotaText === '访问受限') result = '访问受限'
|
||||||
else if (!result && isAccountLevelBlockReason(key.oauth_invalid_reason)) {
|
|
||||||
const reason = String(key.oauth_invalid_reason || '').trim()
|
|
||||||
const cleaned = cleanAccountBlockReason(reason)
|
|
||||||
result = classifyAccountBlockLabel(cleaned || reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
_accountAlertCache.set(key, result)
|
_accountAlertCache.set(key, result)
|
||||||
return result
|
return result
|
||||||
@@ -2410,17 +2392,8 @@ function getAccountAlertTitle(key: PoolKeyDetail): string {
|
|||||||
const label = getAccountAlertLabel(key)
|
const label = getAccountAlertLabel(key)
|
||||||
if (!label) return ''
|
if (!label) return ''
|
||||||
|
|
||||||
const explicitReason = String(key.account_status_reason || '').trim()
|
const accountTitle = getAccountStatusTitle(key)
|
||||||
if (explicitReason) return `${label}: ${explicitReason}`
|
if (accountTitle) return accountTitle
|
||||||
|
|
||||||
const reason = String(key.oauth_invalid_reason || '').trim()
|
|
||||||
if (reason) {
|
|
||||||
if (isAccountLevelBlockReason(reason)) {
|
|
||||||
const cleaned = cleanAccountBlockReason(reason)
|
|
||||||
return cleaned ? `${label}: ${cleaned}` : label
|
|
||||||
}
|
|
||||||
return `${label}: ${reason}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotaText = String(key.account_quota || '').trim()
|
const quotaText = String(key.account_quota || '').trim()
|
||||||
if (quotaText) return `${label}: ${quotaText}`
|
if (quotaText) return `${label}: ${quotaText}`
|
||||||
|
|||||||
101
scripts/backfill_provider_key_status_snapshot.py
Normal file
101
scripts/backfill_provider_key_status_snapshot.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import joinedload, load_only
|
||||||
|
|
||||||
|
from src.database import create_session
|
||||||
|
from src.models.database import Provider, ProviderAPIKey
|
||||||
|
from src.services.provider_keys.status_snapshot_store import sync_provider_key_status_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Backfill provider_api_keys.status_snapshot from existing OAuth/account/quota fields."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size",
|
||||||
|
type=int,
|
||||||
|
default=200,
|
||||||
|
help="Number of provider keys to process per commit.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-existing",
|
||||||
|
action="store_true",
|
||||||
|
help="Recompute rows that already have status_snapshot instead of only filling missing rows.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def build_batch_stmt(
|
||||||
|
*,
|
||||||
|
batch_size: int,
|
||||||
|
last_id: str | None,
|
||||||
|
include_existing: bool,
|
||||||
|
) -> Any:
|
||||||
|
stmt = (
|
||||||
|
select(ProviderAPIKey)
|
||||||
|
.options(joinedload(ProviderAPIKey.provider).load_only(Provider.provider_type))
|
||||||
|
.order_by(ProviderAPIKey.id)
|
||||||
|
.limit(batch_size)
|
||||||
|
)
|
||||||
|
if last_id is not None:
|
||||||
|
stmt = stmt.where(ProviderAPIKey.id > last_id)
|
||||||
|
if not include_existing:
|
||||||
|
stmt = stmt.where(ProviderAPIKey.status_snapshot.is_(None))
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
batch_size = max(1, int(args.batch_size or 200))
|
||||||
|
include_existing = bool(args.include_existing)
|
||||||
|
|
||||||
|
db = create_session()
|
||||||
|
processed = 0
|
||||||
|
updated = 0
|
||||||
|
last_id: str | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
batch = (
|
||||||
|
db.execute(
|
||||||
|
build_batch_stmt(
|
||||||
|
batch_size=batch_size,
|
||||||
|
last_id=last_id,
|
||||||
|
include_existing=include_existing,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
|
||||||
|
for key in batch:
|
||||||
|
last_id = str(key.id)
|
||||||
|
processed += 1
|
||||||
|
previous = getattr(key, "status_snapshot", None)
|
||||||
|
current = sync_provider_key_status_snapshot(key)
|
||||||
|
if current != previous:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(
|
||||||
|
f"processed={processed} updated={updated} last_id={last_id}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"backfill finished: processed={processed} updated={updated} include_existing={include_existing}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -9,11 +9,10 @@ Provides endpoints for managing account pools at scale:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -35,7 +34,9 @@ from src.models.database import Provider, ProviderAPIKey
|
|||||||
from src.services.billing.precision import to_money_decimal
|
from src.services.billing.precision import to_money_decimal
|
||||||
from src.services.provider.fingerprint import generate_fingerprint
|
from src.services.provider.fingerprint import generate_fingerprint
|
||||||
from src.services.provider.pool import redis_ops as pool_redis
|
from src.services.provider.pool import redis_ops as pool_redis
|
||||||
from src.services.provider.pool.account_state import resolve_pool_account_state
|
from src.services.provider.pool.account_state import (
|
||||||
|
resolve_pool_account_state,
|
||||||
|
)
|
||||||
from src.services.provider.pool.config import parse_pool_config
|
from src.services.provider.pool.config import parse_pool_config
|
||||||
from src.services.provider.pool.dimensions import get_preset_dimension_metas
|
from src.services.provider.pool.dimensions import get_preset_dimension_metas
|
||||||
from src.services.provider.pool.scheduling_dimensions import (
|
from src.services.provider.pool.scheduling_dimensions import (
|
||||||
@@ -45,6 +46,18 @@ from src.services.provider.pool.scheduling_dimensions import (
|
|||||||
)
|
)
|
||||||
from src.services.provider_keys.key_side_effects import cleanup_key_references
|
from src.services.provider_keys.key_side_effects import cleanup_key_references
|
||||||
from src.services.provider_keys.quota_reader import get_quota_reader
|
from src.services.provider_keys.quota_reader import get_quota_reader
|
||||||
|
from src.services.provider_keys.status_snapshot_store import (
|
||||||
|
derive_oauth_expires_at as derive_persisted_oauth_expires_at,
|
||||||
|
)
|
||||||
|
from src.services.provider_keys.status_snapshot_store import (
|
||||||
|
extract_oauth_auth_config as extract_persisted_oauth_auth_config,
|
||||||
|
)
|
||||||
|
from src.services.provider_keys.status_snapshot_store import (
|
||||||
|
normalize_oauth_expires_at as normalize_persisted_oauth_expires_at,
|
||||||
|
)
|
||||||
|
from src.services.provider_keys.status_snapshot_store import (
|
||||||
|
resolve_provider_key_status_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
BatchActionRequest,
|
BatchActionRequest,
|
||||||
@@ -252,10 +265,6 @@ def _build_account_quota(provider_type: str, upstream_metadata: Any) -> str | No
|
|||||||
return get_quota_reader(provider_type, upstream_metadata).display_summary()
|
return get_quota_reader(provider_type, upstream_metadata).display_summary()
|
||||||
|
|
||||||
|
|
||||||
def _extract_quota_updated_at(provider_type: str, upstream_metadata: Any) -> int | None:
|
|
||||||
return get_quota_reader(provider_type, upstream_metadata).updated_at()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_oauth_plan_type(plan_type: Any, provider_type: str) -> str | None:
|
def _normalize_oauth_plan_type(plan_type: Any, provider_type: str) -> str | None:
|
||||||
if not isinstance(plan_type, str):
|
if not isinstance(plan_type, str):
|
||||||
return None
|
return None
|
||||||
@@ -273,52 +282,17 @@ def _normalize_oauth_plan_type(plan_type: Any, provider_type: str) -> str | None
|
|||||||
|
|
||||||
|
|
||||||
def _extract_oauth_auth_config(key: ProviderAPIKey) -> dict[str, Any] | None:
|
def _extract_oauth_auth_config(key: ProviderAPIKey) -> dict[str, Any] | None:
|
||||||
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
|
return extract_persisted_oauth_auth_config(key)
|
||||||
return None
|
|
||||||
|
|
||||||
auth_config_raw = getattr(key, "auth_config", None)
|
|
||||||
if not auth_config_raw:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
decrypted = crypto_service.decrypt(auth_config_raw)
|
|
||||||
parsed = json.loads(decrypted)
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
return parsed
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_oauth_expires_at(raw: Any) -> int | None:
|
def _normalize_oauth_expires_at(raw: Any) -> int | None:
|
||||||
value = _to_float(raw)
|
return normalize_persisted_oauth_expires_at(raw)
|
||||||
if value is None or value <= 0:
|
|
||||||
return None
|
|
||||||
# 兼容毫秒时间戳
|
|
||||||
if value > 1_000_000_000_000:
|
|
||||||
value /= 1000
|
|
||||||
return int(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _derive_oauth_expires_at(
|
def _derive_oauth_expires_at(
|
||||||
key: ProviderAPIKey, auth_config: dict[str, Any] | None = None
|
key: ProviderAPIKey, auth_config: dict[str, Any] | None = None
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
|
return derive_persisted_oauth_expires_at(key, auth_config=auth_config)
|
||||||
return None
|
|
||||||
|
|
||||||
cfg = auth_config if isinstance(auth_config, dict) else _extract_oauth_auth_config(key)
|
|
||||||
if cfg:
|
|
||||||
for field in ("expires_at", "expiresAt", "expiry", "exp"):
|
|
||||||
expires_at = _normalize_oauth_expires_at(cfg.get(field))
|
|
||||||
if expires_at is not None:
|
|
||||||
return expires_at
|
|
||||||
|
|
||||||
# 兼容历史字段
|
|
||||||
expires_dt = getattr(key, "expires_at", None)
|
|
||||||
if isinstance(expires_dt, datetime):
|
|
||||||
return int(expires_dt.timestamp())
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _derive_oauth_plan_type(
|
def _derive_oauth_plan_type(
|
||||||
@@ -864,7 +838,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
|
||||||
status_code = _normalize_batch_text(detail.account_status_code)
|
snapshot_oauth_code = _normalize_batch_text(getattr(detail.status_snapshot.oauth, "code", None))
|
||||||
|
if snapshot_oauth_code == "invalid":
|
||||||
|
return True
|
||||||
|
if snapshot_oauth_code == "expired":
|
||||||
|
return True
|
||||||
|
if snapshot_oauth_code == "check_failed":
|
||||||
|
return False
|
||||||
|
status_code = _normalize_batch_text(
|
||||||
|
getattr(detail.status_snapshot.account, "code", None) or detail.account_status_code
|
||||||
|
)
|
||||||
if status_code in _TOKEN_ISSUE_CODES:
|
if status_code in _TOKEN_ISSUE_CODES:
|
||||||
return True
|
return True
|
||||||
if status_code in _ACCOUNT_BANNED_CODES or status_code == "oauth_request_failed":
|
if status_code in _ACCOUNT_BANNED_CODES or status_code == "oauth_request_failed":
|
||||||
@@ -883,9 +866,16 @@ def _detail_is_oauth_invalid(detail: PoolKeyDetail) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _detail_is_banned(detail: PoolKeyDetail) -> bool:
|
def _detail_is_banned(detail: PoolKeyDetail) -> bool:
|
||||||
|
snapshot_account_code = _normalize_batch_text(
|
||||||
|
getattr(detail.status_snapshot.account, "code", None)
|
||||||
|
)
|
||||||
|
if snapshot_account_code in _ACCOUNT_BANNED_CODES:
|
||||||
|
return True
|
||||||
if _normalize_batch_text(detail.account_status_code) in _ACCOUNT_BANNED_CODES:
|
if _normalize_batch_text(detail.account_status_code) in _ACCOUNT_BANNED_CODES:
|
||||||
return True
|
return True
|
||||||
reason = _normalize_batch_text(detail.oauth_invalid_reason)
|
reason = _normalize_batch_text(
|
||||||
|
getattr(detail.status_snapshot.account, "reason", None) or detail.oauth_invalid_reason
|
||||||
|
)
|
||||||
if reason and _BANNED_REASON_PATTERN.search(reason):
|
if reason and _BANNED_REASON_PATTERN.search(reason):
|
||||||
return True
|
return True
|
||||||
for item in detail.scheduling_reasons or []:
|
for item in detail.scheduling_reasons or []:
|
||||||
@@ -916,12 +906,15 @@ def _matches_pool_key_search(
|
|||||||
detail.key_name,
|
detail.key_name,
|
||||||
detail.auth_type,
|
detail.auth_type,
|
||||||
detail.oauth_plan_type,
|
detail.oauth_plan_type,
|
||||||
detail.account_status_label,
|
getattr(detail.status_snapshot.account, "label", None) or detail.account_status_label,
|
||||||
detail.account_status_reason,
|
getattr(detail.status_snapshot.account, "reason", None) or detail.account_status_reason,
|
||||||
detail.account_quota,
|
detail.account_quota,
|
||||||
"独立代理" if _detail_has_proxy(detail) else "未配置代理",
|
"独立代理" if _detail_has_proxy(detail) else "未配置代理",
|
||||||
"已启用" if detail.is_active else "已禁用",
|
"已启用" if detail.is_active else "已禁用",
|
||||||
detail.oauth_invalid_reason,
|
getattr(detail.status_snapshot.oauth, "reason", None) or detail.oauth_invalid_reason,
|
||||||
|
getattr(detail.status_snapshot.oauth, "label", None),
|
||||||
|
getattr(detail.status_snapshot.quota, "label", None),
|
||||||
|
getattr(detail.status_snapshot.quota, "reason", None),
|
||||||
]
|
]
|
||||||
return any(keyword in _normalize_batch_text(part) for part in parts)
|
return any(keyword in _normalize_batch_text(part) for part in parts)
|
||||||
|
|
||||||
@@ -984,7 +977,9 @@ def _detail_is_schedulable(detail: PoolKeyDetail) -> bool:
|
|||||||
|
|
||||||
if not detail.is_active:
|
if not detail.is_active:
|
||||||
return False
|
return False
|
||||||
if detail.account_status_blocked:
|
if bool(
|
||||||
|
getattr(detail.status_snapshot.account, "blocked", False) or detail.account_status_blocked
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
if detail.cooldown_reason:
|
if detail.cooldown_reason:
|
||||||
return False
|
return False
|
||||||
@@ -1081,11 +1076,15 @@ async def _serialize_pool_key_details(
|
|||||||
cost_limit = pcfg.cost_limit_per_key_tokens if pcfg else None
|
cost_limit = pcfg.cost_limit_per_key_tokens if pcfg else None
|
||||||
latency_avg_raw = latency_avgs.get(kid)
|
latency_avg_raw = latency_avgs.get(kid)
|
||||||
latency_avg_ms = float(latency_avg_raw) if latency_avg_raw is not None else None
|
latency_avg_ms = float(latency_avg_raw) if latency_avg_raw is not None else None
|
||||||
account_state = resolve_pool_account_state(
|
oauth_auth_config = _extract_oauth_auth_config(k)
|
||||||
|
oauth_expires_at = _derive_oauth_expires_at(k, auth_config=oauth_auth_config)
|
||||||
|
status_snapshot = resolve_provider_key_status_snapshot(
|
||||||
|
k,
|
||||||
provider_type=provider_type,
|
provider_type=provider_type,
|
||||||
upstream_metadata=getattr(k, "upstream_metadata", None),
|
auth_config=oauth_auth_config,
|
||||||
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
oauth_expires_at=oauth_expires_at,
|
||||||
)
|
)
|
||||||
|
account_state = status_snapshot.account
|
||||||
(
|
(
|
||||||
scheduling_status,
|
scheduling_status,
|
||||||
scheduling_reason,
|
scheduling_reason,
|
||||||
@@ -1153,7 +1152,7 @@ async def _serialize_pool_key_details(
|
|||||||
key_total_tokens = int(getattr(k, "total_tokens", 0) or 0)
|
key_total_tokens = int(getattr(k, "total_tokens", 0) or 0)
|
||||||
key_total_cost_usd = _serialize_money(getattr(k, "total_cost_usd", 0.0))
|
key_total_cost_usd = _serialize_money(getattr(k, "total_cost_usd", 0.0))
|
||||||
key_last_used_at = getattr(k, "last_used_at", None)
|
key_last_used_at = getattr(k, "last_used_at", None)
|
||||||
oauth_auth_config = _extract_oauth_auth_config(k)
|
oauth_invalid_at = status_snapshot.oauth.invalid_at
|
||||||
|
|
||||||
key_details.append(
|
key_details.append(
|
||||||
PoolKeyDetail(
|
PoolKeyDetail(
|
||||||
@@ -1161,12 +1160,8 @@ async def _serialize_pool_key_details(
|
|||||||
key_name=str(getattr(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=oauth_expires_at,
|
||||||
oauth_invalid_at=(
|
oauth_invalid_at=oauth_invalid_at,
|
||||||
int(k.oauth_invalid_at.timestamp())
|
|
||||||
if getattr(k, "oauth_invalid_at", None)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
||||||
oauth_plan_type=_derive_oauth_plan_type(
|
oauth_plan_type=_derive_oauth_plan_type(
|
||||||
k, provider_type, auth_config=oauth_auth_config
|
k, provider_type, auth_config=oauth_auth_config
|
||||||
@@ -1181,10 +1176,8 @@ async def _serialize_pool_key_details(
|
|||||||
account_status_blocked=account_state.blocked,
|
account_status_blocked=account_state.blocked,
|
||||||
account_status_recoverable=bool(getattr(account_state, "recoverable", False)),
|
account_status_recoverable=bool(getattr(account_state, "recoverable", False)),
|
||||||
account_status_source=getattr(account_state, "source", None),
|
account_status_source=getattr(account_state, "source", None),
|
||||||
quota_updated_at=_extract_quota_updated_at(
|
status_snapshot=asdict(status_snapshot),
|
||||||
provider_type,
|
quota_updated_at=status_snapshot.quota.updated_at,
|
||||||
getattr(k, "upstream_metadata", None),
|
|
||||||
),
|
|
||||||
health_score=health_score,
|
health_score=health_score,
|
||||||
circuit_breaker_open=any_circuit_open,
|
circuit_breaker_open=any_circuit_open,
|
||||||
api_formats=api_formats,
|
api_formats=api_formats,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from typing import Any
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from src.models.status_snapshot import ProviderKeyStatusSnapshotResponse
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Overview
|
# Overview
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -80,20 +82,42 @@ class PoolKeyDetail(BaseModel):
|
|||||||
key_name: str
|
key_name: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
auth_type: str = "api_key"
|
auth_type: str = "api_key"
|
||||||
oauth_expires_at: int | None = None
|
oauth_expires_at: int | None = Field(
|
||||||
oauth_invalid_at: int | None = None
|
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
|
||||||
oauth_invalid_reason: str | None = None
|
)
|
||||||
|
oauth_invalid_at: int | None = Field(
|
||||||
|
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
|
||||||
|
)
|
||||||
|
oauth_invalid_reason: str | None = Field(
|
||||||
|
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
|
||||||
|
)
|
||||||
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_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)
|
||||||
account_status_code: str | None = None
|
account_status_code: str | None = Field(
|
||||||
account_status_label: str | None = None
|
default=None, description="兼容字段;优先使用 status_snapshot.account"
|
||||||
account_status_reason: str | None = None
|
)
|
||||||
account_status_blocked: bool = False
|
account_status_label: str | None = Field(
|
||||||
account_status_recoverable: bool = False
|
default=None, description="兼容字段;优先使用 status_snapshot.account"
|
||||||
account_status_source: str | None = None
|
)
|
||||||
|
account_status_reason: str | None = Field(
|
||||||
|
default=None, description="兼容字段;优先使用 status_snapshot.account"
|
||||||
|
)
|
||||||
|
account_status_blocked: bool = Field(
|
||||||
|
default=False, description="兼容字段;优先使用 status_snapshot.account"
|
||||||
|
)
|
||||||
|
account_status_recoverable: bool = Field(
|
||||||
|
default=False, description="兼容字段;优先使用 status_snapshot.account"
|
||||||
|
)
|
||||||
|
account_status_source: str | None = Field(
|
||||||
|
default=None, description="兼容字段;优先使用 status_snapshot.account"
|
||||||
|
)
|
||||||
|
status_snapshot: ProviderKeyStatusSnapshotResponse = Field(
|
||||||
|
default_factory=ProviderKeyStatusSnapshotResponse,
|
||||||
|
description="统一的账号/OAuth/额度状态快照",
|
||||||
|
)
|
||||||
quota_updated_at: int | None = None
|
quota_updated_at: int | None = None
|
||||||
# 健康度聚合字段(与 Provider Key 列表口径一致)
|
# 健康度聚合字段(与 Provider Key 列表口径一致)
|
||||||
health_score: float = 1.0
|
health_score: float = 1.0
|
||||||
|
|||||||
@@ -51,6 +51,85 @@ from src.utils.auth_utils import require_admin
|
|||||||
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
|
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_oauth_refresh_error_message(
|
||||||
|
message: str | None,
|
||||||
|
*,
|
||||||
|
status_code: int | None = None,
|
||||||
|
error_code: str | None = None,
|
||||||
|
error_type: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
text = str(message or "").strip()
|
||||||
|
lowered = text.lower()
|
||||||
|
code = str(error_code or "").strip().lower()
|
||||||
|
err_type = str(error_type or "").strip().lower()
|
||||||
|
|
||||||
|
if code == "refresh_token_reused" or (
|
||||||
|
"already been used to generate a new access token" in lowered
|
||||||
|
):
|
||||||
|
return "refresh_token 已被使用并轮换,请重新登录授权"
|
||||||
|
|
||||||
|
if code in {"invalid_grant", "invalid_refresh_token"} or (
|
||||||
|
"refresh token" in lowered
|
||||||
|
and any(keyword in lowered for keyword in ("expired", "revoked", "invalid"))
|
||||||
|
):
|
||||||
|
return "refresh_token 无效、已过期或已撤销,请重新登录授权"
|
||||||
|
|
||||||
|
if err_type == "invalid_request_error" and text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
if status_code is not None:
|
||||||
|
return f"HTTP {status_code}"
|
||||||
|
return "未知错误"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_oauth_refresh_error_reason(resp: httpx.Response) -> str:
|
||||||
|
status_code = int(resp.status_code)
|
||||||
|
message: str | None = None
|
||||||
|
error_code: str | None = None
|
||||||
|
error_type: str | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_body = resp.json()
|
||||||
|
if isinstance(error_body, dict):
|
||||||
|
err = error_body.get("error")
|
||||||
|
if isinstance(err, dict):
|
||||||
|
raw_message = err.get("message") or err.get("error_description")
|
||||||
|
if raw_message is not None:
|
||||||
|
message = str(raw_message).strip() or None
|
||||||
|
raw_code = err.get("code")
|
||||||
|
if raw_code is not None:
|
||||||
|
error_code = str(raw_code).strip() or None
|
||||||
|
raw_type = err.get("type")
|
||||||
|
if raw_type is not None:
|
||||||
|
error_type = str(raw_type).strip() or None
|
||||||
|
elif isinstance(err, str):
|
||||||
|
message = err.strip() or None
|
||||||
|
raw_message = error_body.get("message") or error_body.get("error_description")
|
||||||
|
if raw_message is not None and not message:
|
||||||
|
message = str(raw_message).strip() or None
|
||||||
|
raw_code = error_body.get("code")
|
||||||
|
if raw_code is not None and not error_code:
|
||||||
|
error_code = str(raw_code).strip() or None
|
||||||
|
raw_type = error_body.get("type")
|
||||||
|
if raw_type is not None and not error_type:
|
||||||
|
error_type = str(raw_type).strip() or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
text = str(getattr(resp, "text", "") or "").strip()
|
||||||
|
message = text[:300] if text else None
|
||||||
|
|
||||||
|
return _normalize_oauth_refresh_error_message(
|
||||||
|
message,
|
||||||
|
status_code=status_code,
|
||||||
|
error_code=error_code,
|
||||||
|
error_type=error_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _store_completed_oauth_sync(
|
def _store_completed_oauth_sync(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
provider_type: str,
|
provider_type: str,
|
||||||
@@ -71,22 +150,31 @@ def _mark_refresh_failed_sync(key_id: str, reason: str) -> None:
|
|||||||
if not key:
|
if not key:
|
||||||
raise NotFoundException("Key 不存在", "key")
|
raise NotFoundException("Key 不存在", "key")
|
||||||
current_reason = str(getattr(key, "oauth_invalid_reason", None) or "").strip()
|
current_reason = str(getattr(key, "oauth_invalid_reason", None) or "").strip()
|
||||||
if _should_preserve_refresh_failure_reason(current_reason):
|
merged_reason = _merge_refresh_failure_reason(current_reason, reason)
|
||||||
|
if merged_reason is None:
|
||||||
return
|
return
|
||||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||||
key.oauth_invalid_reason = reason
|
key.oauth_invalid_reason = merged_reason
|
||||||
|
|
||||||
|
|
||||||
def _should_preserve_refresh_failure_reason(reason: str | None) -> bool:
|
def _merge_refresh_failure_reason(current_reason: str | None, refresh_reason: str) -> str | None:
|
||||||
from src.services.provider.oauth_token import is_account_level_block
|
from src.services.provider.oauth_token import is_account_level_block
|
||||||
from src.services.provider.pool.account_state import OAUTH_EXPIRED_PREFIX
|
from src.services.provider.pool.account_state import OAUTH_EXPIRED_PREFIX
|
||||||
|
|
||||||
text = str(reason or "").strip()
|
current = str(current_reason or "").strip()
|
||||||
if not text:
|
next_reason = str(refresh_reason or "").strip()
|
||||||
return False
|
if not next_reason:
|
||||||
if is_account_level_block(text):
|
return current or None
|
||||||
return True
|
if not current:
|
||||||
return text.startswith(OAUTH_EXPIRED_PREFIX)
|
return next_reason
|
||||||
|
if current.startswith(OAUTH_EXPIRED_PREFIX):
|
||||||
|
return None
|
||||||
|
if is_account_level_block(current):
|
||||||
|
if "[REFRESH_FAILED]" in current:
|
||||||
|
head, _sep, _tail = current.partition("[REFRESH_FAILED]")
|
||||||
|
return f"{head.rstrip()}\n{next_reason}".strip()
|
||||||
|
return f"{current}\n{next_reason}"
|
||||||
|
return next_reason
|
||||||
|
|
||||||
|
|
||||||
def _store_refreshed_oauth_sync(
|
def _store_refreshed_oauth_sync(
|
||||||
@@ -1168,15 +1256,7 @@ async def refresh_oauth(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code < 200 or resp.status_code >= 300:
|
if resp.status_code < 200 or resp.status_code >= 300:
|
||||||
error_reason = f"HTTP {resp.status_code}"
|
error_reason = _extract_oauth_refresh_error_reason(resp)
|
||||||
try:
|
|
||||||
error_body = resp.json()
|
|
||||||
if "error" in error_body:
|
|
||||||
error_reason = str(
|
|
||||||
error_body.get("error_description") or error_body.get("error")
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
|
|
||||||
|
|
||||||
if resp.status_code in (400, 401, 403):
|
if resp.status_code in (400, 401, 403):
|
||||||
await run_in_threadpool(
|
await run_in_threadpool(
|
||||||
@@ -1194,7 +1274,7 @@ async def refresh_oauth(
|
|||||||
refresh_error=error_reason,
|
refresh_error=error_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
raise InvalidRequestException(f"token refresh 失败: {error_reason}")
|
raise InvalidRequestException(f"Token 刷新失败:{error_reason}")
|
||||||
|
|
||||||
token = resp.json()
|
token = resp.json()
|
||||||
access_token = str(token.get("access_token") or "")
|
access_token = str(token.get("access_token") or "")
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
event,
|
||||||
|
inspect,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -2066,6 +2068,7 @@ class ProviderAPIKey(ExportMixin, Base):
|
|||||||
# OAuth 失效状态(账号被封、授权撤销、刷新失败等)
|
# OAuth 失效状态(账号被封、授权撤销、刷新失败等)
|
||||||
oauth_invalid_at = Column(DateTime(timezone=True), nullable=True) # 失效时间
|
oauth_invalid_at = Column(DateTime(timezone=True), nullable=True) # 失效时间
|
||||||
oauth_invalid_reason = Column(String(255), nullable=True) # 失效原因
|
oauth_invalid_reason = Column(String(255), nullable=True) # 失效原因
|
||||||
|
status_snapshot = Column(JSON, nullable=True, default=None) # 结构化状态快照(兼容旧字段)
|
||||||
|
|
||||||
# Key 级别的代理配置(覆盖 Provider 级别的代理设置)
|
# Key 级别的代理配置(覆盖 Provider 级别的代理设置)
|
||||||
# 结构: {"node_id": "xxx", "enabled": true} 或 {"url": "socks5://...", "enabled": true}
|
# 结构: {"node_id": "xxx", "enabled": true} 或 {"url": "socks5://...", "enabled": true}
|
||||||
@@ -2092,6 +2095,49 @@ class ProviderAPIKey(ExportMixin, Base):
|
|||||||
provider = relationship("Provider", back_populates="api_keys")
|
provider = relationship("Provider", back_populates="api_keys")
|
||||||
|
|
||||||
|
|
||||||
|
_PROVIDER_API_KEY_STATUS_SNAPSHOT_FIELDS: tuple[str, ...] = (
|
||||||
|
"auth_type",
|
||||||
|
"auth_config",
|
||||||
|
"expires_at",
|
||||||
|
"oauth_invalid_at",
|
||||||
|
"oauth_invalid_reason",
|
||||||
|
"upstream_metadata",
|
||||||
|
"provider_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_provider_api_key_status_snapshot(
|
||||||
|
target: ProviderAPIKey,
|
||||||
|
*,
|
||||||
|
connection: Any | None,
|
||||||
|
force: bool,
|
||||||
|
) -> None:
|
||||||
|
state = inspect(target)
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
if not force:
|
||||||
|
relevant_changed = any(
|
||||||
|
state.attrs[field].history.has_changes()
|
||||||
|
for field in _PROVIDER_API_KEY_STATUS_SNAPSHOT_FIELDS
|
||||||
|
)
|
||||||
|
if not relevant_changed and getattr(target, "status_snapshot", None) is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
from src.services.provider_keys.status_snapshot_store import sync_provider_key_status_snapshot
|
||||||
|
|
||||||
|
sync_provider_key_status_snapshot(target, connection=connection)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(ProviderAPIKey, "before_insert")
|
||||||
|
def _provider_api_key_before_insert(mapper: Any, connection: Any, target: ProviderAPIKey) -> None:
|
||||||
|
_sync_provider_api_key_status_snapshot(target, connection=connection, force=True)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(ProviderAPIKey, "before_update")
|
||||||
|
def _provider_api_key_before_update(mapper: Any, connection: Any, target: ProviderAPIKey) -> None:
|
||||||
|
_sync_provider_api_key_status_snapshot(target, connection=connection, force=False)
|
||||||
|
|
||||||
|
|
||||||
def _generate_short_id(length: int = 12) -> str:
|
def _generate_short_id(length: int = 12) -> str:
|
||||||
"""生成 Gemini 风格的短 ID(小写字母+数字)"""
|
"""生成 Gemini 风格的短 ID(小写字母+数字)"""
|
||||||
import secrets
|
import secrets
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from src.models.admin_requests import (
|
|||||||
PoolAdvancedConfig,
|
PoolAdvancedConfig,
|
||||||
ProxyConfig,
|
ProxyConfig,
|
||||||
)
|
)
|
||||||
|
from src.models.status_snapshot import ProviderKeyStatusSnapshotResponse
|
||||||
|
|
||||||
# ========== Header Rule 类型定义 ==========
|
# ========== Header Rule 类型定义 ==========
|
||||||
# 请求头规则支持三种操作:
|
# 请求头规则支持三种操作:
|
||||||
@@ -130,8 +131,7 @@ 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"{rule_label}: condition.op 必须是 {sorted(_CONDITION_OPS)} 之一," f"当前值: {op!r}"
|
||||||
f"当前值: {op!r}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
path = condition.get("path")
|
path = condition.get("path")
|
||||||
@@ -152,15 +152,11 @@ 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(
|
raise ValueError(f"{rule_label}: condition op=matches 的 value 必须为非空字符串")
|
||||||
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(
|
raise ValueError(f"{rule_label}: condition op=matches 的 value 不是合法正则: {e}")
|
||||||
f"{rule_label}: condition op=matches 的 value 不是合法正则: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# in 校验
|
# in 校验
|
||||||
if op == "in":
|
if op == "in":
|
||||||
@@ -188,10 +184,7 @@ 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 (
|
if not isinstance(action, str) or action.strip().lower() not in _HEADER_RULE_ACTIONS:
|
||||||
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}"
|
||||||
@@ -241,10 +234,7 @@ 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 (
|
if not isinstance(action, str) or action.strip().lower() not in _BODY_RULE_ACTIONS:
|
||||||
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}"
|
||||||
@@ -255,9 +245,7 @@ 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(
|
raise ValueError(f"body_rules[{idx}]: action={action!r} 必须提供非空 path")
|
||||||
f"body_rules[{idx}]: action={action!r} 必须提供非空 path"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------- rename 校验 ----------
|
# ---------- rename 校验 ----------
|
||||||
if action == "rename":
|
if action == "rename":
|
||||||
@@ -278,15 +266,11 @@ 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(
|
raise ValueError(f"body_rules[{idx}]: regex_replace 必须提供非空 pattern 字符串")
|
||||||
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(
|
raise ValueError(f"body_rules[{idx}]: regex_replace 的 replacement 必须为字符串")
|
||||||
f"body_rules[{idx}]: regex_replace 的 replacement 必须为字符串"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 校验 flags
|
# 校验 flags
|
||||||
flags_str = rule.get("flags", "")
|
flags_str = rule.get("flags", "")
|
||||||
@@ -312,9 +296,7 @@ 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(
|
raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数")
|
||||||
f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------- name_style 校验 ----------
|
# ---------- name_style 校验 ----------
|
||||||
if action == "name_style":
|
if action == "name_style":
|
||||||
@@ -347,9 +329,7 @@ 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(
|
custom_path: str | None = Field(default=None, max_length=200, description="自定义请求路径")
|
||||||
default=None, max_length=200, description="自定义请求路径"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 请求头配置
|
# 请求头配置
|
||||||
header_rules: list[HeaderRule] | None = Field(
|
header_rules: list[HeaderRule] | None = Field(
|
||||||
@@ -411,9 +391,7 @@ class ProviderEndpointCreate(BaseModel):
|
|||||||
|
|
||||||
@field_validator("header_rules")
|
@field_validator("header_rules")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_header_rules(
|
def validate_header_rules(cls, v: list[HeaderRule] | None) -> list[HeaderRule] | None:
|
||||||
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
|
||||||
@@ -426,9 +404,7 @@ 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(
|
custom_path: str | None = Field(default=None, max_length=200, description="自定义请求路径")
|
||||||
default=None, max_length=200, description="自定义请求路径"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 请求头配置
|
# 请求头配置
|
||||||
header_rules: list[HeaderRule] | None = Field(
|
header_rules: list[HeaderRule] | None = Field(
|
||||||
@@ -442,9 +418,7 @@ class ProviderEndpointUpdate(BaseModel):
|
|||||||
description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作",
|
description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作",
|
||||||
)
|
)
|
||||||
|
|
||||||
max_retries: int | None = Field(
|
max_retries: int | None = Field(default=None, ge=0, le=999, description="最大重试次数")
|
||||||
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="代理配置")
|
||||||
@@ -477,9 +451,7 @@ class ProviderEndpointUpdate(BaseModel):
|
|||||||
|
|
||||||
@field_validator("header_rules")
|
@field_validator("header_rules")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_header_rules(
|
def validate_header_rules(cls, v: list[HeaderRule] | None) -> list[HeaderRule] | None:
|
||||||
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
|
||||||
@@ -499,14 +471,10 @@ class ProviderEndpointResponse(BaseModel):
|
|||||||
custom_path: str | None = None
|
custom_path: str | None = None
|
||||||
|
|
||||||
# 请求头配置
|
# 请求头配置
|
||||||
header_rules: list[HeaderRule] | None = Field(
|
header_rules: list[HeaderRule] | None = Field(default=None, description="请求头规则列表")
|
||||||
default=None, description="请求头规则列表"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 请求体配置
|
# 请求体配置
|
||||||
body_rules: list[BodyRule] | None = Field(
|
body_rules: list[BodyRule] | None = Field(default=None, description="请求体规则列表")
|
||||||
default=None, description="请求体规则列表"
|
|
||||||
)
|
|
||||||
|
|
||||||
max_retries: int
|
max_retries: int
|
||||||
|
|
||||||
@@ -517,9 +485,7 @@ class ProviderEndpointResponse(BaseModel):
|
|||||||
config: dict[str, Any] | None = None
|
config: dict[str, Any] | None = None
|
||||||
|
|
||||||
# 代理配置(响应中密码已脱敏)
|
# 代理配置(响应中密码已脱敏)
|
||||||
proxy: dict[str, Any] | None = Field(
|
proxy: dict[str, Any] | None = Field(default=None, description="代理配置(密码已脱敏)")
|
||||||
default=None, description="代理配置(密码已脱敏)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 格式转换配置
|
# 格式转换配置
|
||||||
format_acceptance_config: dict[str, Any] | None = Field(
|
format_acceptance_config: dict[str, Any] | None = Field(
|
||||||
@@ -544,9 +510,7 @@ class ProviderEndpointResponse(BaseModel):
|
|||||||
class EndpointAPIKeyCreate(BaseModel):
|
class EndpointAPIKeyCreate(BaseModel):
|
||||||
"""为 Provider 添加 API Key"""
|
"""为 Provider 添加 API Key"""
|
||||||
|
|
||||||
provider_id: str | None = Field(
|
provider_id: str | None = Field(default=None, description="Provider ID(从 URL 获取)")
|
||||||
default=None, description="Provider ID(从 URL 获取)"
|
|
||||||
)
|
|
||||||
api_formats: list[str] | None = Field(
|
api_formats: list[str] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
min_length=1,
|
min_length=1,
|
||||||
@@ -569,9 +533,7 @@ class EndpointAPIKeyCreate(BaseModel):
|
|||||||
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
|
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
name: str = Field(
|
name: str = Field(..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)")
|
||||||
..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 成本计算
|
# 成本计算
|
||||||
rate_multipliers: dict[str, float] | None = Field(
|
rate_multipliers: dict[str, float] | None = Field(
|
||||||
@@ -580,9 +542,7 @@ class EndpointAPIKeyCreate(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 优先级和限制(数字越小越优先)
|
# 优先级和限制(数字越小越优先)
|
||||||
internal_priority: int = Field(
|
internal_priority: int = Field(default=50, description="Key 内部优先级(提供商优先模式)")
|
||||||
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=自适应模式)"
|
||||||
@@ -607,9 +567,7 @@ class EndpointAPIKeyCreate(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 备注
|
# 备注
|
||||||
note: str | None = Field(
|
note: str | None = Field(default=None, max_length=500, description="备注说明(可选)")
|
||||||
default=None, max_length=500, description="备注说明(可选)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 自动获取模型
|
# 自动获取模型
|
||||||
auto_fetch_models: bool = Field(
|
auto_fetch_models: bool = Field(
|
||||||
@@ -649,9 +607,7 @@ 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(
|
raise ValueError(f"api_formats 必须是以下之一: {allowed},当前值: {fmt}")
|
||||||
f"api_formats 必须是以下之一: {allowed},当前值: {fmt}"
|
|
||||||
)
|
|
||||||
if normalized in seen:
|
if normalized in seen:
|
||||||
continue # 静默去重
|
continue # 静默去重
|
||||||
seen.add(normalized)
|
seen.add(normalized)
|
||||||
@@ -737,9 +693,7 @@ class EndpointAPIKeyUpdate(BaseModel):
|
|||||||
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
|
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
name: str | None = Field(
|
name: str | None = Field(default=None, min_length=1, max_length=100, description="密钥名称")
|
||||||
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}",
|
||||||
@@ -774,9 +728,7 @@ 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(
|
auto_fetch_models: bool | None = Field(default=None, description="是否启用自动获取模型")
|
||||||
default=None, description="是否启用自动获取模型"
|
|
||||||
)
|
|
||||||
locked_models: list[str] | None = Field(
|
locked_models: list[str] | None = Field(
|
||||||
default=None, description="被锁定的模型列表(刷新时不会被删除)"
|
default=None, description="被锁定的模型列表(刷新时不会被删除)"
|
||||||
)
|
)
|
||||||
@@ -891,9 +843,7 @@ 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(
|
capabilities: dict[str, bool] | None = Field(default=None, description="Key 能力标签")
|
||||||
default=None, description="Key 能力标签"
|
|
||||||
)
|
|
||||||
|
|
||||||
# OAuth 相关
|
# OAuth 相关
|
||||||
oauth_expires_at: int | None = Field(
|
oauth_expires_at: int | None = Field(
|
||||||
@@ -904,9 +854,7 @@ 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(
|
oauth_account_name: str | None = Field(default=None, description="OAuth 当前工作区/账号名称")
|
||||||
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)",
|
||||||
@@ -917,17 +865,19 @@ class EndpointAPIKeyResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
oauth_invalid_at: int | None = Field(
|
oauth_invalid_at: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="OAuth Token 失效时间(Unix 时间戳),如账号被封、授权撤销等",
|
description="OAuth Token 失效时间(Unix 时间戳,兼容字段;优先使用 status_snapshot.oauth)",
|
||||||
)
|
)
|
||||||
oauth_invalid_reason: str | None = Field(
|
oauth_invalid_reason: str | None = Field(
|
||||||
default=None, description="OAuth Token 失效原因"
|
default=None, description="OAuth Token 失效原因(兼容字段;优先使用 status_snapshot.oauth)"
|
||||||
|
)
|
||||||
|
status_snapshot: ProviderKeyStatusSnapshotResponse = Field(
|
||||||
|
default_factory=ProviderKeyStatusSnapshotResponse,
|
||||||
|
description="统一的账号/OAuth/额度状态快照",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 缓存与熔断配置
|
# 缓存与熔断配置
|
||||||
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(
|
max_probe_interval_minutes: int = Field(default=32, description="熔断探测间隔(分钟)")
|
||||||
default=32, description="熔断探测间隔(分钟)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 按 endpoint signature 的健康度数据
|
# 按 endpoint signature 的健康度数据
|
||||||
health_by_format: dict[str, Any] | None = Field(
|
health_by_format: dict[str, Any] | None = Field(
|
||||||
@@ -943,18 +893,10 @@ class EndpointAPIKeyResponse(BaseModel):
|
|||||||
last_failure_at: datetime | None = None
|
last_failure_at: datetime | None = None
|
||||||
|
|
||||||
# 聚合熔断器字段
|
# 聚合熔断器字段
|
||||||
circuit_breaker_open: bool = Field(
|
circuit_breaker_open: bool = Field(default=False, description="熔断器是否打开(任何格式)")
|
||||||
default=False, description="熔断器是否打开(任何格式)"
|
circuit_breaker_open_at: datetime | None = Field(default=None, description="熔断器打开时间")
|
||||||
)
|
next_probe_at: datetime | None = Field(default=None, description="下次进入半开状态时间")
|
||||||
circuit_breaker_open_at: datetime | None = Field(
|
half_open_until: datetime | None = Field(default=None, description="半开状态结束时间")
|
||||||
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[str, Any]] | None = Field(
|
request_results_window: list[dict[str, Any]] | None = Field(
|
||||||
@@ -972,18 +914,12 @@ class EndpointAPIKeyResponse(BaseModel):
|
|||||||
is_active: bool
|
is_active: bool
|
||||||
|
|
||||||
# 自适应 RPM 信息
|
# 自适应 RPM 信息
|
||||||
is_adaptive: bool = Field(
|
is_adaptive: bool = Field(default=False, description="是否为自适应模式(rpm_limit=NULL)")
|
||||||
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[str, Any]] | None = Field(
|
utilization_samples: list[dict[str, Any]] | None = Field(None, description="利用率采样窗口")
|
||||||
None, description="利用率采样窗口"
|
last_probe_increase_at: datetime | None = Field(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
|
||||||
@@ -995,9 +931,7 @@ 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(
|
last_models_fetch_error: str | None = Field(None, description="最后获取模型错误信息")
|
||||||
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="模型包含规则")
|
||||||
@@ -1071,9 +1005,7 @@ class HealthStatusResponse(BaseModel):
|
|||||||
class HealthSummaryResponse(BaseModel):
|
class HealthSummaryResponse(BaseModel):
|
||||||
"""健康状态摘要"""
|
"""健康状态摘要"""
|
||||||
|
|
||||||
endpoints: dict[str, int] = Field(
|
endpoints: dict[str, int] = Field(..., description="Endpoint 统计 (total, active, unhealthy)")
|
||||||
..., 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)")
|
||||||
|
|
||||||
|
|
||||||
@@ -1092,17 +1024,13 @@ class KeyPriorityItem(BaseModel):
|
|||||||
"""单个 Key 优先级项"""
|
"""单个 Key 优先级项"""
|
||||||
|
|
||||||
key_id: str = Field(..., description="Key ID")
|
key_id: str = Field(..., description="Key ID")
|
||||||
internal_priority: int = Field(
|
internal_priority: int = Field(..., ge=0, description="Key 内部优先级(数字越小越优先)")
|
||||||
..., ge=0, description="Key 内部优先级(数字越小越优先)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BatchUpdateKeyPriorityRequest(BaseModel):
|
class BatchUpdateKeyPriorityRequest(BaseModel):
|
||||||
"""批量更新 Key 优先级请求"""
|
"""批量更新 Key 优先级请求"""
|
||||||
|
|
||||||
priorities: list[KeyPriorityItem] = Field(
|
priorities: list[KeyPriorityItem] = Field(..., min_length=1, description="Key 优先级列表")
|
||||||
..., min_length=1, description="Key 优先级列表"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 提供商摘要(增强版) ==========
|
# ========== 提供商摘要(增强版) ==========
|
||||||
@@ -1114,9 +1042,7 @@ 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(
|
provider_priority: int | None = Field(None, description="提供商优先级(数字越小越优先)")
|
||||||
None, description="提供商优先级(数字越小越优先)"
|
|
||||||
)
|
|
||||||
keep_priority_on_conversion: bool | None = Field(
|
keep_priority_on_conversion: bool | None = Field(
|
||||||
None,
|
None,
|
||||||
description="格式转换时是否保持优先级(True=保持原优先级,False=需要转换时降级)",
|
description="格式转换时是否保持优先级(True=保持原优先级,False=需要转换时降级)",
|
||||||
@@ -1130,9 +1056,7 @@ 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(
|
quota_reset_day: int | None = Field(None, ge=1, le=31, description="配额重置日(1-31)")
|
||||||
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="最大重试次数")
|
||||||
@@ -1148,9 +1072,7 @@ 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(
|
failover_rules: FailoverRulesConfig | None = Field(None, description="故障转移规则配置")
|
||||||
None, description="故障转移规则配置"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderWithEndpointsSummary(BaseModel):
|
class ProviderWithEndpointsSummary(BaseModel):
|
||||||
@@ -1165,9 +1087,7 @@ class ProviderWithEndpointsSummary(BaseModel):
|
|||||||
)
|
)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
website: str | None = None
|
website: str | None = None
|
||||||
provider_priority: int = Field(
|
provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)")
|
||||||
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=需要转换时降级)",
|
||||||
@@ -1182,12 +1102,8 @@ 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(
|
quota_reset_day: int | None = Field(default=None, description="配额重置周期(天数)")
|
||||||
default=None, description="配额重置周期(天数)"
|
quota_last_reset_at: datetime | None = Field(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 迁移)
|
||||||
@@ -1197,18 +1113,12 @@ 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(
|
request_timeout: float | None = Field(default=None, description="非流式请求整体超时(秒)")
|
||||||
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(
|
pool_advanced: PoolAdvancedConfig | None = Field(default=None, description="通用号池配置")
|
||||||
default=None, description="通用号池配置"
|
failover_rules: FailoverRulesConfig | None = Field(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 数量")
|
||||||
@@ -1221,9 +1131,7 @@ 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(
|
global_model_ids: list[str] = Field(default=[], description="活跃模型关联的全局模型 ID 列表")
|
||||||
default=[], description="活跃模型关联的全局模型 ID 列表"
|
|
||||||
)
|
|
||||||
|
|
||||||
# API 格式列表
|
# API 格式列表
|
||||||
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")
|
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")
|
||||||
@@ -1241,9 +1149,7 @@ class ProviderWithEndpointsSummary(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Provider Ops 配置状态
|
# Provider Ops 配置状态
|
||||||
ops_configured: bool = Field(
|
ops_configured: bool = Field(default=False, description="是否配置了扩展操作(余额监控等)")
|
||||||
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)"
|
||||||
)
|
)
|
||||||
@@ -1322,9 +1228,7 @@ 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(
|
time_range_end: datetime | None = Field(default=None, description="时间线所覆盖区间的结束时间")
|
||||||
default=None, description="时间线所覆盖区间的结束时间"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiFormatHealthMonitorResponse(BaseModel):
|
class ApiFormatHealthMonitorResponse(BaseModel):
|
||||||
@@ -1358,19 +1262,13 @@ 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(
|
events: list[PublicHealthEvent] = Field(default_factory=list, description="事件列表")
|
||||||
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(
|
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 PublicApiFormatHealthMonitorResponse(BaseModel):
|
class PublicApiFormatHealthMonitorResponse(BaseModel):
|
||||||
|
|||||||
40
src/models/status_snapshot.py
Normal file
40
src/models/status_snapshot.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthStatusSnapshotResponse(BaseModel):
|
||||||
|
code: str = Field(default="none", description="OAuth 状态代码")
|
||||||
|
label: str | None = Field(default=None, description="OAuth 状态标签")
|
||||||
|
reason: str | None = Field(default=None, description="OAuth 状态原因")
|
||||||
|
expires_at: int | None = Field(default=None, description="OAuth 过期时间(Unix 时间戳)")
|
||||||
|
invalid_at: int | None = Field(default=None, description="OAuth 失效时间(Unix 时间戳)")
|
||||||
|
source: str | None = Field(default=None, description="OAuth 状态来源")
|
||||||
|
requires_reauth: bool = Field(default=False, description="是否需要重新授权")
|
||||||
|
expiring_soon: bool = Field(default=False, description="是否即将过期")
|
||||||
|
|
||||||
|
|
||||||
|
class AccountStatusSnapshotResponse(BaseModel):
|
||||||
|
code: str = Field(default="ok", description="账号状态代码")
|
||||||
|
label: str | None = Field(default=None, description="账号状态标签")
|
||||||
|
reason: str | None = Field(default=None, description="账号状态原因")
|
||||||
|
blocked: bool = Field(default=False, description="是否为账号级阻塞")
|
||||||
|
source: str | None = Field(default=None, description="账号状态来源")
|
||||||
|
recoverable: bool = Field(default=False, description="是否为可恢复状态")
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaStatusSnapshotResponse(BaseModel):
|
||||||
|
code: str = Field(default="unknown", description="额度状态代码")
|
||||||
|
label: str | None = Field(default=None, description="额度状态标签")
|
||||||
|
reason: str | None = Field(default=None, description="额度状态原因")
|
||||||
|
exhausted: bool = Field(default=False, description="额度是否耗尽")
|
||||||
|
usage_ratio: float | None = Field(default=None, description="额度使用比例 [0, 1]")
|
||||||
|
updated_at: int | None = Field(default=None, description="额度刷新时间(Unix 时间戳)")
|
||||||
|
reset_seconds: float | None = Field(default=None, description="距离重置剩余秒数")
|
||||||
|
plan_type: str | None = Field(default=None, description="额度读取到的套餐类型")
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderKeyStatusSnapshotResponse(BaseModel):
|
||||||
|
oauth: OAuthStatusSnapshotResponse = Field(default_factory=OAuthStatusSnapshotResponse)
|
||||||
|
account: AccountStatusSnapshotResponse = Field(default_factory=AccountStatusSnapshotResponse)
|
||||||
|
quota: QuotaStatusSnapshotResponse = Field(default_factory=QuotaStatusSnapshotResponse)
|
||||||
@@ -6,6 +6,8 @@ from upstream metadata and OAuth invalid reasons.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -106,6 +108,47 @@ class PoolAccountState:
|
|||||||
recoverable: bool = False
|
recoverable: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OAuthStatusSnapshot:
|
||||||
|
code: str = "none" # none / valid / expiring / expired / invalid / check_failed
|
||||||
|
label: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
expires_at: int | None = None
|
||||||
|
invalid_at: int | None = None
|
||||||
|
source: str | None = None
|
||||||
|
requires_reauth: bool = False
|
||||||
|
expiring_soon: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AccountStatusSnapshot:
|
||||||
|
code: str = "ok"
|
||||||
|
label: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
blocked: bool = False
|
||||||
|
source: str | None = None
|
||||||
|
recoverable: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class QuotaStatusSnapshot:
|
||||||
|
code: str = "unknown" # unknown / ok / exhausted
|
||||||
|
label: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
exhausted: bool = False
|
||||||
|
usage_ratio: float | None = None
|
||||||
|
updated_at: int | None = None
|
||||||
|
reset_seconds: float | None = None
|
||||||
|
plan_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProviderKeyStatusSnapshot:
|
||||||
|
oauth: OAuthStatusSnapshot
|
||||||
|
account: AccountStatusSnapshot
|
||||||
|
quota: QuotaStatusSnapshot
|
||||||
|
|
||||||
|
|
||||||
def _is_truthy_flag(value: Any) -> bool:
|
def _is_truthy_flag(value: Any) -> bool:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
@@ -139,6 +182,26 @@ def _is_workspace_deactivated_reason(reason: str | None) -> bool:
|
|||||||
return bool(text and "deactivated_workspace" in text.lower())
|
return bool(text and "deactivated_workspace" in text.lower())
|
||||||
|
|
||||||
|
|
||||||
|
_TAGGED_REASON_PATTERN = re.compile(
|
||||||
|
r"(?:^|\n)\[(?P<tag>[A-Z_]+)\]\s*(?P<detail>.*?)(?=\n\[[A-Z_]+\]|\Z)",
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tagged_reason_sections(reason: str | None) -> dict[str, str]:
|
||||||
|
text = _clean_text(reason)
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
sections: dict[str, str] = {}
|
||||||
|
for match in _TAGGED_REASON_PATTERN.finditer(text):
|
||||||
|
tag = str(match.group("tag") or "").strip().upper()
|
||||||
|
if not tag or tag in sections:
|
||||||
|
continue
|
||||||
|
detail = str(match.group("detail") or "").strip()
|
||||||
|
sections[tag] = detail
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
def _resolve_from_metadata(
|
def _resolve_from_metadata(
|
||||||
provider_type: str | None,
|
provider_type: str | None,
|
||||||
upstream_metadata: Any,
|
upstream_metadata: Any,
|
||||||
@@ -266,6 +329,216 @@ def _resolve_from_oauth_invalid_reason(reason: str | None) -> PoolAccountState |
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_account_status_snapshot(
|
||||||
|
*,
|
||||||
|
provider_type: str | None,
|
||||||
|
upstream_metadata: Any,
|
||||||
|
oauth_invalid_reason: str | None,
|
||||||
|
) -> AccountStatusSnapshot:
|
||||||
|
from_metadata = _resolve_from_metadata(provider_type, upstream_metadata)
|
||||||
|
if from_metadata is not None:
|
||||||
|
return AccountStatusSnapshot(
|
||||||
|
code=from_metadata.code or "ok",
|
||||||
|
label=from_metadata.label,
|
||||||
|
reason=from_metadata.reason,
|
||||||
|
blocked=from_metadata.blocked,
|
||||||
|
source=from_metadata.source,
|
||||||
|
recoverable=from_metadata.recoverable,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = _clean_text(oauth_invalid_reason)
|
||||||
|
if not text:
|
||||||
|
return AccountStatusSnapshot()
|
||||||
|
|
||||||
|
tagged_sections = _extract_tagged_reason_sections(text)
|
||||||
|
if "ACCOUNT_BLOCK" in tagged_sections:
|
||||||
|
cleaned = tagged_sections["ACCOUNT_BLOCK"]
|
||||||
|
code, label = (
|
||||||
|
_classify_block_reason(cleaned) if cleaned else ("account_blocked", "账号异常")
|
||||||
|
)
|
||||||
|
return AccountStatusSnapshot(
|
||||||
|
code=code,
|
||||||
|
label=label,
|
||||||
|
reason=cleaned or "账号异常",
|
||||||
|
blocked=True,
|
||||||
|
source="oauth_invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
if text.startswith("["):
|
||||||
|
return AccountStatusSnapshot()
|
||||||
|
|
||||||
|
lowered = text.lower()
|
||||||
|
if any(keyword in lowered for keyword in ACCOUNT_BLOCK_REASON_KEYWORDS):
|
||||||
|
code, label = _classify_block_reason(text)
|
||||||
|
return AccountStatusSnapshot(
|
||||||
|
code=code,
|
||||||
|
label=label,
|
||||||
|
reason=text,
|
||||||
|
blocked=True,
|
||||||
|
source="oauth_invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
return AccountStatusSnapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_oauth_status_snapshot(
|
||||||
|
*,
|
||||||
|
auth_type: str | None,
|
||||||
|
oauth_expires_at: int | None,
|
||||||
|
oauth_invalid_at: int | None,
|
||||||
|
oauth_invalid_reason: str | None,
|
||||||
|
now_ts: int | None = None,
|
||||||
|
) -> OAuthStatusSnapshot:
|
||||||
|
if str(auth_type or "").strip().lower() != "oauth":
|
||||||
|
return OAuthStatusSnapshot()
|
||||||
|
|
||||||
|
now = int(now_ts if now_ts is not None else time.time())
|
||||||
|
invalid_at = int(oauth_invalid_at) if isinstance(oauth_invalid_at, int) else None
|
||||||
|
tagged_sections = _extract_tagged_reason_sections(oauth_invalid_reason)
|
||||||
|
raw_reason = _clean_text(oauth_invalid_reason)
|
||||||
|
|
||||||
|
expired_reason = tagged_sections.get("OAUTH_EXPIRED")
|
||||||
|
if expired_reason:
|
||||||
|
return OAuthStatusSnapshot(
|
||||||
|
code="invalid",
|
||||||
|
label="已失效",
|
||||||
|
reason=expired_reason,
|
||||||
|
invalid_at=invalid_at,
|
||||||
|
expires_at=oauth_expires_at,
|
||||||
|
source="oauth_invalid",
|
||||||
|
requires_reauth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_failed_reason = tagged_sections.get("REFRESH_FAILED")
|
||||||
|
if refresh_failed_reason:
|
||||||
|
return OAuthStatusSnapshot(
|
||||||
|
code="invalid",
|
||||||
|
label="已失效",
|
||||||
|
reason=refresh_failed_reason,
|
||||||
|
invalid_at=invalid_at,
|
||||||
|
expires_at=oauth_expires_at,
|
||||||
|
source="oauth_refresh",
|
||||||
|
requires_reauth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request_failed_reason = tagged_sections.get("REQUEST_FAILED")
|
||||||
|
if request_failed_reason:
|
||||||
|
return OAuthStatusSnapshot(
|
||||||
|
code="check_failed",
|
||||||
|
label="检查失败",
|
||||||
|
reason=request_failed_reason,
|
||||||
|
expires_at=oauth_expires_at,
|
||||||
|
source="oauth_request",
|
||||||
|
)
|
||||||
|
|
||||||
|
account_snapshot = resolve_account_status_snapshot(
|
||||||
|
provider_type=None,
|
||||||
|
upstream_metadata=None,
|
||||||
|
oauth_invalid_reason=raw_reason,
|
||||||
|
)
|
||||||
|
if account_snapshot.blocked:
|
||||||
|
if oauth_expires_at is None:
|
||||||
|
return OAuthStatusSnapshot()
|
||||||
|
elif raw_reason or invalid_at is not None:
|
||||||
|
return OAuthStatusSnapshot(
|
||||||
|
code="invalid",
|
||||||
|
label="已失效",
|
||||||
|
reason=raw_reason,
|
||||||
|
invalid_at=invalid_at,
|
||||||
|
expires_at=oauth_expires_at,
|
||||||
|
source="oauth_invalid",
|
||||||
|
requires_reauth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
expires_at = int(oauth_expires_at) if isinstance(oauth_expires_at, int) else None
|
||||||
|
if expires_at is None:
|
||||||
|
return OAuthStatusSnapshot()
|
||||||
|
if expires_at <= now:
|
||||||
|
return OAuthStatusSnapshot(
|
||||||
|
code="expired",
|
||||||
|
label="已过期",
|
||||||
|
reason="Token 已过期,请重新授权",
|
||||||
|
expires_at=expires_at,
|
||||||
|
source="expires_at",
|
||||||
|
requires_reauth=True,
|
||||||
|
)
|
||||||
|
expiring_soon = (expires_at - now) < 24 * 3600
|
||||||
|
return OAuthStatusSnapshot(
|
||||||
|
code="expiring" if expiring_soon else "valid",
|
||||||
|
label="即将过期" if expiring_soon else "有效",
|
||||||
|
expires_at=expires_at,
|
||||||
|
source="expires_at",
|
||||||
|
expiring_soon=expiring_soon,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_quota_status_snapshot(
|
||||||
|
*,
|
||||||
|
provider_type: str | None,
|
||||||
|
upstream_metadata: Any,
|
||||||
|
) -> QuotaStatusSnapshot:
|
||||||
|
normalized_provider = str(provider_type or "").strip().lower()
|
||||||
|
reader = get_quota_reader(normalized_provider, upstream_metadata)
|
||||||
|
quota_state = reader.is_exhausted()
|
||||||
|
usage_ratio = reader.usage_ratio()
|
||||||
|
updated_at = reader.updated_at()
|
||||||
|
reset_seconds = reader.reset_seconds()
|
||||||
|
plan_type = reader.plan_type()
|
||||||
|
|
||||||
|
if quota_state.exhausted:
|
||||||
|
return QuotaStatusSnapshot(
|
||||||
|
code="exhausted",
|
||||||
|
label="额度耗尽",
|
||||||
|
reason=quota_state.reason,
|
||||||
|
exhausted=True,
|
||||||
|
usage_ratio=usage_ratio,
|
||||||
|
updated_at=updated_at,
|
||||||
|
reset_seconds=reset_seconds,
|
||||||
|
plan_type=plan_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(value is not None for value in (usage_ratio, updated_at, reset_seconds, plan_type)):
|
||||||
|
return QuotaStatusSnapshot(
|
||||||
|
code="ok",
|
||||||
|
exhausted=False,
|
||||||
|
usage_ratio=usage_ratio,
|
||||||
|
updated_at=updated_at,
|
||||||
|
reset_seconds=reset_seconds,
|
||||||
|
plan_type=plan_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return QuotaStatusSnapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider_key_status_snapshot(
|
||||||
|
*,
|
||||||
|
auth_type: str | None,
|
||||||
|
oauth_expires_at: int | None,
|
||||||
|
oauth_invalid_at: int | None,
|
||||||
|
oauth_invalid_reason: str | None,
|
||||||
|
provider_type: str | None,
|
||||||
|
upstream_metadata: Any,
|
||||||
|
now_ts: int | None = None,
|
||||||
|
) -> ProviderKeyStatusSnapshot:
|
||||||
|
account = resolve_account_status_snapshot(
|
||||||
|
provider_type=provider_type,
|
||||||
|
upstream_metadata=upstream_metadata,
|
||||||
|
oauth_invalid_reason=oauth_invalid_reason,
|
||||||
|
)
|
||||||
|
oauth = resolve_oauth_status_snapshot(
|
||||||
|
auth_type=auth_type,
|
||||||
|
oauth_expires_at=oauth_expires_at,
|
||||||
|
oauth_invalid_at=oauth_invalid_at,
|
||||||
|
oauth_invalid_reason=oauth_invalid_reason,
|
||||||
|
now_ts=now_ts,
|
||||||
|
)
|
||||||
|
quota = resolve_quota_status_snapshot(
|
||||||
|
provider_type=provider_type,
|
||||||
|
upstream_metadata=upstream_metadata,
|
||||||
|
)
|
||||||
|
return ProviderKeyStatusSnapshot(oauth=oauth, account=account, quota=quota)
|
||||||
|
|
||||||
|
|
||||||
def resolve_pool_account_state(
|
def resolve_pool_account_state(
|
||||||
*,
|
*,
|
||||||
provider_type: str | None,
|
provider_type: str | None,
|
||||||
@@ -303,11 +576,19 @@ def should_auto_remove_account_state(state: PoolAccountState) -> bool:
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"ACCOUNT_BLOCK_REASON_KEYWORDS",
|
"ACCOUNT_BLOCK_REASON_KEYWORDS",
|
||||||
"AUTO_REMOVABLE_ACCOUNT_STATE_CODES",
|
"AUTO_REMOVABLE_ACCOUNT_STATE_CODES",
|
||||||
|
"AccountStatusSnapshot",
|
||||||
"OAUTH_ACCOUNT_BLOCK_PREFIX",
|
"OAUTH_ACCOUNT_BLOCK_PREFIX",
|
||||||
"OAUTH_EXPIRED_PREFIX",
|
"OAUTH_EXPIRED_PREFIX",
|
||||||
"OAUTH_REFRESH_FAILED_PREFIX",
|
"OAUTH_REFRESH_FAILED_PREFIX",
|
||||||
"OAUTH_REQUEST_FAILED_PREFIX",
|
"OAUTH_REQUEST_FAILED_PREFIX",
|
||||||
|
"OAuthStatusSnapshot",
|
||||||
"PoolAccountState",
|
"PoolAccountState",
|
||||||
|
"ProviderKeyStatusSnapshot",
|
||||||
|
"QuotaStatusSnapshot",
|
||||||
|
"build_provider_key_status_snapshot",
|
||||||
|
"resolve_account_status_snapshot",
|
||||||
|
"resolve_oauth_status_snapshot",
|
||||||
"resolve_pool_account_state",
|
"resolve_pool_account_state",
|
||||||
|
"resolve_quota_status_snapshot",
|
||||||
"should_auto_remove_account_state",
|
"should_auto_remove_account_state",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -204,6 +204,12 @@ def list_provider_keys_responses(
|
|||||||
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
||||||
if not provider:
|
if not provider:
|
||||||
raise NotFoundException(f"Provider {provider_id} 不存在")
|
raise NotFoundException(f"Provider {provider_id} 不存在")
|
||||||
|
provider_type = (
|
||||||
|
str(
|
||||||
|
getattr(provider, "provider_type", None) or getattr(provider, "type", None) or ""
|
||||||
|
).strip()
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
keys = (
|
keys = (
|
||||||
db.query(ProviderAPIKey)
|
db.query(ProviderAPIKey)
|
||||||
@@ -213,7 +219,7 @@ def list_provider_keys_responses(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [build_key_response(key) for key in keys]
|
return [build_key_response(key, provider_type=provider_type) for key in keys]
|
||||||
|
|
||||||
|
|
||||||
def reveal_endpoint_key_payload(
|
def reveal_endpoint_key_payload(
|
||||||
|
|||||||
25
src/services/provider_keys/quota_refresh/_helpers.py
Normal file
25
src/services/provider_keys/quota_refresh/_helpers.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Shared helpers for quota refresh strategies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.models.database import ProviderAPIKey
|
||||||
|
from src.services.provider.pool.account_state import OAUTH_REFRESH_FAILED_PREFIX
|
||||||
|
|
||||||
|
|
||||||
|
def build_success_state_update(key: ProviderAPIKey) -> dict[str, Any]:
|
||||||
|
"""配额刷新成功时的 state_updates 构建。
|
||||||
|
|
||||||
|
如果当前 key 携带 [REFRESH_FAILED] 标记,保留该标记(配额刷新不等于 token 刷新成功)。
|
||||||
|
"""
|
||||||
|
current_reason = str(getattr(key, "oauth_invalid_reason", None) or "").strip()
|
||||||
|
if current_reason.startswith(OAUTH_REFRESH_FAILED_PREFIX):
|
||||||
|
return {
|
||||||
|
"oauth_invalid_at": getattr(key, "oauth_invalid_at", None),
|
||||||
|
"oauth_invalid_reason": current_reason,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"oauth_invalid_at": None,
|
||||||
|
"oauth_invalid_reason": None,
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
|||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||||
from src.services.provider.auth import get_provider_auth
|
from src.services.provider.auth import get_provider_auth
|
||||||
|
from src.services.provider_keys.quota_refresh._helpers import build_success_state_update
|
||||||
|
|
||||||
|
|
||||||
async def refresh_antigravity_key_quota(
|
async def refresh_antigravity_key_quota(
|
||||||
@@ -110,10 +111,7 @@ async def refresh_antigravity_key_quota(
|
|||||||
upstream_meta["antigravity"]["forbidden_reason"] = None
|
upstream_meta["antigravity"]["forbidden_reason"] = None
|
||||||
upstream_meta["antigravity"]["forbidden_at"] = None
|
upstream_meta["antigravity"]["forbidden_at"] = None
|
||||||
metadata_updates[key.id] = upstream_meta
|
metadata_updates[key.id] = upstream_meta
|
||||||
state_updates[key.id] = {
|
state_updates[key.id] = build_success_state_update(key)
|
||||||
"oauth_invalid_at": None,
|
|
||||||
"oauth_invalid_reason": None,
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
"key_name": key.name,
|
"key_name": key.name,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from src.services.provider_keys.codex_usage_parser import (
|
|||||||
parse_codex_usage_headers,
|
parse_codex_usage_headers,
|
||||||
parse_codex_wham_usage_response,
|
parse_codex_wham_usage_response,
|
||||||
)
|
)
|
||||||
|
from src.services.provider_keys.quota_refresh._helpers import build_success_state_update
|
||||||
|
|
||||||
|
|
||||||
def _normalize_plan_type(value: Any) -> str | None:
|
def _normalize_plan_type(value: Any) -> str | None:
|
||||||
@@ -277,10 +278,7 @@ async def refresh_codex_key_quota(
|
|||||||
metadata_updates[key.id] = {
|
metadata_updates[key.id] = {
|
||||||
"codex": _build_quota_exhausted_fallback_metadata(oauth_plan_type)
|
"codex": _build_quota_exhausted_fallback_metadata(oauth_plan_type)
|
||||||
}
|
}
|
||||||
state_updates[key.id] = {
|
state_updates[key.id] = build_success_state_update(key)
|
||||||
"oauth_invalid_at": None,
|
|
||||||
"oauth_invalid_reason": None,
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
"key_name": key.name,
|
"key_name": key.name,
|
||||||
@@ -348,10 +346,7 @@ async def refresh_codex_key_quota(
|
|||||||
if metadata:
|
if metadata:
|
||||||
# 收集元数据,稍后统一更新数据库(存储到 codex 子对象)
|
# 收集元数据,稍后统一更新数据库(存储到 codex 子对象)
|
||||||
metadata_updates[key.id] = {"codex": metadata}
|
metadata_updates[key.id] = {"codex": metadata}
|
||||||
state_updates[key.id] = {
|
state_updates[key.id] = build_success_state_update(key)
|
||||||
"oauth_invalid_at": None,
|
|
||||||
"oauth_invalid_reason": None,
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
"key_name": key.name,
|
"key_name": key.name,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
|||||||
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
|
||||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||||
|
from src.services.provider_keys.quota_refresh._helpers import build_success_state_update
|
||||||
|
|
||||||
|
|
||||||
async def refresh_kiro_key_quota(
|
async def refresh_kiro_key_quota(
|
||||||
@@ -132,10 +133,7 @@ async def refresh_kiro_key_quota(
|
|||||||
metadata["banned_at"] = None
|
metadata["banned_at"] = None
|
||||||
# 收集元数据,稍后统一更新数据库(存储到 kiro 子对象)
|
# 收集元数据,稍后统一更新数据库(存储到 kiro 子对象)
|
||||||
metadata_updates[key.id] = {"kiro": metadata}
|
metadata_updates[key.id] = {"kiro": metadata}
|
||||||
state_updates[key.id] = {
|
state_updates[key.id] = build_success_state_update(key)
|
||||||
"oauth_invalid_at": None,
|
|
||||||
"oauth_invalid_reason": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 如果 auth_config 有更新(例如 token 刷新),也需要更新
|
# 如果 auth_config 有更新(例如 token 刷新),也需要更新
|
||||||
if updated_auth_config:
|
if updated_auth_config:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Provider Key 响应对象构建器。
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from dataclasses import asdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
@@ -14,10 +14,17 @@ from src.core.provider_oauth_utils import normalize_oauth_organizations
|
|||||||
from src.models.database import ProviderAPIKey
|
from src.models.database import ProviderAPIKey
|
||||||
from src.models.endpoint_models import EndpointAPIKeyResponse
|
from src.models.endpoint_models import EndpointAPIKeyResponse
|
||||||
from src.services.provider_keys.auth_type import normalize_auth_type
|
from src.services.provider_keys.auth_type import normalize_auth_type
|
||||||
|
from src.services.provider_keys.status_snapshot_store import (
|
||||||
|
normalize_oauth_expires_at,
|
||||||
|
resolve_provider_key_status_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_key_response(
|
def build_key_response(
|
||||||
key: ProviderAPIKey, api_key_plain: str | None = None
|
key: ProviderAPIKey,
|
||||||
|
api_key_plain: str | None = None,
|
||||||
|
*,
|
||||||
|
provider_type: str | None = None,
|
||||||
) -> 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"))
|
||||||
@@ -40,9 +47,7 @@ def build_key_response(
|
|||||||
masked_key = "***ERROR***"
|
masked_key = "***ERROR***"
|
||||||
|
|
||||||
success_rate = success_count / request_count if 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 = total_response_time_ms / success_count if success_count > 0 else 0.0
|
||||||
total_response_time_ms / success_count if success_count > 0 else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
is_adaptive = rpm_limit is None
|
is_adaptive = rpm_limit is None
|
||||||
key_dict: dict[str, Any] = dict(getattr(key, "__dict__", {}))
|
key_dict: dict[str, Any] = dict(getattr(key, "__dict__", {}))
|
||||||
@@ -57,66 +62,61 @@ def build_key_response(
|
|||||||
oauth_account_id = None
|
oauth_account_id = None
|
||||||
oauth_account_name = None
|
oauth_account_name = None
|
||||||
oauth_account_user_id = None
|
oauth_account_user_id = None
|
||||||
|
auth_config: dict[str, Any] | None = 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 (
|
if auth_type == "oauth" and isinstance(encrypted_auth_config, str) and encrypted_auth_config:
|
||||||
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 = normalize_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(
|
oauth_plan_type = auth_config.get("plan_type") # Codex: plus/free/team/enterprise
|
||||||
"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(
|
oauth_account_id = auth_config.get("account_id") # Codex: chatgpt_account_id
|
||||||
"account_id"
|
|
||||||
) # Codex: chatgpt_account_id
|
|
||||||
oauth_account_name = auth_config.get("account_name")
|
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(
|
oauth_organizations = normalize_oauth_organizations(auth_config.get("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)
|
||||||
|
|
||||||
|
if not provider_type:
|
||||||
|
provider_rel = getattr(key, "provider", None)
|
||||||
|
provider_type = (
|
||||||
|
str(getattr(provider_rel, "provider_type", None) or "").strip()
|
||||||
|
or str(getattr(provider_rel, "type", None) or "").strip()
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
|
status_snapshot = resolve_provider_key_status_snapshot(
|
||||||
|
key,
|
||||||
|
provider_type=provider_type,
|
||||||
|
auth_config=auth_config,
|
||||||
|
oauth_expires_at=oauth_expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
# 从 health_by_format 计算汇总字段(便于列表展示)
|
# 从 health_by_format 计算汇总字段(便于列表展示)
|
||||||
raw_health_by_format = getattr(key, "health_by_format", None)
|
raw_health_by_format = getattr(key, "health_by_format", None)
|
||||||
health_by_format = (
|
health_by_format = raw_health_by_format if isinstance(raw_health_by_format, dict) else {}
|
||||||
raw_health_by_format if isinstance(raw_health_by_format, dict) else {}
|
|
||||||
)
|
|
||||||
raw_circuit_by_format = getattr(key, "circuit_breaker_by_format", None)
|
raw_circuit_by_format = getattr(key, "circuit_breaker_by_format", None)
|
||||||
circuit_by_format = (
|
circuit_by_format = raw_circuit_by_format if isinstance(raw_circuit_by_format, dict) else {}
|
||||||
raw_circuit_by_format if isinstance(raw_circuit_by_format, dict) else {}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 计算整体健康度(取所有格式中的最低值)
|
# 计算整体健康度(取所有格式中的最低值)
|
||||||
if health_by_format:
|
if health_by_format:
|
||||||
health_scores = [
|
health_scores = [float(h.get("health_score") or 1.0) for h in health_by_format.values()]
|
||||||
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")
|
h.get("last_failure_at") for h in health_by_format.values() if 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:
|
||||||
@@ -154,15 +154,9 @@ def build_key_response(
|
|||||||
"oauth_account_name": oauth_account_name,
|
"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": status_snapshot.oauth.invalid_at,
|
||||||
int(oauth_invalid_at.timestamp())
|
|
||||||
if isinstance(
|
|
||||||
(oauth_invalid_at := getattr(key, "oauth_invalid_at", None)),
|
|
||||||
datetime,
|
|
||||||
)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"oauth_invalid_reason": getattr(key, "oauth_invalid_reason", None),
|
"oauth_invalid_reason": getattr(key, "oauth_invalid_reason", None),
|
||||||
|
"status_snapshot": asdict(status_snapshot),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
293
src/services/provider_keys/status_snapshot_store.py
Normal file
293
src/services/provider_keys/status_snapshot_store.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
from src.models.database import Provider, ProviderAPIKey
|
||||||
|
from src.services.provider.pool.account_state import (
|
||||||
|
AccountStatusSnapshot,
|
||||||
|
OAuthStatusSnapshot,
|
||||||
|
ProviderKeyStatusSnapshot,
|
||||||
|
QuotaStatusSnapshot,
|
||||||
|
build_provider_key_status_snapshot,
|
||||||
|
resolve_oauth_status_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: Any) -> str | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool(value: Any) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value != 0
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "y"}
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value: Any) -> int | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float):
|
||||||
|
return int(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(text))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_float(value: Any) -> float | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_oauth_auth_config(key: ProviderAPIKey) -> dict[str, Any] | None:
|
||||||
|
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth_config_raw = getattr(key, "auth_config", None)
|
||||||
|
if not auth_config_raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted = crypto_service.decrypt(auth_config_raw)
|
||||||
|
if isinstance(decrypted, str) and decrypted.strip():
|
||||||
|
parsed = json.loads(decrypted)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_oauth_expires_at(raw: Any) -> int | None:
|
||||||
|
value = _coerce_float(raw)
|
||||||
|
if value is None or value <= 0:
|
||||||
|
return None
|
||||||
|
if value > 1_000_000_000_000:
|
||||||
|
value /= 1000
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def hydrate_provider_key_status_snapshot(raw: Any) -> ProviderKeyStatusSnapshot | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
oauth_raw = raw.get("oauth") if isinstance(raw.get("oauth"), dict) else {}
|
||||||
|
account_raw = raw.get("account") if isinstance(raw.get("account"), dict) else {}
|
||||||
|
quota_raw = raw.get("quota") if isinstance(raw.get("quota"), dict) else {}
|
||||||
|
|
||||||
|
return ProviderKeyStatusSnapshot(
|
||||||
|
oauth=OAuthStatusSnapshot(
|
||||||
|
code=_clean_text(oauth_raw.get("code")) or "none",
|
||||||
|
label=_clean_text(oauth_raw.get("label")),
|
||||||
|
reason=_clean_text(oauth_raw.get("reason")),
|
||||||
|
expires_at=_coerce_int(oauth_raw.get("expires_at")),
|
||||||
|
invalid_at=_coerce_int(oauth_raw.get("invalid_at")),
|
||||||
|
source=_clean_text(oauth_raw.get("source")),
|
||||||
|
requires_reauth=_coerce_bool(oauth_raw.get("requires_reauth")),
|
||||||
|
expiring_soon=_coerce_bool(oauth_raw.get("expiring_soon")),
|
||||||
|
),
|
||||||
|
account=AccountStatusSnapshot(
|
||||||
|
code=_clean_text(account_raw.get("code")) or "ok",
|
||||||
|
label=_clean_text(account_raw.get("label")),
|
||||||
|
reason=_clean_text(account_raw.get("reason")),
|
||||||
|
blocked=_coerce_bool(account_raw.get("blocked")),
|
||||||
|
source=_clean_text(account_raw.get("source")),
|
||||||
|
recoverable=_coerce_bool(account_raw.get("recoverable")),
|
||||||
|
),
|
||||||
|
quota=QuotaStatusSnapshot(
|
||||||
|
code=_clean_text(quota_raw.get("code")) or "unknown",
|
||||||
|
label=_clean_text(quota_raw.get("label")),
|
||||||
|
reason=_clean_text(quota_raw.get("reason")),
|
||||||
|
exhausted=_coerce_bool(quota_raw.get("exhausted")),
|
||||||
|
usage_ratio=_coerce_float(quota_raw.get("usage_ratio")),
|
||||||
|
updated_at=_coerce_int(quota_raw.get("updated_at")),
|
||||||
|
reset_seconds=_coerce_float(quota_raw.get("reset_seconds")),
|
||||||
|
plan_type=_clean_text(quota_raw.get("plan_type")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_provider_type_for_key(
|
||||||
|
key: ProviderAPIKey,
|
||||||
|
*,
|
||||||
|
provider_type: str | None = None,
|
||||||
|
connection: Connection | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
normalized = _clean_text(provider_type)
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
provider_rel = getattr(key, "__dict__", {}).get("provider")
|
||||||
|
rel_type = _clean_text(getattr(provider_rel, "provider_type", None)) or _clean_text(
|
||||||
|
getattr(provider_rel, "type", None)
|
||||||
|
)
|
||||||
|
if rel_type:
|
||||||
|
return rel_type
|
||||||
|
|
||||||
|
provider_id = _clean_text(getattr(key, "provider_id", None))
|
||||||
|
if provider_id and connection is not None:
|
||||||
|
result = connection.execute(
|
||||||
|
select(Provider.provider_type).where(Provider.id == provider_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return _clean_text(result)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def derive_oauth_expires_at(
|
||||||
|
key: ProviderAPIKey,
|
||||||
|
*,
|
||||||
|
auth_config: dict[str, Any] | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
|
||||||
|
return None
|
||||||
|
|
||||||
|
cfg = auth_config if isinstance(auth_config, dict) else extract_oauth_auth_config(key)
|
||||||
|
if cfg:
|
||||||
|
for field in ("expires_at", "expiresAt", "expiry", "exp"):
|
||||||
|
expires_at = normalize_oauth_expires_at(cfg.get(field))
|
||||||
|
if expires_at is not None:
|
||||||
|
return expires_at
|
||||||
|
|
||||||
|
expires_dt = getattr(key, "expires_at", None)
|
||||||
|
if isinstance(expires_dt, datetime):
|
||||||
|
return int(expires_dt.timestamp())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_provider_key_status_snapshot(
|
||||||
|
key: ProviderAPIKey,
|
||||||
|
*,
|
||||||
|
provider_type: str | None = None,
|
||||||
|
connection: Connection | None = None,
|
||||||
|
auth_config: dict[str, Any] | None = None,
|
||||||
|
oauth_expires_at: int | None = None,
|
||||||
|
now_ts: int | None = None,
|
||||||
|
) -> ProviderKeyStatusSnapshot:
|
||||||
|
persisted_snapshot = hydrate_provider_key_status_snapshot(getattr(key, "status_snapshot", None))
|
||||||
|
current_snapshot = _build_snapshot_from_current_fields(
|
||||||
|
key,
|
||||||
|
provider_type=provider_type,
|
||||||
|
connection=connection,
|
||||||
|
auth_config=auth_config,
|
||||||
|
oauth_expires_at=oauth_expires_at,
|
||||||
|
now_ts=now_ts,
|
||||||
|
)
|
||||||
|
if persisted_snapshot is None:
|
||||||
|
return current_snapshot
|
||||||
|
|
||||||
|
resolved_oauth_expires_at = current_snapshot.oauth.expires_at
|
||||||
|
if resolved_oauth_expires_at is None and persisted_snapshot.oauth.expires_at is not None:
|
||||||
|
resolved_oauth_expires_at = int(persisted_snapshot.oauth.expires_at)
|
||||||
|
resolved_oauth_invalid_at = current_snapshot.oauth.invalid_at
|
||||||
|
if resolved_oauth_invalid_at is None and persisted_snapshot.oauth.invalid_at is not None:
|
||||||
|
resolved_oauth_invalid_at = int(persisted_snapshot.oauth.invalid_at)
|
||||||
|
oauth_invalid_reason = _clean_text(getattr(key, "oauth_invalid_reason", None)) or (
|
||||||
|
persisted_snapshot.oauth.reason if persisted_snapshot is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProviderKeyStatusSnapshot(
|
||||||
|
oauth=resolve_oauth_status_snapshot(
|
||||||
|
auth_type=str(getattr(key, "auth_type", "api_key") or "api_key"),
|
||||||
|
oauth_expires_at=resolved_oauth_expires_at,
|
||||||
|
oauth_invalid_at=resolved_oauth_invalid_at,
|
||||||
|
oauth_invalid_reason=oauth_invalid_reason,
|
||||||
|
now_ts=now_ts,
|
||||||
|
),
|
||||||
|
account=persisted_snapshot.account,
|
||||||
|
quota=persisted_snapshot.quota,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_snapshot_from_current_fields(
|
||||||
|
key: ProviderAPIKey,
|
||||||
|
*,
|
||||||
|
provider_type: str | None = None,
|
||||||
|
connection: Connection | None = None,
|
||||||
|
auth_config: dict[str, Any] | None = None,
|
||||||
|
oauth_expires_at: int | None = None,
|
||||||
|
now_ts: int | None = None,
|
||||||
|
) -> ProviderKeyStatusSnapshot:
|
||||||
|
resolved_provider_type = resolve_provider_type_for_key(
|
||||||
|
key, provider_type=provider_type, connection=connection
|
||||||
|
)
|
||||||
|
oauth_auth_config = (
|
||||||
|
auth_config if isinstance(auth_config, dict) else extract_oauth_auth_config(key)
|
||||||
|
)
|
||||||
|
normalized_oauth_expires_at = normalize_oauth_expires_at(oauth_expires_at)
|
||||||
|
resolved_oauth_expires_at = (
|
||||||
|
normalized_oauth_expires_at
|
||||||
|
if normalized_oauth_expires_at is not None
|
||||||
|
else derive_oauth_expires_at(
|
||||||
|
key,
|
||||||
|
auth_config=oauth_auth_config,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raw_invalid_at = getattr(key, "oauth_invalid_at", None)
|
||||||
|
oauth_invalid_at = (
|
||||||
|
int(raw_invalid_at.timestamp()) if isinstance(raw_invalid_at, datetime) else None
|
||||||
|
)
|
||||||
|
oauth_invalid_reason = _clean_text(getattr(key, "oauth_invalid_reason", None))
|
||||||
|
|
||||||
|
return build_provider_key_status_snapshot(
|
||||||
|
auth_type=str(getattr(key, "auth_type", "api_key") or "api_key"),
|
||||||
|
oauth_expires_at=resolved_oauth_expires_at,
|
||||||
|
oauth_invalid_at=oauth_invalid_at,
|
||||||
|
oauth_invalid_reason=oauth_invalid_reason,
|
||||||
|
provider_type=resolved_provider_type,
|
||||||
|
upstream_metadata=getattr(key, "upstream_metadata", None),
|
||||||
|
now_ts=now_ts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_provider_key_status_snapshot(
|
||||||
|
key: ProviderAPIKey,
|
||||||
|
*,
|
||||||
|
provider_type: str | None = None,
|
||||||
|
connection: Connection | None = None,
|
||||||
|
auth_config: dict[str, Any] | None = None,
|
||||||
|
oauth_expires_at: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
snapshot = _build_snapshot_from_current_fields(
|
||||||
|
key,
|
||||||
|
provider_type=provider_type,
|
||||||
|
connection=connection,
|
||||||
|
auth_config=auth_config,
|
||||||
|
oauth_expires_at=oauth_expires_at,
|
||||||
|
)
|
||||||
|
snapshot_dict = asdict(snapshot)
|
||||||
|
key.status_snapshot = snapshot_dict
|
||||||
|
return snapshot_dict
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src.services.provider.pool.account_state import (
|
from src.services.provider.pool.account_state import (
|
||||||
|
build_provider_key_status_snapshot,
|
||||||
resolve_pool_account_state,
|
resolve_pool_account_state,
|
||||||
should_auto_remove_account_state,
|
should_auto_remove_account_state,
|
||||||
)
|
)
|
||||||
@@ -192,3 +193,68 @@ def test_auto_remove_state_excludes_token_expired_and_verification() -> None:
|
|||||||
assert should_auto_remove_account_state(expired) is False
|
assert should_auto_remove_account_state(expired) is False
|
||||||
assert should_auto_remove_account_state(verification) is False
|
assert should_auto_remove_account_state(verification) is False
|
||||||
assert should_auto_remove_account_state(disabled) is True
|
assert should_auto_remove_account_state(disabled) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_provider_key_status_snapshot_separates_account_block_from_oauth_state() -> None:
|
||||||
|
snapshot = build_provider_key_status_snapshot(
|
||||||
|
auth_type="oauth",
|
||||||
|
oauth_expires_at=2_000_000_000,
|
||||||
|
oauth_invalid_at=1_900_000_000,
|
||||||
|
oauth_invalid_reason="[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)",
|
||||||
|
provider_type="codex",
|
||||||
|
upstream_metadata=None,
|
||||||
|
now_ts=1_800_000_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.account.blocked is True
|
||||||
|
assert snapshot.account.code == "workspace_deactivated"
|
||||||
|
assert snapshot.account.label == "工作区停用"
|
||||||
|
assert snapshot.oauth.code == "valid"
|
||||||
|
assert snapshot.oauth.requires_reauth is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_provider_key_status_snapshot_keeps_refresh_failure_visible_beside_account_block() -> (
|
||||||
|
None
|
||||||
|
):
|
||||||
|
snapshot = build_provider_key_status_snapshot(
|
||||||
|
auth_type="oauth",
|
||||||
|
oauth_expires_at=2_000_000_000,
|
||||||
|
oauth_invalid_at=1_900_000_000,
|
||||||
|
oauth_invalid_reason=(
|
||||||
|
"[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n"
|
||||||
|
"[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||||
|
),
|
||||||
|
provider_type="codex",
|
||||||
|
upstream_metadata=None,
|
||||||
|
now_ts=1_800_000_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.account.blocked is True
|
||||||
|
assert snapshot.account.code == "workspace_deactivated"
|
||||||
|
assert snapshot.oauth.code == "invalid"
|
||||||
|
assert snapshot.oauth.label == "已失效"
|
||||||
|
assert snapshot.oauth.reason == "Token 续期失败 (400): refresh_token_reused"
|
||||||
|
assert snapshot.oauth.requires_reauth is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_provider_key_status_snapshot_marks_quota_exhausted() -> None:
|
||||||
|
snapshot = build_provider_key_status_snapshot(
|
||||||
|
auth_type="oauth",
|
||||||
|
oauth_expires_at=2_000_000_000,
|
||||||
|
oauth_invalid_at=None,
|
||||||
|
oauth_invalid_reason=None,
|
||||||
|
provider_type="codex",
|
||||||
|
upstream_metadata={
|
||||||
|
"codex": {
|
||||||
|
"primary_used_percent": 100.0,
|
||||||
|
"secondary_used_percent": 20.0,
|
||||||
|
"updated_at": 1_800_000_000,
|
||||||
|
"plan_type": "team",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
now_ts=1_800_000_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.quota.code == "exhausted"
|
||||||
|
assert snapshot.quota.exhausted is True
|
||||||
|
assert snapshot.quota.reason == "Codex 周限额剩余 0%"
|
||||||
|
|||||||
@@ -263,6 +263,141 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
|
|||||||
assert state_updates["k1"]["oauth_invalid_reason"] is None
|
assert state_updates["k1"]["oauth_invalid_reason"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_refresher_success_preserves_refresh_failed_marker(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from src.services.provider_keys.quota_refresh import codex_refresher as module
|
||||||
|
|
||||||
|
key = SimpleNamespace(
|
||||||
|
id="k1",
|
||||||
|
name="K1",
|
||||||
|
api_key="enc-key",
|
||||||
|
auth_type="oauth",
|
||||||
|
auth_config="enc-config",
|
||||||
|
proxy=None,
|
||||||
|
oauth_invalid_at="sentinel-invalid-at",
|
||||||
|
oauth_invalid_reason="[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused",
|
||||||
|
)
|
||||||
|
provider = SimpleNamespace(proxy=None)
|
||||||
|
endpoint = SimpleNamespace()
|
||||||
|
metadata_updates: dict[str, dict[str, Any]] = {}
|
||||||
|
state_updates: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_install_module(
|
||||||
|
monkeypatch,
|
||||||
|
"src.services.proxy_node.resolver",
|
||||||
|
{
|
||||||
|
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||||
|
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.crypto_service,
|
||||||
|
"decrypt",
|
||||||
|
lambda value: (
|
||||||
|
"sk-test"
|
||||||
|
if value == "enc-key"
|
||||||
|
else json.dumps({"plan_type": "team", "account_id": "acc-1"})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module, "parse_codex_wham_usage_response", lambda _data: {"used_percent": 10.0}
|
||||||
|
)
|
||||||
|
response = _FakeResponse(status_code=200, payload={"ok": True})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await refresh_codex_key_quota(
|
||||||
|
db=cast(Any, _FakeDB()),
|
||||||
|
provider=cast(Any, provider),
|
||||||
|
key=cast(Any, key),
|
||||||
|
endpoint=cast(Any, endpoint),
|
||||||
|
codex_wham_usage_url="https://example.test",
|
||||||
|
metadata_updates=metadata_updates,
|
||||||
|
state_updates=state_updates,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert state_updates["k1"]["oauth_invalid_at"] == "sentinel-invalid-at"
|
||||||
|
assert (
|
||||||
|
state_updates["k1"]["oauth_invalid_reason"]
|
||||||
|
== "[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_refresher_quota_exhausted_preserves_refresh_failed_marker(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from src.services.provider_keys.quota_refresh import codex_refresher as module
|
||||||
|
|
||||||
|
key = SimpleNamespace(
|
||||||
|
id="k1",
|
||||||
|
name="K1",
|
||||||
|
api_key="enc-key",
|
||||||
|
auth_type="oauth",
|
||||||
|
auth_config="enc-config",
|
||||||
|
proxy=None,
|
||||||
|
oauth_invalid_at="sentinel-invalid-at",
|
||||||
|
oauth_invalid_reason="[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused",
|
||||||
|
)
|
||||||
|
provider = SimpleNamespace(proxy=None)
|
||||||
|
endpoint = SimpleNamespace()
|
||||||
|
metadata_updates: dict[str, dict[str, Any]] = {}
|
||||||
|
state_updates: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_install_module(
|
||||||
|
monkeypatch,
|
||||||
|
"src.services.proxy_node.resolver",
|
||||||
|
{
|
||||||
|
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||||
|
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.crypto_service,
|
||||||
|
"decrypt",
|
||||||
|
lambda value: (
|
||||||
|
"sk-test"
|
||||||
|
if value == "enc-key"
|
||||||
|
else json.dumps({"plan_type": "team", "account_id": "acc-1"})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await refresh_codex_key_quota(
|
||||||
|
db=cast(Any, _FakeDB()),
|
||||||
|
provider=cast(Any, provider),
|
||||||
|
key=cast(Any, key),
|
||||||
|
endpoint=cast(Any, endpoint),
|
||||||
|
codex_wham_usage_url="https://example.test",
|
||||||
|
metadata_updates=metadata_updates,
|
||||||
|
state_updates=state_updates,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "quota_exhausted"
|
||||||
|
assert state_updates["k1"]["oauth_invalid_at"] == "sentinel-invalid-at"
|
||||||
|
assert (
|
||||||
|
state_updates["k1"]["oauth_invalid_reason"]
|
||||||
|
== "[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||||
|
)
|
||||||
|
codex_meta = metadata_updates["k1"]["codex"]
|
||||||
|
assert codex_meta["secondary_used_percent"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
|
async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def _detail(
|
|||||||
circuit_breaker_open: bool = False,
|
circuit_breaker_open: bool = False,
|
||||||
cost_limit: int | None = None,
|
cost_limit: int | None = None,
|
||||||
cost_window_usage: int = 0,
|
cost_window_usage: int = 0,
|
||||||
|
status_snapshot: dict | None = None,
|
||||||
) -> PoolKeyDetail:
|
) -> PoolKeyDetail:
|
||||||
return PoolKeyDetail(
|
return PoolKeyDetail(
|
||||||
key_id=key_id,
|
key_id=key_id,
|
||||||
@@ -39,6 +40,12 @@ def _detail(
|
|||||||
circuit_breaker_open=circuit_breaker_open,
|
circuit_breaker_open=circuit_breaker_open,
|
||||||
cost_limit=cost_limit,
|
cost_limit=cost_limit,
|
||||||
cost_window_usage=cost_window_usage,
|
cost_window_usage=cost_window_usage,
|
||||||
|
status_snapshot=status_snapshot # type: ignore[arg-type]
|
||||||
|
or {
|
||||||
|
"oauth": {"code": "none"},
|
||||||
|
"account": {"code": "ok", "blocked": False},
|
||||||
|
"quota": {"code": "unknown", "exhausted": False},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -106,3 +113,58 @@ def test_detail_is_oauth_invalid_accepts_refresh_failed_state() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert _detail_is_oauth_invalid(detail) is True
|
assert _detail_is_oauth_invalid(detail) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_is_oauth_invalid_uses_status_snapshot_without_legacy_fields() -> None:
|
||||||
|
detail = _detail(
|
||||||
|
"snapshot-invalid",
|
||||||
|
auth_type="oauth",
|
||||||
|
oauth_invalid_at=None,
|
||||||
|
oauth_invalid_reason=None,
|
||||||
|
account_status_blocked=False,
|
||||||
|
account_status_code=None,
|
||||||
|
account_status_label=None,
|
||||||
|
status_snapshot={
|
||||||
|
"oauth": {
|
||||||
|
"code": "invalid",
|
||||||
|
"label": "已失效",
|
||||||
|
"reason": "refresh_token_reused",
|
||||||
|
"requires_reauth": True,
|
||||||
|
},
|
||||||
|
"account": {"code": "workspace_deactivated", "label": "工作区停用", "blocked": True},
|
||||||
|
"quota": {"code": "ok", "exhausted": False},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _detail_is_oauth_invalid(detail) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_pool_key_details_require_schedulable_uses_status_snapshot_account_block() -> None:
|
||||||
|
details = [
|
||||||
|
_detail(
|
||||||
|
"snapshot-blocked",
|
||||||
|
scheduling_status="",
|
||||||
|
is_active=True,
|
||||||
|
account_status_blocked=False,
|
||||||
|
status_snapshot={
|
||||||
|
"oauth": {"code": "valid"},
|
||||||
|
"account": {"code": "account_disabled", "label": "账号停用", "blocked": True},
|
||||||
|
"quota": {"code": "ok", "exhausted": False},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_detail(
|
||||||
|
"snapshot-ok",
|
||||||
|
scheduling_status="",
|
||||||
|
is_active=True,
|
||||||
|
account_status_blocked=False,
|
||||||
|
status_snapshot={
|
||||||
|
"oauth": {"code": "valid"},
|
||||||
|
"account": {"code": "ok", "blocked": False},
|
||||||
|
"quota": {"code": "ok", "exhausted": False},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
filtered = _filter_pool_key_details(details, require_schedulable=True)
|
||||||
|
|
||||||
|
assert [item.key_id for item in filtered] == ["snapshot-ok"]
|
||||||
|
|||||||
@@ -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_name":"Workspace Alpha","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":2100000000}',
|
||||||
name="codex-user",
|
name="codex-user",
|
||||||
)
|
)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -54,3 +54,63 @@ def test_build_key_response_includes_codex_identity_metadata(
|
|||||||
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"
|
||||||
assert result.oauth_organizations[0].is_default is True
|
assert result.oauth_organizations[0].is_default is True
|
||||||
|
assert result.status_snapshot.oauth.code == "valid"
|
||||||
|
assert result.status_snapshot.oauth.expires_at == 2100000000
|
||||||
|
assert result.status_snapshot.account.code == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_key_response_prefers_persisted_status_snapshot_layers(
|
||||||
|
monkeypatch: "pytest.MonkeyPatch",
|
||||||
|
) -> None:
|
||||||
|
key = ProviderAPIKey(
|
||||||
|
id="key-2",
|
||||||
|
provider_id="provider-1",
|
||||||
|
api_formats=["openai:chat"],
|
||||||
|
auth_type="oauth",
|
||||||
|
api_key="enc-access-token",
|
||||||
|
auth_config='{"expires_at":100}',
|
||||||
|
name="codex-user",
|
||||||
|
status_snapshot={
|
||||||
|
"oauth": {"code": "valid", "label": "有效", "expires_at": 100},
|
||||||
|
"account": {
|
||||||
|
"code": "workspace_deactivated",
|
||||||
|
"label": "工作区停用",
|
||||||
|
"reason": "persisted",
|
||||||
|
"blocked": True,
|
||||||
|
},
|
||||||
|
"quota": {
|
||||||
|
"code": "exhausted",
|
||||||
|
"label": "额度耗尽",
|
||||||
|
"reason": "persisted quota",
|
||||||
|
"exhausted": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
key.success_count = 0
|
||||||
|
key.request_count = 0
|
||||||
|
key.error_count = 0
|
||||||
|
key.total_response_time_ms = 0
|
||||||
|
key.rpm_limit = None
|
||||||
|
key.global_priority_by_format = None
|
||||||
|
key.allowed_models = None
|
||||||
|
key.capabilities = None
|
||||||
|
key.is_active = True
|
||||||
|
key.created_at = now
|
||||||
|
key.updated_at = now
|
||||||
|
key.cache_ttl_minutes = 5
|
||||||
|
key.max_probe_interval_minutes = 32
|
||||||
|
key.health_by_format = None
|
||||||
|
key.circuit_breaker_by_format = None
|
||||||
|
key.oauth_invalid_at = None
|
||||||
|
key.oauth_invalid_reason = None
|
||||||
|
key.note = None
|
||||||
|
key.last_used_at = None
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value: value)
|
||||||
|
|
||||||
|
result = build_key_response(key)
|
||||||
|
|
||||||
|
assert result.status_snapshot.oauth.code == "expired"
|
||||||
|
assert result.status_snapshot.account.code == "workspace_deactivated"
|
||||||
|
assert result.status_snapshot.quota.code == "exhausted"
|
||||||
|
|||||||
96
tests/unit/test_provider_key_status_snapshot_store.py
Normal file
96
tests/unit/test_provider_key_status_snapshot_store.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.models.database import Provider, ProviderAPIKey, _provider_api_key_before_insert
|
||||||
|
from src.services.provider_keys import status_snapshot_store as module
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_api_key_before_insert_populates_status_snapshot(
|
||||||
|
monkeypatch: "pytest.MonkeyPatch",
|
||||||
|
) -> None:
|
||||||
|
provider = Provider(
|
||||||
|
id="provider-1",
|
||||||
|
name="Codex Pool",
|
||||||
|
provider_type="codex",
|
||||||
|
)
|
||||||
|
key = ProviderAPIKey(
|
||||||
|
id="key-1",
|
||||||
|
provider_id="provider-1",
|
||||||
|
provider=provider, # type: ignore[arg-type]
|
||||||
|
api_key="enc-access-token",
|
||||||
|
auth_type="oauth",
|
||||||
|
auth_config='{"expires_at":2100000000}',
|
||||||
|
name="codex-user",
|
||||||
|
oauth_invalid_reason=(
|
||||||
|
"[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n"
|
||||||
|
"[REFRESH_FAILED] refresh_token_reused"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value: value)
|
||||||
|
|
||||||
|
_provider_api_key_before_insert(None, None, key)
|
||||||
|
|
||||||
|
assert isinstance(key.status_snapshot, dict)
|
||||||
|
assert key.status_snapshot["oauth"]["code"] == "invalid"
|
||||||
|
assert key.status_snapshot["oauth"]["label"] == "已失效"
|
||||||
|
assert key.status_snapshot["oauth"]["reason"] == "refresh_token_reused"
|
||||||
|
assert key.status_snapshot["account"]["code"] == "workspace_deactivated"
|
||||||
|
assert key.status_snapshot["account"]["blocked"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_provider_key_status_snapshot_prefers_persisted_snapshot_layers(
|
||||||
|
monkeypatch: "pytest.MonkeyPatch",
|
||||||
|
) -> None:
|
||||||
|
provider = Provider(
|
||||||
|
id="provider-1",
|
||||||
|
name="Codex Pool",
|
||||||
|
provider_type="codex",
|
||||||
|
)
|
||||||
|
key = ProviderAPIKey(
|
||||||
|
id="key-2",
|
||||||
|
provider_id="provider-1",
|
||||||
|
provider=provider, # type: ignore[arg-type]
|
||||||
|
api_key="enc-access-token",
|
||||||
|
auth_type="oauth",
|
||||||
|
auth_config='{"expires_at":100}',
|
||||||
|
name="codex-user",
|
||||||
|
upstream_metadata=None,
|
||||||
|
oauth_invalid_reason=None,
|
||||||
|
status_snapshot={
|
||||||
|
"oauth": {
|
||||||
|
"code": "valid",
|
||||||
|
"label": "有效",
|
||||||
|
"expires_at": 100,
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"code": "workspace_deactivated",
|
||||||
|
"label": "工作区停用",
|
||||||
|
"reason": "persisted",
|
||||||
|
"blocked": True,
|
||||||
|
"source": "persisted",
|
||||||
|
},
|
||||||
|
"quota": {
|
||||||
|
"code": "exhausted",
|
||||||
|
"label": "额度耗尽",
|
||||||
|
"reason": "persisted quota",
|
||||||
|
"exhausted": True,
|
||||||
|
"usage_ratio": 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value: value)
|
||||||
|
|
||||||
|
snapshot = module.resolve_provider_key_status_snapshot(
|
||||||
|
key,
|
||||||
|
now_ts=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.oauth.code == "expired"
|
||||||
|
assert snapshot.oauth.label == "已过期"
|
||||||
|
assert snapshot.account.code == "workspace_deactivated"
|
||||||
|
assert snapshot.account.reason == "persisted"
|
||||||
|
assert snapshot.quota.code == "exhausted"
|
||||||
|
assert snapshot.quota.reason == "persisted quota"
|
||||||
56
tests/unit/test_provider_oauth_refresh_error.py
Normal file
56
tests/unit/test_provider_oauth_refresh_error.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.api.admin import provider_oauth as module
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_oauth_refresh_error_reason_for_reused_refresh_token() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
400,
|
||||||
|
json={
|
||||||
|
"error": {
|
||||||
|
"message": (
|
||||||
|
"Your refresh token has already been used to generate a new access token. "
|
||||||
|
"Please try signing in again."
|
||||||
|
),
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"param": None,
|
||||||
|
"code": "refresh_token_reused",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
request=httpx.Request("POST", "https://example.com/oauth/token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
module._extract_oauth_refresh_error_reason(response)
|
||||||
|
== "refresh_token 已被使用并轮换,请重新登录授权"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_oauth_refresh_error_reason_prefers_nested_message() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
401,
|
||||||
|
json={
|
||||||
|
"error": {
|
||||||
|
"message": "refresh token expired",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
request=httpx.Request("POST", "https://example.com/oauth/token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
module._extract_oauth_refresh_error_reason(response)
|
||||||
|
== "refresh_token 无效、已过期或已撤销,请重新登录授权"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_refresh_failure_reason_keeps_account_block_and_appends_refresh_failure() -> None:
|
||||||
|
current_reason = "[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)"
|
||||||
|
refresh_reason = "[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||||
|
|
||||||
|
assert module._merge_refresh_failure_reason(current_reason, refresh_reason) == (
|
||||||
|
"[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n"
|
||||||
|
"[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user