feat(providers): expand OAuth account management

Add Claude Code manual and cookie authorization, including redacted batch tasks. Harden OAuth imports, duplicate replacement, provider dialogs, and related account-management tests.
This commit is contained in:
elky
2026-07-27 15:53:28 +08:00
parent 531cf11025
commit 550cc36760
55 changed files with 4957 additions and 403 deletions
@@ -13,12 +13,17 @@ vi.mock('@/api/client', () => ({
}))
import {
authorizeProviderWithCookie,
getProviderCookieAuthorizeTaskStatus,
getBatchImportOAuthTaskStatus,
importProviderRefreshToken,
startProviderCookieAuthorizeTask,
startBatchImportOAuthTask,
} from '@/api/endpoints/provider_oauth'
describe('Agent Identity OAuth management routes', () => {
const CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS = 4 * 60 * 1000
describe('Provider OAuth management routes', () => {
beforeEach(() => {
getMock.mockReset()
postMock.mockReset()
@@ -137,4 +142,37 @@ describe('Agent Identity OAuth management routes', () => {
},
)
})
it('allows the sequential Claude Cookie exchange to outlive the global timeout', async () => {
const payload = {
cookie: 'sessionKey=claude-session-key',
proxy_node_id: 'proxy-1',
}
await authorizeProviderWithCookie('provider-claude', payload)
expect(postMock).toHaveBeenCalledWith(
'/api/admin/provider-oauth/providers/provider-claude/cookie-authorize',
payload,
{ timeout: CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS },
)
})
it('starts and polls Claude Cookie batches through the dedicated task routes', async () => {
const payload = {
cookies: ['sessionKey=claude-1', 'sessionKey=claude-2'],
proxy_node_id: 'proxy-1',
}
await startProviderCookieAuthorizeTask('provider-claude', payload)
await getProviderCookieAuthorizeTaskStatus('provider-claude', 'claude-cookie-task-1')
expect(postMock).toHaveBeenCalledWith(
'/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks',
payload,
)
expect(getMock).toHaveBeenCalledWith(
'/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks/claude-cookie-task-1',
)
})
})
@@ -1,5 +1,7 @@
import client from '../client'
const CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS = 4 * 60 * 1000
export interface ProviderOAuthStartResponse {
authorization_url: string
redirect_uri: string
@@ -36,6 +38,17 @@ export interface ProviderOAuthCompleteResponseWithKey {
detail?: string
}
export interface ProviderCookieAuthorizeRequest {
cookie: string
name?: string
proxy_node_id?: string
}
export interface ProviderCookieAuthorizeBatchTaskRequest {
cookies: string[]
proxy_node_id?: string
}
export interface OAuthBatchImportResultItem {
index: number
status: 'success' | 'error'
@@ -47,9 +60,11 @@ export interface OAuthBatchImportResultItem {
}
export type OAuthBatchImportTaskStatus = 'submitted' | 'processing' | 'completed' | 'failed'
export type OAuthBatchImportKind = 'oauth_batch' | 'agent_identity' | 'cookie_authorize'
export interface OAuthBatchImportTaskStartResponse {
task_id: string
import_kind?: OAuthBatchImportKind
status: OAuthBatchImportTaskStatus
total: number
processed: number
@@ -63,6 +78,7 @@ export interface OAuthBatchImportTaskStartResponse {
export interface OAuthBatchImportTaskStatusResponse {
task_id: string
import_kind?: OAuthBatchImportKind
provider_id: string
provider_type: string
status: OAuthBatchImportTaskStatus
@@ -233,6 +249,39 @@ export async function completeProviderLevelOAuth(
return resp.data
}
export async function authorizeProviderWithCookie(
providerId: string,
data: ProviderCookieAuthorizeRequest
): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize`,
data,
{ timeout: CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS },
)
return resp.data
}
export async function startProviderCookieAuthorizeTask(
providerId: string,
data: ProviderCookieAuthorizeBatchTaskRequest,
): Promise<OAuthBatchImportTaskStartResponse> {
const resp = await client.post(
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize/tasks`,
data,
)
return resp.data
}
export async function getProviderCookieAuthorizeTaskStatus(
providerId: string,
taskId: string,
): Promise<OAuthBatchImportTaskStatusResponse> {
const resp = await client.get(
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize/tasks/${taskId}`,
)
return resp.data
}
export async function importProviderRefreshToken(
providerId: string,
data: {
@@ -10,52 +10,65 @@
>
<div
v-if="!showManualInput"
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
:class="isDragging
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40'"
@click="fileInputRef?.click()"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop"
class="grid [&>*]:col-start-1 [&>*]:row-start-1"
data-testid="json-import-mode-panels"
>
<div class="flex flex-col items-center justify-center py-10 gap-2">
<div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
<Upload class="w-4 h-4 text-muted-foreground" />
</div>
<div class="text-center">
<p class="text-xs font-medium">
{{ localizedDropTitle }}
</p>
<p class="text-[11px] text-muted-foreground mt-0.5">
{{ localizedDropHint }}
</p>
<div
class="rounded-xl border-2 border-dashed transition-all duration-150 cursor-pointer"
:class="[
isDragging
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40',
showManualInput ? 'opacity-0 pointer-events-none' : 'opacity-100',
]"
:inert="showManualInput ? '' : undefined"
:aria-hidden="showManualInput"
data-testid="json-import-file-panel"
@click="fileInputRef?.click()"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop"
>
<div class="flex h-full flex-col items-center justify-center py-10 gap-2">
<div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
<Upload class="w-4 h-4 text-muted-foreground" />
</div>
<div class="text-center">
<p class="text-xs font-medium">
{{ localizedDropTitle }}
</p>
<p class="text-[11px] text-muted-foreground mt-0.5">
{{ localizedDropHint }}
</p>
</div>
</div>
</div>
</div>
<div
v-else
class="space-y-1.5"
>
<Label v-if="manualLabel">
{{ localizedManualLabel }}
</Label>
<Textarea
:model-value="modelValue"
:disabled="disabled"
:placeholder="localizedManualPlaceholder"
:class="textareaClass"
spellcheck="false"
@update:model-value="emit('update:modelValue', $event)"
/>
<p
v-if="manualDescription"
class="text-xs text-muted-foreground"
<div
class="space-y-1.5 transition-opacity duration-150"
:class="showManualInput ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="showManualInput ? undefined : ''"
:aria-hidden="!showManualInput"
data-testid="json-import-manual-panel"
>
{{ localizedManualDescription }}
</p>
<Label v-if="manualLabel">
{{ localizedManualLabel }}
</Label>
<Textarea
:model-value="modelValue"
:disabled="disabled"
:placeholder="localizedManualPlaceholder"
:class="textareaClass"
spellcheck="false"
@update:model-value="emit('update:modelValue', $event)"
/>
<p
v-if="manualDescription"
class="text-xs text-muted-foreground"
>
{{ localizedManualDescription }}
</p>
</div>
</div>
<div class="flex items-center justify-center pt-1">
@@ -63,6 +76,7 @@
v-if="!showManualInput"
type="button"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
data-testid="json-import-mode-toggle"
@click="showManualInput = true"
>
{{ localizedPasteToggleText }}
@@ -71,6 +85,7 @@
v-else
type="button"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
data-testid="json-import-mode-toggle"
@click="switchToFileMode"
>
{{ localizedFileToggleText }}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
import JsonImportInput from '@/components/common/JsonImportInput.vue'
import { createI18n } from '@/i18n'
describe('JsonImportInput', () => {
it('keeps both mode panels in one layout track while switching modes', async () => {
const value = ref('')
const Host = defineComponent({
setup() {
return () => h(JsonImportInput, {
modelValue: value.value,
'onUpdate:modelValue': (nextValue: string) => {
value.value = nextValue
},
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.use(createI18n())
app.mount(root)
const panels = root.querySelector<HTMLElement>('[data-testid="json-import-mode-panels"]')
const filePanel = root.querySelector<HTMLElement>('[data-testid="json-import-file-panel"]')
const manualPanel = root.querySelector<HTMLElement>('[data-testid="json-import-manual-panel"]')
expect(panels?.classList.contains('grid')).toBe(true)
expect(filePanel?.getAttribute('aria-hidden')).toBe('false')
expect(filePanel?.hasAttribute('inert')).toBe(false)
expect(manualPanel?.getAttribute('aria-hidden')).toBe('true')
expect(manualPanel?.getAttribute('inert')).toBe('')
root.querySelector<HTMLButtonElement>('[data-testid="json-import-mode-toggle"]')?.click()
await nextTick()
expect(root.querySelector('[data-testid="json-import-file-panel"]')).toBe(filePanel)
expect(root.querySelector('[data-testid="json-import-manual-panel"]')).toBe(manualPanel)
expect(filePanel?.getAttribute('aria-hidden')).toBe('true')
expect(filePanel?.getAttribute('inert')).toBe('')
expect(manualPanel?.getAttribute('aria-hidden')).toBe('false')
expect(manualPanel?.hasAttribute('inert')).toBe(false)
const textarea = root.querySelector<HTMLTextAreaElement>('textarea')
if (!textarea) throw new Error('Expected manual textarea')
textarea.value = 'credential-value'
textarea.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(value.value).toBe('credential-value')
root.querySelector<HTMLButtonElement>('[data-testid="json-import-mode-toggle"]')?.click()
await nextTick()
expect(value.value).toBe('')
expect(filePanel?.hasAttribute('inert')).toBe(false)
expect(manualPanel?.getAttribute('inert')).toBe('')
app.unmount()
root.remove()
})
})
@@ -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=... 的回调 URLsession 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: 'claude@example.com',
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"
+12 -2
View File
@@ -1401,7 +1401,7 @@ const legacyExactEnglishMessages: Record<string, string> = {
'例如: OpenAI 主账号': 'Example: OpenAI primary account',
'提供商类型': 'Provider type',
'请选择': 'Select',
'ClaudeCode暂不可用': 'ClaudeCode (temporarily unavailable)',
'Claude Code实验性功能': 'Claude Code (experimental feature)',
'反代使用固定端点且不可修改': 'Reverse proxy providers use fixed endpoints that cannot be modified.',
'主站链接': 'Website URL',
'https://example.com(可选)': 'https://example.com (optional)',
@@ -1427,7 +1427,6 @@ const legacyExactEnglishMessages: Record<string, string> = {
'启用后仅对 Kiro 请求模拟 prompt cache 读写计量。': 'When enabled, only Kiro requests simulate prompt cache read/write accounting.',
'请前往模块管理-敏感信息保护中配置详细规则。': 'Configure detailed rules in Modules - Sensitive information protection.',
'验证失败': 'Validation failed',
'ClaudeCode 提供商类型暂时禁用': 'ClaudeCode provider type is temporarily disabled',
'月卡类型必须设置周期开始时间': 'Monthly quota billing requires a cycle start time',
'周期开始时间必须是合法时间': 'Cycle start time must be a valid time',
'过期时间必须是合法时间': 'Expiration time must be a valid time',
@@ -1585,6 +1584,14 @@ const legacyExactEnglishMessages: Record<string, string> = {
'添加账号': 'Add account',
'授权': 'Authorization',
'获取授权': 'Authorize',
'Cookie授权': 'Cookie authorization',
'Claude sessionKey Cookie': 'Claude sessionKey cookie',
'粘贴 sessionKey Cookie 值或完整 Cookie 请求头': 'Paste a sessionKey cookie value or a complete Cookie header',
'每行粘贴一个 sessionKey Cookie 值或完整 Cookie 请求头,最多 20 个': 'Paste one sessionKey cookie value or complete Cookie header per line, up to 20',
'每行一个,最多 20 个': 'One per line, up to 20',
'批量授权': 'Batch authorize',
'Cookie 授权失败': 'Cookie authorization failed',
'授权中...': 'Authorizing...',
'浏览器登录': 'Browser sign-in',
'设备授权': 'Device authorization',
'导入授权': 'Import authorization',
@@ -1612,6 +1619,8 @@ const legacyExactEnglishMessages: Record<string, string> = {
'打开': 'Open',
'开始': 'Start',
'粘贴回调 URL': 'Paste callback URL',
'粘贴回调 URL 或授权码': 'Paste callback URL or authorization code',
'粘贴完整回调 URL 或授权码(code#state': 'Paste the full callback URL or authorization code (code#state)',
'粘贴回调 URL 或 token': 'Paste callback URL or token',
'粘贴包含 token=...&state=... 的回调 URLsession token/apiKey 也可直接粘贴,普通 token 请用导入授权': 'Paste a callback URL containing token=...&state=...; session token/apiKey can also be pasted directly. Use import authorization for regular tokens.',
'在浏览器中完成授权': 'Complete authorization in the browser',
@@ -1633,6 +1642,7 @@ const legacyExactEnglishMessages: Record<string, string> = {
'支持 .json / .txt,可多选': 'Supports .json / .txt and multiple selection',
'粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON': 'Paste Grok sso/session tokens, one per line, or paste JSON containing token, sso_token, access_token, plan_type, and pool_tier.',
'粘贴 show-auth-token Token、API key 或 JSON 内容': 'Paste a show-auth-token token, API key, or JSON content',
'粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容': 'Paste a Claude refresh token or Claude Code .credentials.json',
'粘贴 Refresh Token / Access Token / Agent Identity JSON 内容': 'Paste a Refresh Token, Access Token, or Agent Identity JSON',
'plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。': 'plan_type / pool_tier are saved as account plan and capability traits, not as routing pool selection.',
'或手动粘贴 Grok Token': 'Or manually paste a Grok token',
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config/demo', () => ({
isDemoMode: () => true,
DEMO_ACCOUNTS: {
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
user: { email: 'user@demo.aether.io', password: 'demo123' },
},
}))
import { handleMockRequest, setMockUserToken } from '../handler'
describe('Claude Cookie authorization demo contracts', () => {
beforeEach(() => {
setMockUserToken('demo-access-token-admin')
})
it('keeps the single Cookie authorization response compatible', async () => {
const response = await handleMockRequest({
method: 'POST',
url: '/api/admin/provider-oauth/providers/provider-claude/cookie-authorize',
data: JSON.stringify({ cookie: 'sessionKey=single-secret' }),
})
expect(response?.data).toMatchObject({
provider_type: 'claude_code',
has_refresh_token: true,
})
expect(JSON.stringify(response?.data)).not.toContain('single-secret')
})
it('starts and reads a Cookie batch task without returning Cookie values', async () => {
const startResponse = await handleMockRequest({
method: 'POST',
url: '/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks',
data: JSON.stringify({
cookies: ['sessionKey=success-secret', 'sessionKey=mock-fail-secret'],
}),
})
const start = startResponse?.data as { task_id: string }
expect(startResponse?.data).toMatchObject({
import_kind: 'cookie_authorize',
status: 'submitted',
total: 2,
processed: 0,
})
const statusResponse = await handleMockRequest({
method: 'GET',
url: `/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks/${start.task_id}`,
})
expect(statusResponse?.data).toMatchObject({
task_id: start.task_id,
provider_id: 'provider-claude',
provider_type: 'claude_code',
import_kind: 'cookie_authorize',
status: 'completed',
total: 2,
success: 1,
failed: 1,
error_samples: [{ index: 1, status: 'error' }],
})
expect(JSON.stringify(statusResponse?.data)).not.toContain('success-secret')
expect(JSON.stringify(statusResponse?.data)).not.toContain('mock-fail-secret')
})
})
+79
View File
@@ -3100,6 +3100,85 @@ registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/co
})
})
const mockClaudeCookieAuthorizeTasks = new Map<string, Record<string, unknown>>()
let mockClaudeCookieAuthorizeTaskSequence = 0
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/cookie-authorize/tasks', async (config, params) => {
await delay()
requireAdmin()
const body = JSON.parse(config.data || '{}')
const cookies = Array.isArray(body.cookies)
? body.cookies.filter((cookie: unknown): cookie is string => typeof cookie === 'string' && cookie.trim().length > 0)
: []
const errorSamples = cookies.flatMap((cookie: string, index: number) => (
cookie.includes('mock-fail')
? [{ index, status: 'error', error: '演示模式:Cookie 授权失败', replaced: false }]
: []
))
const failed = errorSamples.length
const success = cookies.length - failed
const now = Math.floor(Date.now() / 1000)
const taskId = `claude-cookie-${Date.now()}-${++mockClaudeCookieAuthorizeTaskSequence}`
mockClaudeCookieAuthorizeTasks.set(taskId, {
task_id: taskId,
provider_id: params.providerId,
provider_type: 'claude_code',
import_kind: 'cookie_authorize',
status: 'completed',
total: cookies.length,
processed: cookies.length,
success,
failed,
created_count: success,
replaced_count: 0,
progress_percent: 100,
message: null,
error: null,
error_samples: errorSamples,
created_at: now,
started_at: now,
finished_at: now,
updated_at: now,
})
return createMockResponse({
task_id: taskId,
import_kind: 'cookie_authorize',
status: 'submitted',
total: cookies.length,
processed: 0,
success: 0,
failed: 0,
created_count: 0,
replaced_count: 0,
progress_percent: 0,
message: '任务已提交',
})
})
registerDynamicRoute('GET', '/api/admin/provider-oauth/providers/:providerId/cookie-authorize/tasks/:taskId', async (_config, params) => {
await delay()
requireAdmin()
const task = mockClaudeCookieAuthorizeTasks.get(params.taskId)
if (!task || task.provider_id !== params.providerId) {
throw { response: createMockResponse({ detail: 'Cookie 授权任务不存在' }, 404) }
}
return createMockResponse(task)
})
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/cookie-authorize', async (config, _params) => {
await delay()
requireAdmin()
const body = JSON.parse(config.data || '{}')
return createMockResponse({
key_id: `key-claude-cookie-${Date.now()}`,
provider_type: 'claude_code',
expires_at: Math.floor(Date.now() / 1000) + 24 * 3600,
has_refresh_token: true,
email: body.name ? `${body.name}@demo.dev` : 'claude-oauth-demo@aether.dev'
})
})
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/import-refresh-token', async (config, _params) => {
await delay()
requireAdmin()