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

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