Merge branch 'fawney19:main' into main

This commit is contained in:
ZheFox
2026-07-28 13:56:34 +08:00
committed by GitHub
117 changed files with 10467 additions and 1253 deletions
@@ -530,7 +530,8 @@ import { CircleHelp } from 'lucide-vue-next'
import { Dialog, Button, Input, Label, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import { getProvider, updateProvider } from '@/api/endpoints'
import { mergePoolAdvancedPatch } from '@/features/pool/utils/poolSchedulingDialog'
import {
buildPoolCooldownFieldLayout,
buildPoolHealthToggleCards,
@@ -557,6 +558,7 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const loading = ref(false)
let dialogRevision = 0
const isClaudeCode = computed(() => {
return (props.providerType || '').trim().toLowerCase() === 'claude_code'
@@ -652,7 +654,9 @@ function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void
}
}
watch(() => props.modelValue, (open) => {
watch([() => props.modelValue, () => props.providerId], ([open]) => {
dialogRevision += 1
loading.value = false
if (!open) return
const cfg = props.currentConfig
@@ -698,12 +702,26 @@ watch(() => props.modelValue, (open) => {
})
async function handleSave() {
const providerId = props.providerId
const revision = dialogRevision
loading.value = true
try {
const latestProvider = await getProvider(providerId)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
const latestAdvanced = (latestProvider as Record<string, unknown>).pool_advanced
const existingPoolAdvanced = mergePoolAdvancedPatch(latestAdvanced, {})
const latestScoreRules = typeof existingPoolAdvanced.score_rules === 'object'
&& existingPoolAdvanced.score_rules !== null
? existingPoolAdvanced.score_rules as Record<string, unknown>
: {}
const latestScoreWeights = typeof latestScoreRules.weights === 'object'
&& latestScoreRules.weights !== null
? latestScoreRules.weights as Record<string, unknown>
: {}
const scoreRules = {
...(props.currentConfig?.score_rules ?? {}),
...latestScoreRules,
weights: {
...(props.currentConfig?.score_rules?.weights ?? {}),
...latestScoreWeights,
manual_priority: form.value.score_weight_manual_priority ?? undefined,
health: form.value.score_weight_health ?? undefined,
probe_freshness: form.value.score_weight_probe_freshness ?? undefined,
@@ -717,31 +735,8 @@ async function handleSave() {
request_failure_penalty: form.value.request_failure_penalty ?? undefined,
probe_failure_cooldown_threshold: form.value.probe_failure_cooldown_threshold ?? undefined,
}
const existingPoolAdvanced: Record<string, unknown> = { ...(props.currentConfig ?? {}) }
for (const key of [
'probing_target_percent',
'probing_target_count',
'probing_active_target_percent',
'probing_active_target_count',
'active_probe_target_percent',
'active_probe_target_count',
'probing_interval_minutes',
'account_self_check_method',
'self_check_method',
'account_self_check_request',
'self_check_request',
'health_policy_enabled',
'sticky_session_ttl_seconds',
'global_priority',
'cost_window_seconds',
'cost_limit_per_key_tokens',
'cost_soft_threshold_percent',
]) {
delete existingPoolAdvanced[key]
}
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
const poolAdvanced: Record<string, unknown> = {
...existingPoolAdvanced,
const poolAdvanced = mergePoolAdvancedPatch(existingPoolAdvanced, {
rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined,
overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined,
batch_concurrency: form.value.batch_concurrency ?? undefined,
@@ -760,7 +755,7 @@ async function handleSave() {
auto_remove_banned_keys: form.value.auto_remove_banned_keys,
auto_remove_quota_exhausted_keys: form.value.auto_remove_quota_exhausted_keys,
skip_exhausted_accounts: form.value.skip_exhausted_accounts,
}
})
const payload: Parameters<typeof updateProvider>[1] = {
pool_advanced: poolAdvanced as PoolAdvancedConfig,
@@ -776,14 +771,17 @@ async function handleSave() {
cli_only_enabled: cf.cli_only_enabled,
}
}
const updatedProvider = await updateProvider(props.providerId, payload)
const updatedProvider = await updateProvider(providerId, payload)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
success('高级设置已保存')
emit('saved', updatedProvider)
emit('update:modelValue', false)
} catch (err) {
showError(parseApiError(err))
} finally {
loading.value = false
if (dialogRevision === revision) {
loading.value = false
}
}
}
</script>
@@ -218,9 +218,13 @@ import { GripVertical } from 'lucide-vue-next'
import { Dialog, Button, Switch } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import { getProvider, updateProvider } from '@/api/endpoints'
import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
import {
mergePoolAdvancedPatch,
moveStrategyItem,
normalizeMutexSelection,
} from '@/features/pool/utils/poolSchedulingDialog'
import type { PoolPresetMeta } from '@/api/endpoints/pool'
import type {
PoolAdvancedConfig,
@@ -267,6 +271,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
mutex_group: DISTRIBUTION_GROUP,
evidence_hint: '依据 LRU 时间戳(最近使用优先,与 LRU 轮转相反)',
providers: [],
default_enabled: true,
modes: null,
default_mode: null,
},
@@ -300,12 +305,25 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
modes: null,
default_mode: null,
},
{
name: 'free_team_first',
label: 'Free/Team 优先',
description: '兼容旧配置:优先消耗 Free、Team 或两者',
evidence_hint: '依据 plan_type,保留旧 free_only/team_only/both 语义',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: [
{ value: 'free_only', label: 'Free' },
{ value: 'team_only', label: 'Team' },
{ value: 'both', label: 'Free + Team' },
],
default_mode: 'both',
},
{
name: 'free_first',
label: 'Free 优先',
description: '优先消耗 Free 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Free 账号优先调度)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -314,7 +332,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Team 优先',
description: '优先消耗 Team 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Team 账号优先调度)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -323,7 +341,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Plus 优先',
description: '优先消耗 Plus 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Plus 账号优先调度)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -332,7 +350,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Pro 优先',
description: '优先消耗 Pro 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Pro 账号优先调度)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -350,7 +368,8 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: '额度刷新优先',
description: '优先选即将刷新额度的账号',
evidence_hint: '依据账号额度重置倒计时(next_reset / reset_seconds)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
default_enabled_providers: ['codex', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -392,10 +411,9 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
},
]
const DEFAULT_ENABLED_PRESETS = new Set(['cache_affinity', 'recent_refresh'])
const { success, error: showError } = useToast()
const loading = ref(false)
let dialogRevision = 0
const presetDefs = ref<PoolPresetMeta[]>([])
const presetDefsLoaded = ref(false)
const loadingPresetDefs = ref(false)
@@ -443,11 +461,16 @@ function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
.filter(mode => Boolean(mode.value))
: null
const defaultMode = normalizeMode(raw.default_mode)
const defaultEnabledProviders = Array.isArray(raw.default_enabled_providers)
? raw.default_enabled_providers.map(p => normalizeProviderType(p)).filter(Boolean)
: []
ordered.push({
name,
label: String(raw.label ?? '').trim() || name,
description: String(raw.description ?? '').trim(),
providers,
default_enabled: raw.default_enabled === true,
default_enabled_providers: defaultEnabledProviders,
modes: modes && modes.length > 0 ? modes : null,
default_mode: defaultMode,
mutex_group: normalizeMutexGroup(raw.mutex_group),
@@ -478,11 +501,11 @@ async function ensurePresetDefsLoaded(): Promise<void> {
const normalized = normalizePresetDefs(Array.isArray(remoteDefs) ? remoteDefs : [])
if (normalized.length > 0) {
presetDefs.value = normalized
presetDefsLoaded.value = true
}
} catch (err) {
showError(parseApiError(err))
} finally {
presetDefsLoaded.value = true
loadingPresetDefs.value = false
}
}
@@ -530,7 +553,15 @@ function buildPresetListItem(def: PoolPresetMeta, enabled: boolean, mode?: unkno
}
function buildDefaultPresetList(): PresetListItem[] {
return getPresetDefs().map(def => buildPresetListItem(def, DEFAULT_ENABLED_PRESETS.has(def.name)))
const providerType = normalizeProviderType(props.providerType)
return getPresetDefs().map((def) => {
const providerDefaults = Array.isArray(def.default_enabled_providers)
? def.default_enabled_providers.map(normalizeProviderType)
: []
const enabled = def.default_enabled === true
|| (Boolean(providerType) && providerDefaults.includes(providerType))
return buildPresetListItem(def, enabled)
})
}
function isNewFormatPresetItem(item: unknown): item is SchedulingPresetItem {
@@ -641,7 +672,7 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
}
insertMissingByPreferredOrder(ordered, seen, defs, defsByName)
return reorderDistributionGroup(ordered)
return reorderDistributionGroup(normalizeMutexSelection(ordered))
}
const legacyPresets = rawPresets as string[]
@@ -664,33 +695,7 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
}
insertMissingByPreferredOrder(ordered, seen, defs, defsByName)
return reorderDistributionGroup(ordered)
}
function normalizeMutexSelection(items: PresetListItem[]): PresetListItem[] {
const next = [...items]
const groups = new Map<string, number[]>()
next.forEach((item, index) => {
if (!item.mutexGroup) return
if (!groups.has(item.mutexGroup)) groups.set(item.mutexGroup, [])
groups.get(item.mutexGroup)?.push(index)
})
for (const indexes of groups.values()) {
if (indexes.length <= 1) continue
const enabledApplicable = indexes.find(index => {
const item = next[index]
return item.enabled && item.applicable
})
const firstApplicable = indexes.find(index => next[index].applicable)
const winner = enabledApplicable ?? firstApplicable ?? indexes[0]
indexes.forEach((index) => {
next[index].enabled = index === winner && next[index].applicable
})
}
return next
return reorderDistributionGroup(normalizeMutexSelection(ordered))
}
function togglePreset(index: number, enabled: boolean) {
@@ -814,15 +819,26 @@ function handleDrop(dropIndex: number) {
dragOverIndex.value = null
}
watch(() => props.modelValue, async (open) => {
watch([() => props.modelValue, () => props.providerId], async ([open]) => {
const revision = ++dialogRevision
loading.value = false
if (!open) return
await ensurePresetDefsLoaded()
if (!props.modelValue || dialogRevision !== revision) return
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
})
async function handleSave() {
const providerId = props.providerId
const revision = dialogRevision
loading.value = true
try {
await ensurePresetDefsLoaded()
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
if (!presetDefsLoaded.value) {
showError('调度策略元数据加载失败,请重试')
return
}
presetList.value = normalizeMutexSelection(presetList.value)
const schedulingPresets: SchedulingPresetItem[] = presetList.value.map(item => {
const result: SchedulingPresetItem = {
@@ -835,14 +851,17 @@ async function handleSave() {
return result
})
const mergedAdvanced: Record<string, unknown> = {
...(props.currentConfig ?? {}),
const latestProvider = await getProvider(providerId)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
const latestAdvanced = (latestProvider as Record<string, unknown>).pool_advanced
const mergedAdvanced = mergePoolAdvancedPatch(latestAdvanced, {
scheduling_presets: schedulingPresets,
}
})
const payload: Parameters<typeof updateProvider>[1] = {
pool_advanced: mergedAdvanced as PoolAdvancedConfig,
}
const updatedProvider = await updateProvider(props.providerId, payload)
const updatedProvider = await updateProvider(providerId, payload)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
success('号池调度已保存')
emit('saved', updatedProvider)
@@ -850,7 +869,9 @@ async function handleSave() {
} catch (err) {
showError(parseApiError(err))
} finally {
loading.value = false
if (dialogRevision === revision) {
loading.value = false
}
}
}
</script>
@@ -1,22 +1,27 @@
import { describe, expect, it } from 'vitest'
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
import {
mergePoolAdvancedPatch,
moveStrategyItem,
normalizeMutexSelection,
} from '@/features/pool/utils/poolSchedulingDialog'
interface TestPresetItem {
preset: string
mutexGroup: string | null
enabled: boolean
applicable: boolean
}
function buildItems(): TestPresetItem[] {
return [
{ preset: 'cache_affinity', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'lru', mutexGroup: 'distribution_mode', enabled: true },
{ preset: 'single_account', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'load_balance', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'recent_refresh', mutexGroup: null, enabled: true },
{ preset: 'quota_balanced', mutexGroup: null, enabled: false },
{ preset: 'priority_first', mutexGroup: null, enabled: true },
{ preset: 'cache_affinity', mutexGroup: 'distribution_mode', enabled: false, applicable: true },
{ preset: 'lru', mutexGroup: 'distribution_mode', enabled: true, applicable: true },
{ preset: 'single_account', mutexGroup: 'distribution_mode', enabled: false, applicable: true },
{ preset: 'load_balance', mutexGroup: 'distribution_mode', enabled: false, applicable: true },
{ preset: 'recent_refresh', mutexGroup: null, enabled: true, applicable: true },
{ preset: 'quota_balanced', mutexGroup: null, enabled: false, applicable: true },
{ preset: 'priority_first', mutexGroup: null, enabled: true, applicable: true },
]
}
@@ -69,4 +74,49 @@ describe('poolSchedulingDialog', () => {
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
})
it('keeps the first enabled distribution from the saved order', () => {
const items = buildItems()
items[0].enabled = true
items[1].enabled = false
items[3].enabled = true
const savedOrder = [items[3], items[0], ...items.slice(1, 3), ...items.slice(4)]
const normalized = normalizeMutexSelection(savedOrder)
expect(normalized.find(item => item.preset === 'load_balance')?.enabled).toBe(true)
expect(normalized.find(item => item.preset === 'cache_affinity')?.enabled).toBe(false)
})
it('does not invent a distribution mode when all are disabled', () => {
const items = buildItems().map(item => ({
...item,
enabled: item.mutexGroup ? false : item.enabled,
}))
const normalized = normalizeMutexSelection(items)
expect(normalized.filter(item => item.mutexGroup).every(item => !item.enabled)).toBe(true)
})
it('preserves pool fields that the current dialog does not edit', () => {
const merged = mergePoolAdvancedPatch({
sticky_session_ttl_seconds: 900,
cost_window_seconds: 7200,
cost_limit_per_key_tokens: 100_000,
probing_target_percent: 25,
global_priority: 7,
}, {
score_top_n: 256,
})
expect(merged).toEqual({
sticky_session_ttl_seconds: 900,
cost_window_seconds: 7200,
cost_limit_per_key_tokens: 100_000,
probing_target_percent: 25,
global_priority: 7,
score_top_n: 256,
})
})
})
@@ -2,6 +2,47 @@ export interface SchedulingDialogPresetLike {
mutexGroup: string | null
}
export interface SchedulingDialogSelectablePresetLike extends SchedulingDialogPresetLike {
enabled: boolean
applicable: boolean
}
export function mergePoolAdvancedPatch(
current: unknown,
patch: Record<string, unknown>,
): Record<string, unknown> {
const currentRecord = typeof current === 'object' && current !== null && !Array.isArray(current)
? current as Record<string, unknown>
: {}
return {
...currentRecord,
...patch,
}
}
export function normalizeMutexSelection<T extends SchedulingDialogSelectablePresetLike>(
items: readonly T[],
): T[] {
const next = items.map(item => ({ ...item }))
const groups = new Map<string, number[]>()
next.forEach((item, index) => {
if (!item.mutexGroup) return
const indexes = groups.get(item.mutexGroup) ?? []
indexes.push(index)
groups.set(item.mutexGroup, indexes)
})
for (const indexes of groups.values()) {
const winner = indexes.find(index => next[index].enabled && next[index].applicable)
indexes.forEach((index) => {
next[index].enabled = winner !== undefined && index === winner && next[index].applicable
})
}
return next
}
export function moveStrategyItem<T extends SchedulingDialogPresetLike>(
items: readonly T[],
itemIndex: number,
@@ -491,17 +491,21 @@ function syncGlobalModelSelection() {
}
// 监听打开状态
watch(() => props.open, async (isOpen) => {
if (isOpen && props.providerId) {
await loadData()
} else {
searchQuery.value = ''
selectedGlobalModelIds.value = new Set()
initialGlobalModelIds.value = new Set()
providerKeys.value = []
fetchingAutoMatchedModels.value = false
}
})
watch(
() => props.open,
async (isOpen) => {
if (isOpen && props.providerId) {
await loadData()
} else {
searchQuery.value = ''
selectedGlobalModelIds.value = new Set()
initialGlobalModelIds.value = new Set()
providerKeys.value = []
fetchingAutoMatchedModels.value = false
}
},
{ immediate: true },
)
// 加载数据
async function loadData() {
@@ -708,14 +708,18 @@ async function fetchUpstreamModels() {
}
// 监听打开状态
watch(() => props.open, async (isOpen) => {
if (isOpen) {
initForm()
if (props.hasAutoFetchKey) {
await fetchUpstreamModels()
watch(
() => props.open,
async (isOpen) => {
if (isOpen) {
initForm()
if (props.hasAutoFetchKey) {
await fetchUpstreamModels()
}
}
}
})
},
{ immediate: true },
)
// 初始化表单
function initForm() {
@@ -63,7 +63,7 @@
<div
v-if="showAuthorizationMode"
class="grid rounded-lg border border-border p-0.5 bg-muted/30"
:class="isCodexProvider ? 'grid-cols-3' : 'grid-cols-2'"
:class="isCodexProvider || isClaudeCodeProvider ? 'grid-cols-3' : 'grid-cols-2'"
>
<button
class="min-w-0 min-h-8 px-2 py-1.5 text-xs font-medium leading-4 rounded-md transition-all disabled:cursor-not-allowed disabled:opacity-60"
@@ -72,17 +72,28 @@
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
]"
:disabled="importing || creatingAgentIdentity"
:disabled="importing || creatingAgentIdentity || cookieAuthorizing"
@click="switchMode('oauth')"
>
{{ authorizationModeLabel }}
</button>
<button
v-if="isClaudeCodeProvider"
class="min-w-0 min-h-8 px-2 py-1.5 text-xs font-medium leading-4 rounded-md transition-all disabled:cursor-not-allowed disabled:opacity-60"
:class="mode === 'cookie'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'"
:disabled="importing || creatingAgentIdentity || cookieAuthorizing"
@click="switchMode('cookie')"
>
{{ legacyT('Cookie授权') }}
</button>
<button
class="min-w-0 min-h-8 px-2 py-1.5 text-xs font-medium leading-4 rounded-md transition-all disabled:cursor-not-allowed disabled:opacity-60"
:class="mode === 'import'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'"
:disabled="importing || creatingAgentIdentity"
:disabled="importing || creatingAgentIdentity || cookieAuthorizing"
@click="switchMode('import')"
>
{{ importModeLabel }}
@@ -93,7 +104,7 @@
:class="mode === 'agent_identity'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'"
:disabled="importing || creatingAgentIdentity"
:disabled="importing || creatingAgentIdentity || cookieAuthorizing"
@click="switchMode('agent_identity')"
>
{{ legacyT('Agent Identity') }}
@@ -106,6 +117,8 @@
<div
class="space-y-4 transition-opacity duration-150"
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'oauth' ? '' : undefined"
:aria-hidden="mode !== 'oauth'"
>
<!-- Windsurf: 浏览器 session/poll 授权 -->
<template v-if="isWindsurfProvider">
@@ -544,8 +557,11 @@
</div>
</div>
<template v-else-if="oauth.authorization_url">
<div class="space-y-2">
<div
v-else-if="oauth.authorization_url"
class="flex h-full min-h-0 flex-col gap-4"
>
<div class="shrink-0 space-y-2">
<div class="flex items-center gap-2">
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
<span class="text-xs font-medium">{{ legacyT('前往授权') }}</span>
@@ -571,29 +587,69 @@
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="flex shrink-0 items-center gap-2">
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
<span class="text-xs font-medium">{{ legacyT('粘贴回调 URL') }}</span>
<span class="text-xs font-medium">{{ oauthCallbackLabel }}</span>
</div>
<div class="pl-6">
<div class="min-h-0 flex-1 pl-6">
<Textarea
v-model="oauth.callback_url"
:disabled="oauthBusy"
placeholder="http://localhost:xxx/callback?code=..."
class="min-h-[120px] text-xs font-mono break-all !rounded-xl"
:placeholder="oauthCallbackPlaceholder"
class="h-full min-h-[120px] overflow-y-auto text-xs font-mono break-all !rounded-xl"
data-testid="oauth-callback-textarea"
spellcheck="false"
/>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- ===== Cookie 授权 ===== -->
<div
v-if="isClaudeCodeProvider"
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'cookie' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'cookie' ? '' : undefined"
:aria-hidden="mode !== 'cookie'"
>
<label
class="sr-only"
for="claude-session-cookie"
>
{{ legacyT('Claude sessionKey Cookie') }}
</label>
<div class="relative">
<Textarea
id="claude-session-cookie"
v-model="cookieInput"
:disabled="cookieAuthorizing"
:placeholder="legacyT('每行粘贴一个 sessionKey Cookie 值或完整 Cookie 请求头,最多 20 个')"
aria-describedby="claude-session-cookie-status"
class="h-[200px] min-h-[200px] overflow-y-auto pb-7 text-xs font-mono break-words !rounded-xl"
data-testid="claude-cookie-input"
autocomplete="off"
spellcheck="false"
/>
<p
id="claude-session-cookie-status"
class="pointer-events-none absolute bottom-2 right-3 text-[10px]"
:class="cookieInputOverLimit ? 'text-destructive' : 'text-muted-foreground'"
aria-live="polite"
>
{{ cookieInputStatusText }}
</p>
</div>
</div>
<!-- ===== 导入授权 ===== -->
<div
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'import' ? '' : undefined"
:aria-hidden="mode !== 'import'"
>
<div
v-if="isWindsurfProvider"
@@ -724,6 +780,8 @@
v-if="isCodexProvider"
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'agent_identity' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'agent_identity' ? '' : undefined"
:aria-hidden="mode !== 'agent_identity'"
>
<Textarea
v-model="agentIdentityInput"
@@ -758,6 +816,13 @@
>
{{ device.completing ? legacyT('验证中...') : legacyT('验证') }}
</Button>
<Button
v-if="mode === 'cookie' && isClaudeCodeProvider"
:disabled="!canAuthorizeWithCookie"
@click="handleCookieAuthorize"
>
{{ cookieAuthorizeButtonText }}
</Button>
<Button
v-if="mode === 'import'"
:disabled="!canImport"
@@ -789,7 +854,16 @@ import {
ComboboxTrigger,
ComboboxViewport,
} from 'radix-vue'
import { UserPlus, Copy, ExternalLink, Globe, AlertCircle, ShieldCheck, ChevronsUpDown, Check } from 'lucide-vue-next'
import {
UserPlus,
Copy,
ExternalLink,
Globe,
AlertCircle,
ShieldCheck,
ChevronsUpDown,
Check,
} from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useTotp } from '@/composables/useTotp'
@@ -798,6 +872,9 @@ import { useI18n } from '@/i18n'
import {
startProviderLevelOAuth,
completeProviderLevelOAuth,
authorizeProviderWithCookie,
startProviderCookieAuthorizeTask,
getProviderCookieAuthorizeTaskStatus,
importProviderRefreshToken,
startBatchImportOAuthTask,
getBatchImportOAuthTaskStatus,
@@ -808,6 +885,7 @@ import {
} from '@/api/endpoints'
import type {
OAuthBatchImportTaskStatus,
OAuthBatchImportTaskStartResponse,
OAuthBatchImportTaskStatusResponse,
} from '@/api/endpoints/provider_oauth'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
@@ -883,7 +961,7 @@ function localizedApiError(error: unknown, fallback: string): string {
}
// 模式
type DialogMode = 'oauth' | 'import' | 'agent_identity'
type DialogMode = 'oauth' | 'cookie' | 'import' | 'agent_identity'
const mode = ref<DialogMode>((props.providerType || '').toLowerCase() === 'grok' ? 'import' : 'oauth')
type WindsurfImportMethod = 'email_password' | 'token_json'
@@ -982,6 +1060,14 @@ const windsurfAccountName = ref('')
const agentIdentityInput = ref('')
const creatingAgentIdentity = ref(false)
let agentIdentityRequestId = 0
const cookieInput = ref('')
const cookieAuthorizing = ref(false)
let cookieAuthorizeRequestId = 0
const cookieAuthorizeTask = ref<OAuthBatchImportTaskStartResponse | OAuthBatchImportTaskStatusResponse | null>(null)
const cookieAuthorizeSubmittedEntries = ref<string[]>([])
let cookieAuthorizePollTimer: ReturnType<typeof setTimeout> | null = null
const cookieAuthorizePolling = ref(false)
const CLAUDE_COOKIE_BATCH_LIMIT = 20
const isOpen = computed(() => props.open)
@@ -989,6 +1075,7 @@ const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() =
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
const isCodexProvider = computed(() => (props.providerType || '').toLowerCase() === 'codex')
const isClaudeCodeProvider = computed(() => (props.providerType || '').toLowerCase() === 'claude_code')
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
const showAuthorizationMode = computed(() => !isGrokProvider.value)
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
@@ -1017,6 +1104,16 @@ const authorizationModeLabel = computed(() => {
return legacyT('获取授权')
})
const oauthCallbackLabel = computed(() =>
legacyT(isClaudeCodeProvider.value ? '粘贴回调 URL 或授权码' : '粘贴回调 URL')
)
const oauthCallbackPlaceholder = computed(() =>
isClaudeCodeProvider.value
? legacyT('粘贴完整回调 URL 或授权码(code#state)')
: 'http://localhost:xxx/callback?code=...'
)
const deviceCallbackPlaceholder = computed(() =>
isWindsurfProvider.value
? legacyT('粘贴包含 token=...&state=... 的回调 URL;session token/apiKey 也可直接粘贴,普通 token 请用导入授权')
@@ -1067,6 +1164,35 @@ const canImport = computed(() => {
return importText.value.trim().length > 0 && !importing.value
})
const cookieEntries = computed(() => cookieInput.value
.split(/\r?\n/)
.map(value => value.trim())
.filter(Boolean)
)
const cookieInputOverLimit = computed(() => cookieEntries.value.length > CLAUDE_COOKIE_BATCH_LIMIT)
const canAuthorizeWithCookie = computed(() =>
isClaudeCodeProvider.value
&& cookieEntries.value.length > 0
&& !cookieInputOverLimit.value
&& !cookieAuthorizing.value
)
const cookieAuthorizeButtonText = computed(() => {
if (cookieAuthorizing.value) return legacyT('授权中...')
return cookieEntries.value.length > 1 ? legacyT('批量授权') : legacyT('授权')
})
const cookieInputStatusText = computed(() => {
const task = cookieAuthorizeTask.value
if (cookieAuthorizing.value && task) {
return isEnglishLocale()
? `${task.processed}/${task.total} · ${task.success} succeeded · ${task.failed} failed`
: `进度 ${task.processed}/${task.total} · 成功 ${task.success} · 失败 ${task.failed}`
}
const count = cookieEntries.value.length
if (count === 0) return legacyT('每行一个,最多 20 个')
if (isEnglishLocale()) return `${count} entered, maximum ${CLAUDE_COOKIE_BATCH_LIMIT}`
return `已输入 ${count} 个,最多 ${CLAUDE_COOKIE_BATCH_LIMIT} 个`
})
const canCreateAgentIdentity = computed(() =>
isCodexProvider.value
&& agentIdentityInput.value.trim().length > 0
@@ -1081,13 +1207,18 @@ const importDropTitle = computed(() => (
const importDropHint = computed(() => (
legacyT(isGrokProvider.value ? '支持 .json / .txt,可多选、批量导入' : '支持 .json / .txt,可多选')
))
const importManualPlaceholder = computed(() => (
isGrokProvider.value
? legacyT('粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON')
: isWindsurfProvider.value
? legacyT('粘贴 show-auth-token Token、API key 或 JSON 内容')
: legacyT('粘贴 Refresh Token / Access Token / Agent Identity JSON 内容')
))
const importManualPlaceholder = computed(() => {
if (isGrokProvider.value) {
return legacyT('粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON')
}
if (isClaudeCodeProvider.value) {
return legacyT('粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容')
}
if (isWindsurfProvider.value) {
return legacyT('粘贴 show-auth-token Token、API key 或 JSON 内容')
}
return legacyT('粘贴 Refresh Token / Access Token / Agent Identity JSON 内容')
})
const importManualDescription = computed(() => (
isGrokProvider.value
? legacyT('plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。')
@@ -1359,8 +1490,10 @@ function resetForm() {
oauthCompleteRequestId += 1
deviceAuthRequestId += 1
agentIdentityRequestId += 1
cookieAuthorizeRequestId += 1
oauth.value = createInitialOAuthState()
stopImportPolling()
stopCookieAuthorizePolling()
stopDevicePolling()
totp.stop()
device.value = createInitialDeviceState()
@@ -1377,6 +1510,10 @@ function resetForm() {
windsurfAccountName.value = ''
agentIdentityInput.value = ''
creatingAgentIdentity.value = false
cookieInput.value = ''
cookieAuthorizing.value = false
cookieAuthorizeTask.value = null
cookieAuthorizeSubmittedEntries.value = []
proxyPopoverOpen.value = false
selectedProxyNodeId.value = ''
mode.value = defaultMode.value
@@ -1385,8 +1522,9 @@ function resetForm() {
function switchMode(newMode: DialogMode) {
if (mode.value === newMode) return
if (newMode === 'oauth' && !showAuthorizationMode.value) return
if (newMode === 'cookie' && !isClaudeCodeProvider.value) return
if (newMode === 'agent_identity' && !isCodexProvider.value) return
if (importing.value || creatingAgentIdentity.value) return
if (importing.value || creatingAgentIdentity.value || cookieAuthorizing.value) return
mode.value = newMode
if (newMode === 'oauth') {
@@ -1467,6 +1605,198 @@ async function handleCompleteOAuth() {
}
}
async function handleCookieAuthorize() {
if (!canAuthorizeWithCookie.value || !props.providerId) return
const entries = [...cookieEntries.value]
const requestId = ++cookieAuthorizeRequestId
cookieAuthorizing.value = true
try {
if (entries.length === 1) {
const result = await authorizeProviderWithCookie(props.providerId, {
cookie: entries[0],
proxy_node_id: selectedProxyNodeId.value || undefined,
})
if (requestId !== cookieAuthorizeRequestId) return
success(getOAuthSuccessMessage('授权', result))
emit('saved')
handleClose()
return
}
const task = await startProviderCookieAuthorizeTask(props.providerId, {
cookies: entries,
proxy_node_id: selectedProxyNodeId.value || undefined,
})
if (requestId !== cookieAuthorizeRequestId) return
cookieAuthorizeTask.value = task
cookieAuthorizeSubmittedEntries.value = entries
scheduleCookieAuthorizePoll(task.task_id, requestId, 0)
} catch (err: unknown) {
if (requestId !== cookieAuthorizeRequestId) return
const errorMessage = localizedApiError(err, 'Cookie 授权失败')
showError(errorMessage, legacyT('错误'))
} finally {
if (requestId === cookieAuthorizeRequestId && !cookieAuthorizeTask.value) {
cookieAuthorizing.value = false
}
}
}
function stopCookieAuthorizePolling() {
if (cookieAuthorizePollTimer) {
clearTimeout(cookieAuthorizePollTimer)
cookieAuthorizePollTimer = null
}
cookieAuthorizePolling.value = false
}
function scheduleCookieAuthorizePoll(taskId: string, requestId: number, delayMs = 1200) {
stopCookieAuthorizePolling()
cookieAuthorizePollTimer = setTimeout(() => {
void pollCookieAuthorizeTaskStatus(taskId, requestId)
}, delayMs)
}
function cookieAuthorizeBatchSummary(task: OAuthBatchImportTaskStatusResponse): string {
const replaced = Math.max(task.replaced_count ?? 0, 0)
const created = Math.max(task.created_count ?? task.success - replaced, 0)
const successDetail = isEnglishLocale()
? `${task.success} succeeded (${created} added, ${replaced} replaced)`
: `成功 ${task.success} 个(新增 ${created} 个,替换 ${replaced} 个)`
if (isEnglishLocale()) {
return task.failed > 0
? `Batch authorization complete: ${successDetail}, ${task.failed} failed`
: `Batch authorization succeeded: ${successDetail}`
}
return task.failed > 0
? `批量授权完成:${successDetail},失败 ${task.failed} 个`
: `批量授权成功:${successDetail}`
}
function cookieAuthorizeFailureReasons(task: OAuthBatchImportTaskStatusResponse): string[] {
const reasons: string[] = []
const seenIndexes = new Set<number>()
for (const item of task.error_samples) {
const index = item.index
const detail = item.error?.trim()
if (
item.status !== 'error'
|| !Number.isInteger(index)
|| index < 0
|| index >= task.total
|| seenIndexes.has(index)
|| !detail
|| detail.length > 512
) {
continue
}
const normalized = detail.toLowerCase()
if (
normalized.includes('sessionkey')
|| normalized.includes('sk-ant-')
|| normalized.includes('cookie:')
) {
continue
}
seenIndexes.add(index)
reasons.push(`#${index + 1} ${legacyT(detail)}`)
if (reasons.length === 2) break
}
return reasons
}
function cookieAuthorizeBatchResultMessage(task: OAuthBatchImportTaskStatusResponse): string {
const summary = cookieAuthorizeBatchSummary(task)
const reasons = cookieAuthorizeFailureReasons(task)
if (reasons.length === 0) return summary
return `${summary}${isEnglishLocale() ? '; ' : ';'}${reasons.join(isEnglishLocale() ? '; ' : ';')}`
}
function failedCookieAuthorizeEntries(
task: OAuthBatchImportTaskStatusResponse,
entries: string[],
): string[] {
const failedIndexes = task.error_samples
.filter(item => item.status === 'error' && Number.isInteger(item.index))
.map(item => item.index)
.filter(index => index >= 0 && index < entries.length)
// Keep every original line if the response is incomplete, so credentials are never discarded.
if (new Set(failedIndexes).size !== task.failed) return entries
return failedIndexes.map(index => entries[index])
}
function handleCookieAuthorizeBatchResult(
task: OAuthBatchImportTaskStatusResponse,
entries: string[],
) {
const message = cookieAuthorizeBatchResultMessage(task)
cookieAuthorizeTask.value = null
cookieAuthorizeSubmittedEntries.value = []
if (task.failed === 0) {
success(message)
emit('saved')
handleClose()
return
}
if (task.success > 0) {
cookieInput.value = failedCookieAuthorizeEntries(task, entries).join('\n')
emit('saved')
warning(message, legacyT('批量授权'))
return
}
showError(message, legacyT('错误'))
}
async function pollCookieAuthorizeTaskStatus(taskId: string, requestId: number) {
if (!props.providerId || cookieAuthorizePolling.value || requestId !== cookieAuthorizeRequestId) return
cookieAuthorizePolling.value = true
try {
const task = await getProviderCookieAuthorizeTaskStatus(props.providerId, taskId)
if (requestId !== cookieAuthorizeRequestId) return
cookieAuthorizeTask.value = task
if (task.status === 'completed') {
stopCookieAuthorizePolling()
cookieAuthorizing.value = false
handleCookieAuthorizeBatchResult(task, [...cookieAuthorizeSubmittedEntries.value])
return
}
if (task.status === 'failed') {
stopCookieAuthorizePolling()
cookieAuthorizing.value = false
cookieAuthorizeTask.value = null
cookieAuthorizeSubmittedEntries.value = []
showError(
legacyT(task.error || task.message || 'Cookie 授权失败'),
legacyT('Cookie 授权失败'),
)
return
}
scheduleCookieAuthorizePoll(taskId, requestId)
} catch {
if (cookieAuthorizing.value && requestId === cookieAuthorizeRequestId) {
scheduleCookieAuthorizePoll(taskId, requestId, 2000)
}
} finally {
if (requestId === cookieAuthorizeRequestId) {
cookieAuthorizePolling.value = false
}
}
}
function parseImportText(text: string): {
api_key?: string
token?: string
@@ -1533,6 +1863,30 @@ function parseImportText(text: string): {
return { token: trimmed }
}
if (isClaudeCodeProvider.value) {
try {
const parsed: unknown = JSON.parse(trimmed)
if (isObjectRecord(parsed) && isObjectRecord(parsed.claudeAiOauth)) {
const claudeAiOauth = parsed.claudeAiOauth
const refreshToken = normalizeStringField(claudeAiOauth.refreshToken)
?? normalizeStringField(claudeAiOauth.refresh_token)
const accessToken = normalizeStringField(claudeAiOauth.accessToken)
?? normalizeStringField(claudeAiOauth.access_token)
if (refreshToken || accessToken) {
return {
refresh_token: refreshToken,
access_token: accessToken,
expires_at: normalizeClaudeCredentialsExpiry(
claudeAiOauth.expiresAt ?? claudeAiOauth.expires_at,
),
}
}
}
} catch {
// Raw Claude refresh tokens continue through the generic import path.
}
}
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
@@ -1723,6 +2077,12 @@ function normalizeExpiryField(value: unknown): number | undefined {
return undefined
}
function normalizeClaudeCredentialsExpiry(value: unknown): number | undefined {
const expiresAt = normalizeExpiryField(value)
if (!expiresAt) return undefined
return expiresAt >= 10_000_000_000 ? Math.floor(expiresAt / 1000) : expiresAt
}
function isLikelyJwtToken(token: string): boolean {
const parts = token.trim().split('.')
if (parts.length !== 3 || parts.some(part => !part)) return false
@@ -2138,27 +2498,32 @@ async function pollDevice(withCallback = false) {
onBeforeUnmount(() => {
stopImportPolling()
stopCookieAuthorizePolling()
stopDevicePolling()
})
watch(() => props.open, (newOpen) => {
if (newOpen) {
proxyNodesStore.ensureLoaded()
mode.value = defaultMode.value
if (!showAuthorizationMode.value) {
return
}
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
} else if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
watch(
() => props.open,
(newOpen) => {
if (newOpen) {
proxyNodesStore.ensureLoaded()
mode.value = defaultMode.value
if (!showAuthorizationMode.value) {
return
}
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
} else if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else {
initOAuth()
}
} else {
initOAuth()
resetForm()
}
} else {
resetForm()
}
})
},
{ immediate: true },
)
watch(
() => [props.open, props.providerId, props.providerType] as const,
@@ -2170,6 +2535,9 @@ watch(
if (props.open && mode.value === 'agent_identity' && !isCodexProvider.value) {
mode.value = defaultMode.value
}
if (props.open && mode.value === 'cookie' && !isClaudeCodeProvider.value) {
mode.value = defaultMode.value
}
if (props.open && isWindsurfProvider.value && mode.value === 'oauth') {
device.value.auth_type = ['default', 'google', 'github'].includes(device.value.auth_type)
? device.value.auth_type
@@ -45,11 +45,8 @@
<SelectItem value="vertex_ai">
Vertex AI
</SelectItem>
<SelectItem
value="claude_code"
disabled
>
{{ legacyT('ClaudeCode(暂不可用)') }}
<SelectItem value="claude_code">
{{ legacyT('Claude Code(实验性功能)') }}
</SelectItem>
<SelectItem value="codex">
Codex
@@ -82,7 +79,7 @@
Vertex AI
</SelectItem>
<SelectItem value="claude_code">
ClaudeCode
{{ legacyT('Claude Code(实验性功能)') }}
</SelectItem>
<SelectItem value="codex">
Codex
@@ -548,11 +545,6 @@ watch(() => form.value.provider_type, () => {
// 提交表单
const handleSubmit = async () => {
if (!isEditMode.value && form.value.provider_type === 'claude_code') {
showError(legacyT('ClaudeCode 提供商类型暂时禁用'), legacyT('验证失败'))
return
}
// 月卡类型必须设置周期开始时间
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
showError(legacyT('月卡类型必须设置周期开始时间'), legacyT('验证失败'))
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import BatchAssignModelsDialog from '../BatchAssignModelsDialog.vue'
const globalModelMocks = vi.hoisted(() => ({
getGlobalModels: vi.fn(),
}))
const endpointMocks = vi.hoisted(() => ({
getProviderModels: vi.fn(),
getProviderKeys: vi.fn(),
batchAssignModelsToProvider: vi.fn(),
deleteModel: vi.fn(),
}))
vi.mock('@/api/endpoints/global-models', () => globalModelMocks)
vi.mock('@/api/endpoints', () => endpointMocks)
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => ({
confirmWarning: vi.fn().mockResolvedValue(true),
}),
}))
vi.mock('@/features/providers/composables/useUpstreamModelsCache', () => ({
useUpstreamModelsCache: () => ({
fetchModels: vi.fn(),
}),
}))
vi.mock('@/components/ui/dialog/Dialog.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'DialogStub',
setup: (_props, { slots }) => () => h('section', [slots.default?.(), slots.footer?.()]),
}),
}
})
vi.mock('@/components/ui', async () => {
const { defineComponent } = await import('vue')
const passthrough = (name: string) => defineComponent({
name,
inheritAttrs: false,
setup: (_props, { slots }) => () => slots.default?.(),
})
return {
DropdownMenu: passthrough('DropdownMenuStub'),
DropdownMenuTrigger: passthrough('DropdownMenuTriggerStub'),
DropdownMenuContent: passthrough('DropdownMenuContentStub'),
DropdownMenuItem: passthrough('DropdownMenuItemStub'),
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
beforeEach(() => {
globalModelMocks.getGlobalModels.mockReset()
globalModelMocks.getGlobalModels.mockResolvedValue({ models: [], total: 0 })
endpointMocks.getProviderModels.mockReset()
endpointMocks.getProviderModels.mockResolvedValue([])
endpointMocks.getProviderKeys.mockReset()
endpointMocks.getProviderKeys.mockResolvedValue([])
endpointMocks.batchAssignModelsToProvider.mockReset()
endpointMocks.deleteModel.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('BatchAssignModelsDialog loading', () => {
it('loads model choices when lazily mounted in the open state', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(BatchAssignModelsDialog, {
open: true,
providerId: 'provider-1',
providerName: 'Provider One',
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await settle()
expect(globalModelMocks.getGlobalModels).toHaveBeenCalledOnce()
expect(globalModelMocks.getGlobalModels).toHaveBeenCalledWith({ limit: 1000 })
expect(endpointMocks.getProviderModels).toHaveBeenCalledWith('provider-1')
expect(endpointMocks.getProviderKeys).toHaveBeenCalledWith('provider-1')
})
})
@@ -5,6 +5,10 @@ import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
import type { Model, ProviderEndpoint } from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
const upstreamModelMocks = vi.hoisted(() => ({
fetchModels: vi.fn(),
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
@@ -106,7 +110,7 @@ vi.mock('@/composables/useToast', () => ({
vi.mock('../../composables/useUpstreamModelsCache', () => ({
useUpstreamModelsCache: () => ({
fetchModels: vi.fn(),
fetchModels: upstreamModelMocks.fetchModels,
}),
}))
@@ -114,6 +118,7 @@ const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
vi.mocked(updateModel).mockClear()
upstreamModelMocks.fetchModels.mockReset()
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
@@ -121,6 +126,36 @@ afterEach(() => {
})
describe('ModelMappingDialog', () => {
it('initializes upstream models when lazily mounted in the open state', async () => {
upstreamModelMocks.fetchModels.mockResolvedValue({
models: [],
error: null,
warning: null,
})
const model = {
id: 'model-1',
provider_model_name: 'provider-model-1',
provider_model_mappings: [],
} as Model
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ModelMappingDialog, {
open: true,
providerId: 'provider-1',
models: [model],
hasAutoFetchKey: true,
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await vi.waitFor(() => expect(upstreamModelMocks.fetchModels).toHaveBeenCalledTimes(1))
expect(upstreamModelMocks.fetchModels).toHaveBeenCalledWith('provider-1')
})
it('offers session compaction only for an explicitly selected Responses endpoint', async () => {
const chatEndpoint = {
id: 'endpoint-chat',
@@ -5,6 +5,9 @@ import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDial
const endpointMocks = vi.hoisted(() => ({
startProviderLevelOAuth: vi.fn(),
completeProviderLevelOAuth: vi.fn(),
authorizeProviderWithCookie: vi.fn(),
startProviderCookieAuthorizeTask: vi.fn(),
getProviderCookieAuthorizeTaskStatus: vi.fn(),
importProviderRefreshToken: vi.fn(),
startBatchImportOAuthTask: vi.fn(),
getBatchImportOAuthTaskStatus: vi.fn(),
@@ -46,9 +49,11 @@ vi.mock('@/components/ui', async () => {
modelValue: Boolean,
},
setup(props, { slots }) {
return () => props.modelValue
? h('section', [slots.headerActions?.(), slots.default?.(), slots.footer?.()])
: null
return () => {
if (!props.modelValue) return null
const headerActions = slots['header-actions'] ?? slots.headerActions
return h('section', [headerActions?.(), slots.default?.(), slots.footer?.()])
}
},
})
@@ -184,6 +189,7 @@ vi.mock('@/components/common/JsonImportInput.vue', async () => {
h('p', props.pasteToggleText),
h('p', props.fileToggleText),
h('textarea', {
'data-testid': 'import-textarea',
placeholder: props.manualPlaceholder,
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value),
@@ -195,14 +201,24 @@ vi.mock('@/components/common/JsonImportInput.vue', async () => {
})
vi.mock('@/components/ui/Label.vue', () => ({}))
vi.mock('./ProxyNodeSelect.vue', () => ({}))
vi.mock('@/features/providers/components/ProxyNodeSelect.vue', async () => {
vi.mock('../ProxyNodeSelect.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'ProxyNodeSelectStub',
setup() {
return () => h('div')
props: {
modelValue: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
setup(_, { emit }) {
return () => h('button', {
type: 'button',
'data-testid': 'proxy-node-select',
onClick: () => emit('update:modelValue', 'proxy-node-1'),
})
},
}),
}
@@ -288,17 +304,20 @@ function getExactButton(root: HTMLElement, text: string) {
}
function getImportTextarea(root: HTMLElement) {
const textarea = root.querySelector('textarea')
const textarea = root.querySelector('[data-testid="import-textarea"]')
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error('Expected import textarea to exist')
}
return textarea
}
describe('OAuthAccountDialog Grok import', () => {
describe('OAuthAccountDialog authorization and import', () => {
beforeEach(() => {
endpointMocks.startProviderLevelOAuth.mockReset()
endpointMocks.completeProviderLevelOAuth.mockReset()
endpointMocks.authorizeProviderWithCookie.mockReset()
endpointMocks.startProviderCookieAuthorizeTask.mockReset()
endpointMocks.getProviderCookieAuthorizeTaskStatus.mockReset()
endpointMocks.importProviderRefreshToken.mockReset()
endpointMocks.startBatchImportOAuthTask.mockReset()
endpointMocks.getBatchImportOAuthTaskStatus.mockReset()
@@ -309,6 +328,28 @@ describe('OAuthAccountDialog Grok import', () => {
toastMocks.warning.mockReset()
toastMocks.error.mockReset()
endpointMocks.startProviderLevelOAuth.mockResolvedValue({
authorization_url: 'https://claude.ai/oauth/authorize',
redirect_uri: 'https://platform.claude.com/oauth/code/callback',
provider_type: 'claude_code',
instructions: '',
})
endpointMocks.authorizeProviderWithCookie.mockResolvedValue({
key_id: 'key-claude-cookie',
provider_type: 'claude_code',
has_refresh_token: true,
email: '[email protected]',
replaced: false,
})
endpointMocks.startProviderCookieAuthorizeTask.mockResolvedValue({
task_id: 'claude-cookie-task-1',
status: 'submitted',
total: 2,
processed: 0,
success: 0,
failed: 0,
progress_percent: 0,
})
endpointMocks.importProviderRefreshToken.mockResolvedValue({
provider_type: 'grok',
has_refresh_token: false,
@@ -331,6 +372,7 @@ describe('OAuthAccountDialog Grok import', () => {
app.unmount()
root.remove()
}
vi.useRealTimers()
})
it('opens Grok in import mode without starting unsupported OAuth', async () => {
@@ -344,6 +386,275 @@ describe('OAuthAccountDialog Grok import', () => {
expect(getButton(root, '导入账号')).toBeTruthy()
})
it('shows Claude authorization modes in the required order', async () => {
const root = mountDialog('claude_code')
await settle()
await settle()
const modeLabels = Array.from(root.querySelectorAll('button'))
.map(button => button.textContent?.trim())
.filter(label => ['获取授权', 'Cookie授权', '导入授权'].includes(label || ''))
expect(modeLabels).toEqual(['获取授权', 'Cookie授权', '导入授权'])
expect(Array.from(root.querySelectorAll<HTMLTextAreaElement>('textarea')).map(
textarea => textarea.placeholder,
)).toContain('粘贴完整回调 URL 或授权码(code#state)')
const callbackTextarea = root.querySelector<HTMLTextAreaElement>(
'[data-testid="oauth-callback-textarea"]',
)
expect(callbackTextarea?.classList.contains('h-full')).toBe(true)
expect(callbackTextarea?.classList.contains('min-h-[120px]')).toBe(true)
expect(callbackTextarea?.parentElement?.classList.contains('flex-1')).toBe(true)
const cookieInput = root.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="每行粘贴一个 sessionKey Cookie 值或完整 Cookie 请求头,最多 20 个"]',
)
const cookiePanel = cookieInput?.closest('[inert]')
expect(cookiePanel?.getAttribute('aria-hidden')).toBe('true')
})
it('keeps Cookie authorization unavailable for non-Claude providers', async () => {
const root = mountDialog('codex')
await settle()
expect(getExactButton(root, 'Cookie授权')).toBeFalsy()
})
it('authorizes a Claude account with a cookie and selected proxy node', async () => {
const root = mountDialog('claude_code')
await settle()
getExactButton(root, 'Cookie授权')?.click()
await settle()
const cookieInput = root.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="每行粘贴一个 sessionKey Cookie 值或完整 Cookie 请求头,最多 20 个"]',
)
if (!cookieInput) throw new Error('Expected Claude cookie input to exist')
expect(cookieInput.classList.contains('min-h-[200px]')).toBe(true)
expect(cookieInput.classList.contains('h-[200px]')).toBe(true)
expect(cookieInput.parentElement?.classList.contains('relative')).toBe(true)
expect(root.querySelector('#claude-session-cookie-status')?.classList.contains('absolute')).toBe(true)
expect(cookieInput.style.getPropertyValue('-webkit-text-security')).toBe('')
expect(cookieInput.closest('[aria-hidden="true"]')).toBeNull()
expect(cookieInput.closest('[inert]')).toBeNull()
expect(root.querySelector('[data-testid="cookie-visibility-toggle"]')).toBeNull()
const authorizeButton = getExactButton(root, '授权')
expect(authorizeButton?.disabled).toBe(true)
const proxyNodeSelect = root.querySelector<HTMLButtonElement>('[data-testid="proxy-node-select"]')
expect(proxyNodeSelect).toBeTruthy()
proxyNodeSelect?.click()
await settle()
cookieInput.value = 'Cookie: sessionKey=claude-session-key'
cookieInput.dispatchEvent(new Event('input'))
await settle()
expect(authorizeButton?.disabled).toBe(false)
authorizeButton?.click()
await settle()
expect(endpointMocks.authorizeProviderWithCookie).toHaveBeenCalledWith('provider-1', {
cookie: 'Cookie: sessionKey=claude-session-key',
proxy_node_id: 'proxy-node-1',
})
expect(toastMocks.success).toHaveBeenCalled()
})
it('authorizes multiple Claude cookies through a task and keeps only failed lines', async () => {
vi.useFakeTimers()
endpointMocks.getProviderCookieAuthorizeTaskStatus.mockResolvedValueOnce({
task_id: 'claude-cookie-task-1',
provider_id: 'provider-1',
provider_type: 'claude_code',
status: 'completed',
total: 3,
processed: 3,
success: 2,
failed: 1,
created_count: 1,
replaced_count: 1,
progress_percent: 100,
message: null,
error: null,
error_samples: [{ index: 1, status: 'error', error: 'expired cookie' }],
created_at: 1,
finished_at: 2,
updated_at: 2,
})
const root = mountDialog('claude_code')
await settle()
getExactButton(root, 'Cookie授权')?.click()
await settle()
const cookieInput = root.querySelector<HTMLTextAreaElement>('[data-testid="claude-cookie-input"]')
if (!cookieInput) throw new Error('Expected Claude cookie input to exist')
cookieInput.value = [
'sessionKey=claude-session-1',
'',
'Cookie: sessionKey=expired-session',
'sessionKey=claude-session-3',
].join('\n')
cookieInput.dispatchEvent(new Event('input'))
await settle()
const batchButton = getExactButton(root, '批量授权')
expect(batchButton?.disabled).toBe(false)
batchButton?.click()
await settle()
expect(endpointMocks.startProviderCookieAuthorizeTask).toHaveBeenCalledWith('provider-1', {
cookies: [
'sessionKey=claude-session-1',
'Cookie: sessionKey=expired-session',
'sessionKey=claude-session-3',
],
proxy_node_id: undefined,
})
expect(getExactButton(root, '授权中...')).toBeTruthy()
await vi.runOnlyPendingTimersAsync()
await settle()
expect(endpointMocks.getProviderCookieAuthorizeTaskStatus).toHaveBeenCalledWith(
'provider-1',
'claude-cookie-task-1',
)
expect(cookieInput.value).toBe('Cookie: sessionKey=expired-session')
expect(toastMocks.warning).toHaveBeenCalledWith(
'批量授权完成:成功 2 个(新增 1 个,替换 1 个),失败 1 个;#2 expired cookie',
'批量授权',
)
expect(toastMocks.error).not.toHaveBeenCalled()
})
it('keeps all Claude cookie lines when a batch task has no successes', async () => {
vi.useFakeTimers()
endpointMocks.getProviderCookieAuthorizeTaskStatus.mockResolvedValueOnce({
task_id: 'claude-cookie-task-1',
provider_id: 'provider-1',
provider_type: 'claude_code',
status: 'completed',
total: 4,
processed: 4,
success: 0,
failed: 4,
created_count: 0,
replaced_count: 0,
progress_percent: 100,
message: null,
error: null,
error_samples: [
{ index: 0, status: 'error', error: 'sessionKey=must-not-leak' },
{ index: 1, status: 'error', error: 'invalid cookie' },
{ index: 2, status: 'error', error: 'expired cookie' },
{ index: 3, status: 'error', error: 'third safe reason' },
],
created_at: 1,
finished_at: 2,
updated_at: 2,
})
const root = mountDialog('claude_code')
await settle()
getExactButton(root, 'Cookie授权')?.click()
await settle()
const cookieInput = root.querySelector<HTMLTextAreaElement>('[data-testid="claude-cookie-input"]')
if (!cookieInput) throw new Error('Expected Claude cookie input to exist')
const originalInput = [
'sessionKey=secret',
'sessionKey=invalid',
'sessionKey=expired',
'sessionKey=other',
].join('\n')
cookieInput.value = originalInput
cookieInput.dispatchEvent(new Event('input'))
await settle()
getExactButton(root, '批量授权')?.click()
await settle()
await vi.runOnlyPendingTimersAsync()
await settle()
expect(cookieInput.value).toBe(originalInput)
expect(toastMocks.error).toHaveBeenCalledWith(
'批量授权完成:成功 0 个(新增 0 个,替换 0 个),失败 4 个;#2 invalid cookie;#3 expired cookie',
'错误',
)
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('must-not-leak')
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('third safe reason')
expect(toastMocks.warning).not.toHaveBeenCalled()
})
it('blocks Claude cookie batches over the 20-account limit', async () => {
const root = mountDialog('claude_code')
await settle()
getExactButton(root, 'Cookie授权')?.click()
await settle()
const cookieInput = root.querySelector<HTMLTextAreaElement>('[data-testid="claude-cookie-input"]')
if (!cookieInput) throw new Error('Expected Claude cookie input to exist')
cookieInput.value = Array.from({ length: 21 }, (_, index) => `sessionKey=claude-${index}`).join('\n')
cookieInput.dispatchEvent(new Event('input'))
await settle()
expect(getExactButton(root, '批量授权')?.disabled).toBe(true)
expect(root.querySelector('#claude-session-cookie-status')?.textContent?.trim())
.toBe('已输入 21 个,最多 20 个')
expect(endpointMocks.startProviderCookieAuthorizeTask).not.toHaveBeenCalled()
})
it('uses a Claude-specific import credential placeholder', async () => {
const root = mountDialog('claude_code')
await settle()
getExactButton(root, '导入授权')?.click()
await settle()
const textarea = root.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容"]',
)
expect(textarea).toBeTruthy()
})
it('imports only Claude OAuth credentials from a Claude Code credentials file', async () => {
const root = mountDialog('claude_code')
await settle()
getExactButton(root, '导入授权')?.click()
await settle()
const textarea = root.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容"]',
)
if (!textarea) throw new Error('Expected Claude credentials import textarea to exist')
textarea.value = JSON.stringify({
claudeAiOauth: {
accessToken: 'claude-access-token',
refreshToken: 'claude-refresh-token',
expiresAt: 4_102_444_800_000,
scopes: ['user:inference'],
},
mcpOAuth: {
accessToken: 'mcp-access-token-must-not-be-imported',
refreshToken: 'mcp-refresh-token-must-not-be-imported',
},
})
textarea.dispatchEvent(new Event('input'))
await settle()
getExactButton(root, '导入')?.click()
await settle()
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', {
access_token: 'claude-access-token',
refresh_token: 'claude-refresh-token',
expires_at: 4_102_444_800,
proxy_node_id: undefined,
})
})
it('maps a single Grok JSON token into account metadata import payload', async () => {
const root = mountDialog('grok')
await settle()
@@ -20,6 +20,49 @@ vi.mock('@/api/endpoints', () => ({
},
}))
vi.mock('@/components/ui', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/components/ui')>()
const { defineComponent, h } = await import('vue')
const passthrough = (name: string) => defineComponent({
name,
setup: (_props, { slots }) => () => slots.default?.(),
})
return {
...actual,
Select: defineComponent({
name: 'SelectStub',
props: {
modelValue: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup: (props, { emit, slots }) => () => h('select', {
value: props.modelValue,
disabled: props.disabled,
onChange: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLSelectElement).value,
),
}, slots.default?.()),
}),
SelectTrigger: passthrough('SelectTriggerStub'),
SelectValue: passthrough('SelectValueStub'),
SelectContent: passthrough('SelectContentStub'),
SelectItem: defineComponent({
name: 'SelectItemStub',
props: {
value: { type: String, required: true },
disabled: Boolean,
},
setup: (props, { slots }) => () => h('option', {
value: props.value,
disabled: props.disabled,
}, slots.default?.()),
}),
}
})
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
@@ -175,3 +218,35 @@ describe('ProviderFormDialog transfer limits', () => {
)
})
})
describe('ProviderFormDialog provider types', () => {
it('creates an experimental Claude Code provider from the add dialog', async () => {
mountDialog(null)
await settle()
const providerTypeSelect = [...document.body.querySelectorAll<HTMLSelectElement>('select')]
.find(select => select.querySelector('option[value="claude_code"]'))
const claudeCodeOption = providerTypeSelect?.querySelector<HTMLOptionElement>(
'option[value="claude_code"]',
)
expect(claudeCodeOption?.disabled).toBe(false)
expect(claudeCodeOption?.textContent?.trim()).toBe('Claude Code(实验性功能)')
await setInput('#name', 'Claude Code Provider')
if (!providerTypeSelect) throw new Error('Missing provider type select')
providerTypeSelect.value = 'claude_code'
providerTypeSelect.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
clickButton('创建')
await settle()
expect(endpointMocks.createProvider).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Claude Code Provider',
provider_type: 'claude_code',
}),
)
})
})
@@ -304,6 +304,7 @@
<!-- 添加/编辑映射对话框 -->
<ModelMappingDialog
v-if="dialogOpen"
v-model:open="dialogOpen"
:provider-id="provider.id"
:models="models"