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:
fawney19
2026-03-20 19:16:52 +08:00
parent 25d38ae632
commit 46737d32f8
39 changed files with 2326 additions and 470 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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 级别代理)

View 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
}

View File

@@ -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
} }
} }

View File

@@ -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 {

View File

@@ -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)
}
// Antigravitytoken 刷新后可能完成了账号激活,触发配额获取 // Antigravitytoken 刷新后可能完成了账号激活,触发配额获取
// (不 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}`
} }
// 健康度颜色 // 健康度颜色

View File

@@ -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' }

View 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)')
})
})

View 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 已被使用并轮换,请重新登录授权',
)
})
})

View 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',
})
})
})

View 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 刷新成功,已重新检查额度/账号状态',
})
})
})

View 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 剩余有效期:')
})
})

View File

@@ -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 {

View File

@@ -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 {

View 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 刷新成功',
}
}

View 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
}

View File

@@ -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}`

View 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())

View File

@@ -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,

View File

@@ -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

View File

@@ -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 "")

View File

@@ -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

View File

@@ -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):

View 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)

View File

@@ -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",
] ]

View File

@@ -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(

View 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,
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

View File

@@ -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),
} }
) )

View 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

View File

@@ -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%"

View File

@@ -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,

View File

@@ -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"]

View File

@@ -18,7 +18,7 @@ def test_build_key_response_includes_codex_identity_metadata(
api_formats=["openai:chat"], api_formats=["openai:chat"],
auth_type="oauth", auth_type="oauth",
api_key="enc-access-token", api_key="enc-access-token",
auth_config='{"email":"u@example.com","plan_type":"team","account_id":"acc-1","account_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"

View 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"

View 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"
)