fix(kiro,pool,model): 对齐 Kiro 管理链路并修复全局模型删除行为 (#305)

* feat(pool): 号池支持跳过额度耗尽账号

- 新增 pool_advanced.skip_exhausted_accounts 开关及高级设置 UI, 默认关闭并兼容旧配置
- 为 Codex/Kiro 增加额度耗尽判定, 接入请求侧候选跳过并新增 account_quota_exhausted skip reason
- 号池列表将额度耗尽账号标记为 blocked/额度耗尽, 并补充前后端相关测试

* fix(kiro): 对齐账号管理与 provider-query 的 Rust 行为

- 修复 Kiro 单条导入误走 import-refresh-token 的前端分流, 并为误用路径返回明确错误提示
- 为 Kiro 导入与本地请求链补齐 bearer 兼容, 同步放开账号启停等 Key 更新操作的 auth_type 校验
- 实现 Kiro provider-query 本地模型测试与 failover 执行链, 并修复结果弹窗在无 trace 时无法展示 attempts/响应体的问题

* fix(model): 删除全局模型时级联清理关联提供商模型

- 对齐 Python 版本删除逻辑, GlobalModel 删除前先在事务内清理关联的 Provider Model 记录
- 修复已绑定 Provider 的模型在 Rust SQL 仓库下会被外键约束拦住、无法正常删除的问题
- 增加管理端回归测试, 覆盖绑定 Provider Model 的 GlobalModel 删除场景

* fix(kiro,ci): 恢复 Kiro OAuth 持久化并修复 Rust CI

* Fix oauth-managed provider key semantics

---------

Co-authored-by: fawney19 <elky0401@gmail.com>
This commit is contained in:
Entropy.Xu
2026-04-17 12:57:06 +08:00
committed by GitHub
parent 96a25d058b
commit ac1a126756
43 changed files with 2909 additions and 168 deletions

View File

@@ -60,7 +60,7 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
* 获取完整的 API Key用于查看和复制
*/
export interface RevealKeyResult {
auth_type: 'api_key' | 'service_account' | 'oauth'
auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer'
api_key?: string
refresh_token?: string
auth_config?: string | Record<string, unknown>
@@ -165,7 +165,7 @@ export async function updateProviderKey(
data: Partial<{
api_formats: string[] // 支持的 API 格式列表
api_key: string
auth_type: 'api_key' | 'service_account' | 'oauth' // 认证类型
auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型
auth_config: Record<string, unknown> // 认证配置Vertex AI Service Account JSON
name: string
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率

View File

@@ -104,6 +104,12 @@ export interface PoolKeyDetail {
key_name: string
is_active: boolean
auth_type: string
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'unknown' | string | null
oauth_managed?: boolean
can_refresh_oauth?: boolean
can_export_oauth?: boolean
can_edit_oauth?: boolean
oauth_expires_at?: number | null
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
@@ -200,6 +206,12 @@ export interface PoolKeySelectionItem {
key_id: string
key_name: string
auth_type: string
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'unknown' | string | null
oauth_managed?: boolean
can_refresh_oauth?: boolean
can_export_oauth?: boolean
can_edit_oauth?: boolean
}
export interface PoolKeySelectionResponse {

View File

@@ -229,7 +229,13 @@ export interface EndpointAPIKey {
api_formats: string[] // 支持的 endpoint signature 列表(如 "openai:chat"
api_key_masked: string
api_key_plain?: string | null
auth_type: 'api_key' | 'service_account' | 'oauth' // 认证类型(必返回)
auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型(必返回)
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'unknown' | string | null
oauth_managed?: boolean
can_refresh_oauth?: boolean
can_export_oauth?: boolean
can_edit_oauth?: boolean
name: string // 密钥名称(必填,用于识别)
rate_multipliers?: Record<string, number> | null // 按 endpoint signature 的成本倍率
internal_priority: number // Key 内部优先级
@@ -492,6 +498,7 @@ export interface PoolAdvancedConfig {
global_priority?: number | null
sticky_session_ttl_seconds?: number | null
load_threshold_percent?: number | null
skip_exhausted_accounts?: boolean | null
// 旧字段(兼容读取)
lru_enabled?: boolean
scheduling_mode?: 'lru' | 'multi_score' | null

View File

@@ -127,7 +127,7 @@
<Badge
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
>{{ normalizeAuthTypeLabel(key) }}</Badge>
<Badge
v-if="getStatusBadgeLabel(key)"
variant="destructive"
@@ -289,6 +289,11 @@ import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import {
canExportOAuthCredential,
canRefreshOAuthCredential,
getProviderAuthLabel,
} from '@/utils/providerKeyAuth'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
@@ -416,10 +421,6 @@ const isCurrentPageFullySelected = computed(() => {
const canClearSelection = computed(() => selectAllFiltered.value || selectedKeyIds.value.length > 0)
const activeQuickSelectorSet = computed(() => new Set(activeQuickSelectors.value))
function normalizeText(value: unknown): string {
return String(value || '').trim().toLowerCase()
}
function sanitizeFileNamePart(value: unknown, fallback: string): string {
const sanitized = String(value || '')
.trim()
@@ -452,11 +453,8 @@ function downloadJsonFile(data: unknown, filename: string): void {
URL.revokeObjectURL(url)
}
function normalizeAuthTypeLabel(authType: string): string {
const text = normalizeText(authType)
if (text === 'oauth') return 'OAuth'
if (text === 'service_account') return 'Service'
return 'API Key'
function normalizeAuthTypeLabel(key: PoolKeyDetail | PoolKeySelectionItem): string {
return getProviderAuthLabel(key)
}
function getStatusBadgeLabel(key: PoolKeyDetail): string | null {
@@ -741,6 +739,12 @@ async function resolveSelectedItems(): Promise<PoolKeySelectionItem[]> {
key_id: keyId,
key_name: key?.key_name || '',
auth_type: key?.auth_type || 'api_key',
credential_kind: key?.credential_kind,
runtime_auth_kind: key?.runtime_auth_kind,
oauth_managed: key?.oauth_managed,
can_refresh_oauth: key?.can_refresh_oauth,
can_export_oauth: key?.can_export_oauth,
can_edit_oauth: key?.can_edit_oauth,
}
})
}
@@ -810,7 +814,7 @@ async function executeAction(actionOverride?: BatchActionValue): Promise<void> {
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
}
} else if (selectedAction.value === 'export') {
const exportableKeys = selectedKeys.filter((key) => normalizeText(key.auth_type) === 'oauth')
const exportableKeys = selectedKeys.filter((key) => canExportOAuthCredential(key))
const exportedEntries: Array<Record<string, unknown> | null> = Array.from({ length: exportableKeys.length }, () => null)
skippedCount += selectedKeys.length - exportableKeys.length
@@ -920,7 +924,7 @@ async function executeAction(actionOverride?: BatchActionValue): Promise<void> {
const CONCURRENCY = props.batchConcurrency || 8
const tasks: Array<() => Promise<'success' | 'skip'>> = []
for (const key of selectedKeys) {
if (selectedAction.value === 'refresh_oauth' && normalizeText(key.auth_type) !== 'oauth') {
if (selectedAction.value === 'refresh_oauth' && !canRefreshOAuthCredential(key)) {
skippedCount += 1
progressDone.value += 1
continue

View File

@@ -467,6 +467,7 @@ const form = ref({
probing_enabled: false,
probing_interval_minutes: null as number | null | undefined,
auto_remove_banned_keys: false,
skip_exhausted_accounts: false,
})
interface ClaudeFormState {
@@ -503,6 +504,8 @@ function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
return form.value.probing_enabled
case 'auto_remove_banned_keys':
return form.value.auto_remove_banned_keys
case 'skip_exhausted_accounts':
return form.value.skip_exhausted_accounts
}
}
@@ -516,6 +519,9 @@ function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void
return
case 'auto_remove_banned_keys':
form.value.auto_remove_banned_keys = value
return
case 'skip_exhausted_accounts':
form.value.skip_exhausted_accounts = value
}
}
@@ -536,6 +542,7 @@ watch(() => props.modelValue, (open) => {
probing_enabled: cfg?.probing_enabled ?? false,
probing_interval_minutes: cfg?.probing_interval_minutes ?? null,
auto_remove_banned_keys: cfg?.auto_remove_banned_keys ?? false,
skip_exhausted_accounts: cfg?.skip_exhausted_accounts ?? false,
}
const cc = props.currentClaudeConfig
@@ -570,6 +577,7 @@ async function handleSave() {
? (form.value.probing_interval_minutes ?? undefined)
: undefined,
auto_remove_banned_keys: form.value.auto_remove_banned_keys,
skip_exhausted_accounts: form.value.skip_exhausted_accounts,
}
const payload: Parameters<typeof updateProvider>[1] = {

View File

@@ -13,6 +13,7 @@ describe('poolAdvancedDialog', () => {
'health_policy_enabled',
'probing_enabled',
'auto_remove_banned_keys',
'skip_exhausted_accounts',
])
})
@@ -33,6 +34,11 @@ describe('poolAdvancedDialog', () => {
label: '异常自动清除',
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
},
{
key: 'skip_exhausted_accounts',
label: '跳过额度耗尽账号',
description: '当 Codex / Kiro 账号额度已耗尽时,直接标记为不可调度并在请求侧跳过。',
},
])
})

View File

@@ -2,6 +2,7 @@ export type PoolHealthToggleKey =
| 'health_policy_enabled'
| 'probing_enabled'
| 'auto_remove_banned_keys'
| 'skip_exhausted_accounts'
export interface PoolHealthToggleCard {
key: PoolHealthToggleKey
@@ -40,6 +41,11 @@ export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
label: '异常自动清除',
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
},
{
key: 'skip_exhausted_accounts',
label: '跳过额度耗尽账号',
description: '当 Codex / Kiro 账号额度已耗尽时,直接标记为不可调度并在请求侧跳过。',
},
]
}

View File

@@ -939,8 +939,8 @@ async function handleImport() {
let keepImporting = false
try {
const proxyNodeId = selectedProxyNodeId.value || undefined
// 检测是否为批量导入
if (isBatchImport(inputText)) {
// Kiro 的单条 JSON 凭据也必须走 batch-import 路径,后端需要完整 auth_config。
if (isKiroProvider.value || isBatchImport(inputText)) {
const task = await startBatchImportOAuthTask(props.providerId, inputText, proxyNodeId)
importTask.value = {
task_id: task.task_id,

View File

@@ -325,10 +325,10 @@
</div>
<div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground">
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : key.api_key_masked) }}
{{ isOAuthManagedCredential(key) ? '[Refresh Token]' : (isServiceAccountCredential(key) ? '[Service Account]' : key.api_key_masked) }}
</span>
<Button
v-if="key.auth_type === 'oauth'"
v-if="canExportOAuthCredential(key)"
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
@@ -403,7 +403,7 @@
</template>
<!-- Antigravity 账号未激活提示 -->
<span
v-if="provider.provider_type === 'antigravity' && key.is_active && key.auth_type === 'oauth' && (!key.upstream_metadata || !hasAntigravityQuotaData(key.upstream_metadata))"
v-if="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && (!key.upstream_metadata || !hasAntigravityQuotaData(key.upstream_metadata))"
class="text-[10px] text-orange-500 dark:text-orange-400"
title="该账号尚未完成 Gemini Code Assist 激活,无法获取配额和使用模型"
>
@@ -1157,6 +1157,12 @@ import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import {
canEditOAuthCredential,
canExportOAuthCredential,
isOAuthManagedCredential,
isServiceAccountCredential,
} from '@/utils/providerKeyAuth'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
@@ -1579,7 +1585,7 @@ function handleEditKey(endpoint: ProviderEndpoint | undefined, key: EndpointAPIK
currentEndpoint.value = endpoint || null
editingKey.value = key
// OAuth 密钥使用专门的编辑对话框
if (key.auth_type === 'oauth') {
if (canEditOAuthCredential(key)) {
oauthKeyEditDialogOpen.value = true
} else {
keyFormDialogOpen.value = true

View File

@@ -715,7 +715,7 @@ const requestBodyDraft = computed(() => props.requestBodyDraft ?? '')
const traceCandidates = computed(() => props.trace?.candidates ?? [])
const showSetup = computed(() => props.open && !props.testing && !props.result)
const showResult = computed(() => !!props.result)
const showTraceTimeline = computed(() => Boolean(props.requestId))
const showTraceTimeline = computed(() => Boolean(props.requestId) && traceCandidates.value.length > 0)
const isDark = computed(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
const { copyToClipboard } = useClipboard()

View File

@@ -104,4 +104,24 @@ describe('providerKeyStatus', () => {
},
}, 0)).toContain('Token 剩余有效期:')
})
it('treats oauth_managed bearer credentials as oauth for legacy countdown fallback', () => {
const future = Math.floor(Date.now() / 1000) + 2 * 24 * 3600
const status = getOAuthStatusDisplay(
{
auth_type: 'bearer',
oauth_managed: true,
oauth_expires_at: future,
},
0,
)
expect(status).not.toBeNull()
expect(status?.isExpired).toBe(false)
expect(getOAuthStatusTitle({
auth_type: 'bearer',
oauth_managed: true,
oauth_expires_at: future,
}, 0)).toContain('Token 剩余有效期:')
})
})

View File

@@ -0,0 +1,100 @@
export interface ProviderKeyAuthCarrier {
auth_type?: string | null
credential_kind?: string | null
runtime_auth_kind?: string | null
oauth_managed?: boolean | null
can_refresh_oauth?: boolean | null
can_export_oauth?: boolean | null
can_edit_oauth?: boolean | null
}
function normalizeText(value: unknown): string | null {
if (typeof value !== 'string') return null
const text = value.trim().toLowerCase()
return text || null
}
export function getProviderCredentialKind(
input: ProviderKeyAuthCarrier,
): 'raw_secret' | 'oauth_session' | 'service_account' {
const credentialKind = normalizeText(input.credential_kind)
if (
credentialKind === 'raw_secret'
|| credentialKind === 'oauth_session'
|| credentialKind === 'service_account'
) {
return credentialKind
}
if (typeof input.oauth_managed === 'boolean') {
return input.oauth_managed ? 'oauth_session' : 'raw_secret'
}
const authType = normalizeText(input.auth_type)
if (authType === 'oauth') return 'oauth_session'
if (authType === 'service_account' || authType === 'vertex_ai') return 'service_account'
return 'raw_secret'
}
export function getProviderRuntimeAuthKind(
input: ProviderKeyAuthCarrier,
): 'api_key' | 'bearer' | 'service_account' | 'unknown' {
const runtimeAuthKind = normalizeText(input.runtime_auth_kind)
if (
runtimeAuthKind === 'api_key'
|| runtimeAuthKind === 'bearer'
|| runtimeAuthKind === 'service_account'
) {
return runtimeAuthKind
}
const authType = normalizeText(input.auth_type)
if (authType === 'service_account' || authType === 'vertex_ai') return 'service_account'
if (authType === 'bearer') return 'bearer'
if (authType === 'api_key') return 'api_key'
return 'unknown'
}
export function isOAuthManagedCredential(input: ProviderKeyAuthCarrier): boolean {
if (typeof input.oauth_managed === 'boolean') {
return input.oauth_managed
}
return getProviderCredentialKind(input) === 'oauth_session'
}
export function isServiceAccountCredential(input: ProviderKeyAuthCarrier): boolean {
return getProviderCredentialKind(input) === 'service_account'
}
export function canRefreshOAuthCredential(input: ProviderKeyAuthCarrier): boolean {
if (typeof input.can_refresh_oauth === 'boolean') {
return input.can_refresh_oauth
}
return isOAuthManagedCredential(input)
}
export function canExportOAuthCredential(input: ProviderKeyAuthCarrier): boolean {
if (typeof input.can_export_oauth === 'boolean') {
return input.can_export_oauth
}
return isOAuthManagedCredential(input)
}
export function canEditOAuthCredential(input: ProviderKeyAuthCarrier): boolean {
if (typeof input.can_edit_oauth === 'boolean') {
return input.can_edit_oauth
}
return isOAuthManagedCredential(input)
}
export function getProviderAuthLabel(input: ProviderKeyAuthCarrier): string {
if (isOAuthManagedCredential(input)) return 'OAuth'
if (isServiceAccountCredential(input)) return '服务账号'
return getProviderRuntimeAuthKind(input) === 'bearer' ? 'Bearer' : 'API Key'
}
export function getProviderMaskedSecretLabel(input: ProviderKeyAuthCarrier): string {
if (isOAuthManagedCredential(input)) return '[OAuth Token]'
if (isServiceAccountCredential(input)) return '[Service Account]'
return getProviderRuntimeAuthKind(input) === 'bearer' ? '[Bearer Token]' : '[Key]'
}

View File

@@ -6,9 +6,12 @@ import {
isAccountLevelBlockReason,
isRefreshFailedReason,
} from './accountBlock'
import {
isOAuthManagedCredential,
type ProviderKeyAuthCarrier,
} from './providerKeyAuth'
export interface ProviderKeyStatusCarrier {
auth_type?: string | null
export interface ProviderKeyStatusCarrier extends ProviderKeyAuthCarrier {
oauth_expires_at?: number | null
oauth_invalid_at?: number | null // compatibility only
oauth_invalid_reason?: string | null // compatibility only
@@ -107,7 +110,7 @@ function getLegacyOAuthState(
input: ProviderKeyStatusCarrier,
tick: number,
): OAuthStatusInfo | null {
if (normalizeText(input.auth_type) !== 'oauth') return null
if (!isOAuthManagedCredential(input)) return null
if (!input.oauth_expires_at && !input.oauth_invalid_at && !input.oauth_invalid_reason) return null
const rawReason = normalizeText(input.oauth_invalid_reason)

View File

@@ -441,7 +441,7 @@
P{{ key.internal_priority ?? 50 }}
</button>
<Button
v-if="key.auth_type === 'oauth'"
v-if="canExportOAuthCredential(key)"
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
@@ -461,9 +461,9 @@
<Copy class="w-2.5 h-2.5" />
</Button>
<span class="font-mono">
{{ key.auth_type === 'oauth' ? '[OAuth Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : '[Key]') }}
{{ getProviderMaskedSecretLabel(key) }}
</span>
<template v-if="key.auth_type === 'oauth'">
<template v-if="canRefreshOAuthCredential(key)">
<Button
variant="ghost"
size="icon"
@@ -853,7 +853,7 @@
class="min-w-0 flex-1 flex justify-center"
>
<Button
v-if="actionId === 'copy_or_download' && key.auth_type === 'oauth'"
v-if="actionId === 'copy_or_download' && canExportOAuthCredential(key)"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@@ -1207,6 +1207,15 @@ import {
} from '@/features/pool/utils/poolManagementState'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import {
canEditOAuthCredential,
canExportOAuthCredential,
canRefreshOAuthCredential,
getProviderAuthLabel,
getProviderMaskedSecretLabel,
isOAuthManagedCredential,
isServiceAccountCredential,
} from '@/utils/providerKeyAuth'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
@@ -1836,8 +1845,9 @@ watch(searchQuery, () => {
}, 300)
})
function normalizeAuthTypeForEdit(authType: string): EndpointAPIKey['auth_type'] {
if (authType === 'oauth' || authType === 'service_account') return authType
function normalizeAuthTypeForEdit(key: PoolKeyDetail): EndpointAPIKey['auth_type'] {
if (isOAuthManagedCredential(key)) return 'oauth'
if (isServiceAccountCredential(key)) return 'service_account'
return 'api_key'
}
@@ -1847,12 +1857,14 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
id: key.key_id,
provider_id: selectedProviderId.value || '',
api_formats: key.api_formats || [],
api_key_masked: key.auth_type === 'oauth'
? '[OAuth Token]'
: key.auth_type === 'service_account'
? '[Service Account]'
: '[Key]',
auth_type: normalizeAuthTypeForEdit(key.auth_type),
api_key_masked: getProviderMaskedSecretLabel(key),
auth_type: normalizeAuthTypeForEdit(key),
credential_kind: key.credential_kind ?? null,
runtime_auth_kind: key.runtime_auth_kind ?? null,
oauth_managed: key.oauth_managed ?? undefined,
can_refresh_oauth: key.can_refresh_oauth ?? undefined,
can_export_oauth: key.can_export_oauth ?? undefined,
can_edit_oauth: key.can_edit_oauth ?? undefined,
name: key.key_name || '未命名',
rate_multipliers: key.rate_multipliers ?? null,
internal_priority: key.internal_priority ?? 50,
@@ -1959,7 +1971,7 @@ async function finishEditInternalPriority(
function handleEditKey(key: PoolKeyDetail) {
editingKeyDetail.value = key
if (key.auth_type === 'oauth') {
if (canEditOAuthCredential(key)) {
oauthKeyEditDialogOpen.value = true
} else {
keyFormDialogOpen.value = true
@@ -2457,10 +2469,8 @@ function getRowClass(key: PoolKeyDetail): string {
return ''
}
function getAuthTypeChipLabel(authType: string): string {
if (authType === 'oauth') return 'OAuth'
if (authType === 'service_account') return '服务账号'
return 'API Key'
function getAuthTypeChipLabel(key: PoolKeyDetail): string {
return getProviderAuthLabel(key)
}
function getMobileOAuthTone(key: PoolKeyDetail): PoolMobileTagTone | null {
@@ -2482,7 +2492,7 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
oauthStatusLabel: oauthState?.text ?? null,
oauthStatusTone: getMobileOAuthTone(key),
priorityLabel: `P${key.internal_priority ?? 50}`,
authLabel: getAuthTypeChipLabel(key.auth_type),
authLabel: getAuthTypeChipLabel(key),
planLabel: key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null,
orgLabel: orgBadge?.label ?? null,
proxyLabel: key.proxy?.node_id ? '独立代理' : null,
@@ -2492,7 +2502,7 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
function getMobileActionIds(key: PoolKeyDetail): PoolMobileActionId[] {
return splitPoolMobileActions({
canDownloadOrCopy: true,
canRefreshToken: key.auth_type === 'oauth',
canRefreshToken: canRefreshOAuthCredential(key),
canClearCooldown: Boolean(key.cooldown_reason),
canRecoverHealth: key.circuit_breaker_open || (key.health_score ?? 1) < 0.5,
hasProxy: true,