Merge commit 'refs/pull/481/head' of github-fawney19:fawney19/Aether

# Conflicts:
#	apps/aether-gateway/src/ai_serving/api.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs
This commit is contained in:
fawney19
2026-05-19 01:46:41 +08:00
124 changed files with 14402 additions and 702 deletions

View File

@@ -51,7 +51,7 @@
@update:model-value="(v: string) => { selectedProxyNodeId = v; proxyPopoverOpen = false }"
/>
<p class="text-[10px] text-muted-foreground">
{{ selectedProxyNodeId ? '授权、刷新、额度查询均走此代理' : '未设置,依次回退到提供商代理 → 系统代理' }}
{{ selectedProxyNodeId ? `${providerCredentialActionLabel}、刷新、额度查询均走此代理` : '未设置,依次回退到提供商代理 → 系统代理' }}
</p>
</div>
</PopoverContent>
@@ -60,7 +60,10 @@
<div class="space-y-4">
<!-- Tab 切换 -->
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
<div
v-if="showAuthorizationMode"
class="flex rounded-lg border border-border p-0.5 bg-muted/30"
>
<button
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
:class="[
@@ -79,7 +82,7 @@
: 'text-muted-foreground hover:text-foreground'"
@click="switchMode('import')"
>
导入授权
{{ importModeLabel }}
</button>
</div>
@@ -458,11 +461,12 @@
v-model="importText"
:disabled="importing"
:reset-key="importInputResetKey"
drop-title="拖入授权文件或点击选择"
drop-hint="支持 .json / .txt可多选"
manual-placeholder="粘贴 Refresh Token / Access Token JSON 内容"
paste-toggle-text="或手动粘贴 Token"
file-toggle-text="或选择 JSON 文件导入"
:drop-title="importDropTitle"
:drop-hint="importDropHint"
:manual-placeholder="importManualPlaceholder"
:manual-description="importManualDescription"
:paste-toggle-text="importPasteToggleText"
:file-toggle-text="importFileToggleText"
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
@error="handleImportInputError"
/>
@@ -523,7 +527,7 @@
取消
</Button>
<Button
v-if="mode === 'oauth' && !isKiroProvider"
v-if="mode === 'oauth' && showAuthorizationMode && !isKiroProvider"
:disabled="!canCompleteOAuth"
@click="handleCompleteOAuth"
>
@@ -541,7 +545,7 @@
:disabled="!canImport"
@click="handleImport"
>
{{ importing ? (importTask ? `导入中 ${importTask.progress_percent}%` : '导入中...') : '导入' }}
{{ importing ? (importTask ? `导入中 ${importTask.progress_percent}%` : '导入中...') : importButtonLabel }}
</Button>
</template>
</Dialog>
@@ -644,7 +648,7 @@ function getSelectedNodeLabel(): string {
// 模式
type DialogMode = 'oauth' | 'import'
const mode = ref<DialogMode>('oauth')
const mode = ref<DialogMode>((props.providerType || '').toLowerCase() === 'grok' ? 'import' : 'oauth')
// OAuth 状态
interface OAuthState {
@@ -736,6 +740,9 @@ const importPolling = ref(false)
const isOpen = computed(() => props.open)
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
const showAuthorizationMode = computed(() => !isGrokProvider.value)
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
const isSocialDeviceAuth = computed(() =>
device.value.auth_type === 'google' || device.value.auth_type === 'github'
@@ -782,6 +789,32 @@ const canImport = computed(() => {
return importText.value.trim().length > 0 && !importing.value
})
const importModeLabel = computed(() => (isGrokProvider.value ? '导入账号' : '导入授权'))
const importButtonLabel = computed(() => (isGrokProvider.value ? '导入账号' : '导入'))
const importDropTitle = computed(() => (
isGrokProvider.value ? '拖入 Grok 账号文件或点击选择' : '拖入授权文件或点击选择'
))
const importDropHint = computed(() => (
isGrokProvider.value ? '支持 .json / .txt可多选、批量导入' : '支持 .json / .txt可多选'
))
const importManualPlaceholder = computed(() => (
isGrokProvider.value
? '粘贴 Grok sso/session token支持每行一个或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON'
: '粘贴 Refresh Token / Access Token 或 JSON 内容'
))
const importManualDescription = computed(() => (
isGrokProvider.value
? 'plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。'
: ''
))
const importPasteToggleText = computed(() => (
isGrokProvider.value ? '或手动粘贴 Grok Token' : '或手动粘贴 Token'
))
const importFileToggleText = computed(() => (
isGrokProvider.value ? '或选择 Grok Token 文件导入' : '或选择 JSON 文件导入'
))
const providerCredentialActionLabel = computed(() => (isGrokProvider.value ? '导入' : '授权'))
function stopImportPolling() {
if (importPollTimer) {
clearTimeout(importPollTimer)
@@ -923,7 +956,7 @@ function resetDeviceRuntimeState() {
device.value.error = ''
}
function isKiroDeviceAuthOptionDisabled(authType: DeviceAuthType): boolean {
function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
if (device.value.starting) {
return !isSocialDeviceAuth.value
}
@@ -976,11 +1009,12 @@ function resetForm() {
importInputResetKey.value += 1
proxyPopoverOpen.value = false
selectedProxyNodeId.value = ''
mode.value = 'oauth'
mode.value = defaultMode.value
}
function switchMode(newMode: DialogMode) {
if (mode.value === newMode) return
if (newMode === 'oauth' && !showAuthorizationMode.value) return
mode.value = newMode
if (newMode === 'oauth') {
@@ -1011,6 +1045,7 @@ function openAuthorizationUrl() {
async function initOAuth() {
if (!props.providerId) return
if (!showAuthorizationMode.value) return
if (isKiroProvider.value) return
if (oauth.value.starting) return
@@ -1095,6 +1130,12 @@ function parseImportText(text: string): {
account_id?: string
account_user_id?: string
plan_type?: string
pool_tier?: string
sso_rw_token?: string
cf_cookies?: string
cf_clearance?: string
user_agent?: string
browser_profile?: string
user_id?: string
account_name?: string
} | null {
@@ -1106,30 +1147,50 @@ function parseImportText(text: string): {
return { refresh_token: trimmed }
}
if (isGrokProvider.value) {
const cookieImport = parseGrokCookieImport(trimmed)
if (cookieImport) {
return cookieImport
}
}
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>
const grokCookieImport = isGrokProvider.value
? parseGrokCookieImport(normalizeStringField(obj.cookie) ?? normalizeStringField(obj.cookieHeader) ?? '')
: null
const refreshToken = obj.refresh_token
const refreshTokenCamel = obj.refreshToken
const accessToken = obj.access_token
const accessTokenCamel = obj.accessToken
const grokSsoToken = isGrokProvider.value
? normalizeStringField(obj.sso_token) ?? normalizeStringField(obj.ssoToken) ?? normalizeStringField(obj.token) ?? grokCookieImport?.access_token
: undefined
const normalizedRefreshToken = typeof refreshToken === 'string' && refreshToken.trim()
? refreshToken.trim()
: (typeof refreshTokenCamel === 'string' && refreshTokenCamel.trim() ? refreshTokenCamel.trim() : undefined)
const normalizedAccessToken = typeof accessToken === 'string' && accessToken.trim()
? accessToken.trim()
: (typeof accessTokenCamel === 'string' && accessTokenCamel.trim() ? accessTokenCamel.trim() : undefined)
if (normalizedRefreshToken || normalizedAccessToken) {
const importedAccessToken = normalizedAccessToken ?? grokSsoToken
if (normalizedRefreshToken || importedAccessToken) {
return {
refresh_token: normalizedRefreshToken,
access_token: normalizedAccessToken,
access_token: importedAccessToken,
expires_at: normalizeNumberField(obj.expires_at) ?? normalizeNumberField(obj.expiresAt),
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
email: normalizeStringField(obj.email) ?? normalizeStringField(obj.oauth_email),
account_id: normalizeStringField(obj.account_id) ?? normalizeStringField(obj.accountId) ?? normalizeStringField(obj.chatgpt_account_id) ?? normalizeStringField(obj.chatgptAccountId),
account_user_id: normalizeStringField(obj.account_user_id) ?? normalizeStringField(obj.accountUserId) ?? normalizeStringField(obj.chatgpt_account_user_id) ?? normalizeStringField(obj.chatgptAccountUserId),
plan_type: normalizeStringField(obj.plan_type) ?? normalizeStringField(obj.planType) ?? normalizeStringField(obj.chatgpt_plan_type) ?? normalizeStringField(obj.chatgptPlanType),
pool_tier: isGrokProvider.value ? normalizeStringField(obj.pool_tier) ?? normalizeStringField(obj.poolTier) ?? normalizeStringField(obj.tier) : undefined,
sso_rw_token: isGrokProvider.value ? normalizeStringField(obj.sso_rw_token) ?? normalizeStringField(obj.ssoRwToken) ?? grokCookieImport?.sso_rw_token : undefined,
cf_cookies: isGrokProvider.value ? normalizeStringField(obj.cf_cookies) ?? normalizeStringField(obj.cfCookies) ?? grokCookieImport?.cf_cookies : undefined,
cf_clearance: isGrokProvider.value ? normalizeStringField(obj.cf_clearance) ?? normalizeStringField(obj.cfClearance) ?? grokCookieImport?.cf_clearance : undefined,
user_agent: isGrokProvider.value ? normalizeStringField(obj.user_agent) ?? normalizeStringField(obj.userAgent) ?? grokCookieImport?.user_agent : undefined,
browser_profile: isGrokProvider.value ? normalizeStringField(obj.browser_profile) ?? normalizeStringField(obj.browserProfile) ?? normalizeStringField(obj.browser) ?? normalizeStringField(obj.impersonate) ?? grokCookieImport?.browser_profile : undefined,
user_id: normalizeStringField(obj.user_id) ?? normalizeStringField(obj.userId) ?? normalizeStringField(obj.chatgpt_user_id) ?? normalizeStringField(obj.chatgptUserId),
account_name: normalizeStringField(obj.account_name) ?? normalizeStringField(obj.accountName),
}
@@ -1147,6 +1208,72 @@ function parseImportText(text: string): {
return { refresh_token: trimmed }
}
function parseGrokCookieImport(text: string): {
access_token: string
sso_rw_token?: string
cf_cookies?: string
cf_clearance?: string
user_agent?: string
browser_profile?: string
user_id?: string
} | null {
const cookies = parseCookieHeader(text)
const sso = cookies.get('sso')
if (!sso) return null
const userAgent = currentBrowserUserAgent()
return {
access_token: sso,
sso_rw_token: cookies.get('sso-rw'),
cf_cookies: buildGrokCookieProfile(cookies),
cf_clearance: cookies.get('cf_clearance'),
user_agent: userAgent,
browser_profile: inferGrokBrowserProfile(userAgent),
user_id: cookies.get('x-userid'),
}
}
function currentBrowserUserAgent(): string | undefined {
const value = typeof navigator !== 'undefined' ? navigator.userAgent?.trim() : ''
return value || undefined
}
function inferGrokBrowserProfile(userAgent: string | undefined): string | undefined {
const value = (userAgent || '').toLowerCase()
if (!value) return 'chrome136'
if (value.includes('firefox/')) return 'firefox'
if (value.includes('safari/') && !value.includes('chrome/') && !value.includes('chromium/')) {
return value.includes('iphone') || value.includes('ipad') ? 'safari_ios' : 'safari'
}
return 'chrome136'
}
function buildGrokCookieProfile(cookies: Map<string, string>): string | undefined {
const parts: string[] = []
for (const [name, value] of cookies) {
if (name === 'sso' || name === 'sso-rw') continue
parts.push(`${name}=${value}`)
}
return parts.length > 0 ? parts.join('; ') : undefined
}
function parseCookieHeader(text: string): Map<string, string> {
const normalized = text.trim().replace(/^cookie:\s*/i, '')
const cookies = new Map<string, string>()
for (const segment of normalized.split(';')) {
const part = segment.trim()
if (!part) continue
const separator = part.indexOf('=')
if (separator <= 0) continue
const name = part.slice(0, separator).trim().toLowerCase()
const value = part.slice(separator + 1).trim()
if (name && value) {
cookies.set(name, value)
}
}
return cookies
}
function normalizeStringField(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
@@ -1404,6 +1531,10 @@ onBeforeUnmount(() => {
watch(() => props.open, (newOpen) => {
if (newOpen) {
proxyNodesStore.ensureLoaded()
mode.value = defaultMode.value
if (!showAuthorizationMode.value) {
return
}
if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else {
@@ -1417,6 +1548,10 @@ watch(() => props.open, (newOpen) => {
watch(
() => [props.open, props.providerId, props.providerType] as const,
() => {
if (props.open && !showAuthorizationMode.value) {
mode.value = 'import'
return
}
if (props.open && isKiroProvider.value && mode.value === 'oauth') {
void ensureKiroSocialDeviceAuth()
}

View File

@@ -858,6 +858,7 @@ const PROVIDER_TYPE_LABELS: Record<string, string> = {
gemini_cli: 'Gemini CLI',
antigravity: 'Antigravity',
kiro: 'Kiro',
grok: 'Grok',
}
function formatProviderType(type?: string): string {

View File

@@ -346,7 +346,7 @@
<Copy class="w-2.5 h-2.5" />
</Button>
<!-- OAuth 状态失效/过期/倒计时和刷新按钮 -->
<template v-if="shouldShowOAuthRefreshControl(key)">
<template v-if="shouldShowOAuthRefreshControl(key, provider.provider_type)">
<!-- 账号级别异常醒目提示 + 清除按钮 -->
<template v-if="isAccountLevelBlock(key)">
<Badge
@@ -1293,6 +1293,7 @@ import type {
AntigravityModelQuota,
CodexUpstreamMetadata,
ChatGPTWebUpstreamMetadata,
GrokUpstreamMetadata,
KiroUpstreamMetadata,
QuotaStatusSnapshot,
QuotaWindowSnapshot,
@@ -1964,7 +1965,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
function getQuotaSnapshotForProvider(
key: EndpointAPIKey,
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli',
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
): QuotaStatusSnapshot | null {
const quota = key.status_snapshot?.quota
if (!quota) return null
@@ -2168,6 +2169,66 @@ function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
}
type GrokQuotaDisplay = GrokUpstreamMetadata & {
usage_percentage?: number
usage_limit?: number
current_usage?: number
remaining?: number
next_reset_at?: number
}
function getGrokQuotaDisplay(key: EndpointAPIKey): GrokQuotaDisplay | null {
const quota = getQuotaSnapshotForProvider(key, 'grok')
if (!quota) return null
const display: GrokQuotaDisplay = {}
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
if (updatedAt !== undefined) display.updated_at = updatedAt
if (quota.plan_type) display.plan_type = quota.plan_type
if (quota.pool_tier) display.pool_tier = quota.pool_tier
const code = String(quota.code || '').trim().toLowerCase()
if (code === 'banned' || code === 'forbidden') {
display.is_banned = true
if (quota.reason) display.ban_reason = quota.reason
}
const usageWindow =
getQuotaWindow(quota, 'usage')
?? getQuotaWindowByScope(quota, 'account')[0]
?? getQuotaWindowByScope(quota, 'model')
.map(window => ({
window,
remainingPercent: getQuotaWindowRemainingPercent(window),
}))
.filter((item): item is { window: QuotaWindowSnapshot, remainingPercent: number } => item.remainingPercent !== undefined)
.sort((a, b) => a.remainingPercent - b.remainingPercent)[0]?.window
?? null
if (usageWindow) {
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
if (usedPercent !== undefined) display.usage_percentage = usedPercent
if (typeof usageWindow.used_value === 'number') display.current_usage = usageWindow.used_value
if (typeof usageWindow.limit_value === 'number') display.usage_limit = usageWindow.limit_value
if (typeof usageWindow.remaining_value === 'number') display.remaining = usageWindow.remaining_value
const nextResetAt =
getQuotaWindowResetAt(usageWindow)
?? (() => {
const resetSeconds = getQuotaWindowResetSeconds(usageWindow)
if (updatedAt === undefined || resetSeconds === undefined) return undefined
return updatedAt + resetSeconds
})()
if (nextResetAt !== undefined) display.next_reset_at = nextResetAt
}
return Object.keys(display).length > 0 ? display : null
}
function hasGrokQuotaDisplayData(key: EndpointAPIKey): boolean {
const grok = getGrokQuotaDisplay(key)
return !!grok && (grok.usage_percentage !== undefined || grok.usage_limit !== undefined)
}
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
image_quota_remaining_percent?: number
image_quota_used_percent?: number
@@ -2435,6 +2496,28 @@ function shouldAutoRefreshKiroQuota(): boolean {
return false
}
function shouldAutoRefreshGrokQuota(): boolean {
if (provider.value?.provider_type !== 'grok') return false
const now = Math.floor(Date.now() / 1000)
for (const { key } of allKeys.value) {
if (!key.is_active) continue
if (isTokenExpiringSoon(key, now)) return true
if (!hasGrokQuotaDisplayData(key)) {
return true
}
const updatedAt = getGrokQuotaDisplay(key)?.updated_at
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
function shouldAutoRefreshChatGPTWebQuota(): boolean {
if (provider.value?.provider_type !== 'chatgpt_web') return false
const now = Math.floor(Date.now() / 1000)
@@ -2541,7 +2624,7 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
if (refreshingQuota.value) return
const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web') return
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
// 检查是否需要刷新
let shouldRefresh = false
@@ -2551,6 +2634,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
shouldRefresh = shouldAutoRefreshAntigravityQuota()
} else if (providerType === 'kiro') {
shouldRefresh = shouldAutoRefreshKiroQuota()
} else if (providerType === 'grok') {
shouldRefresh = shouldAutoRefreshGrokQuota()
} else if (providerType === 'chatgpt_web') {
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
}
@@ -2564,6 +2649,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key))
} else if (providerType === 'kiro') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
} else if (providerType === 'grok') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key))
} else if (providerType === 'chatgpt_web') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
}
@@ -3030,6 +3117,9 @@ function formatOAuthPlanType(planType: string): string {
team: 'Team',
enterprise: 'Enterprise',
ultra: 'Ultra',
basic: 'Basic',
super: 'Super',
heavy: 'Heavy',
}
return labels[planType.toLowerCase()] || planType
}
@@ -3377,6 +3467,9 @@ function getOAuthPlanTypeClass(planType: string): string {
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
basic: 'border-primary/50 text-primary',
super: 'border-green-500/50 text-green-600 dark:text-green-400',
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
return classes[planType.toLowerCase()] || ''
}

View File

@@ -60,6 +60,9 @@
<SelectItem value="gemini_cli">
Gemini CLI
</SelectItem>
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -87,6 +90,9 @@
<SelectItem value="gemini_cli">
Gemini CLI
</SelectItem>
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -355,7 +361,7 @@ const defaultPriority = computed(() => {
// 表单数据
const form = ref({
name: '',
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro',
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok',
description: '',
website: '',
// 计费配置

View File

@@ -0,0 +1,389 @@
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
const endpointMocks = vi.hoisted(() => ({
startProviderLevelOAuth: vi.fn(),
completeProviderLevelOAuth: vi.fn(),
importProviderRefreshToken: vi.fn(),
startBatchImportOAuthTask: vi.fn(),
getBatchImportOAuthTaskStatus: vi.fn(),
startDeviceAuthorize: vi.fn(),
pollDeviceAuthorize: vi.fn(),
getAwsRegions: vi.fn(),
}))
vi.mock('@/api/endpoints', () => endpointMocks)
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string, tag = 'div') => defineComponent({
name,
setup(_, { slots }) {
return () => h(tag, slots.default?.())
},
})
const Dialog = defineComponent({
name: 'DialogStub',
props: {
modelValue: Boolean,
},
setup(props, { slots }) {
return () => props.modelValue
? h('section', [slots.headerActions?.(), slots.default?.(), slots.footer?.()])
: null
},
})
const Button = defineComponent({
name: 'ButtonStub',
inheritAttrs: false,
props: {
disabled: Boolean,
variant: String,
size: String,
},
setup(props, { attrs, slots }) {
return () => h('button', {
...attrs,
disabled: props.disabled,
type: attrs.type ?? 'button',
}, slots.default?.())
},
})
const Textarea = defineComponent({
name: 'TextareaStub',
inheritAttrs: false,
props: {
modelValue: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('textarea', {
...attrs,
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value),
})
},
})
return {
Dialog,
Button,
Textarea,
Popover: passthrough('PopoverStub'),
PopoverTrigger: passthrough('PopoverTriggerStub'),
PopoverContent: passthrough('PopoverContentStub'),
}
})
vi.mock('radix-vue', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string) => defineComponent({
name,
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
return {
ComboboxAnchor: passthrough('ComboboxAnchorStub'),
ComboboxContent: passthrough('ComboboxContentStub'),
ComboboxEmpty: passthrough('ComboboxEmptyStub'),
ComboboxInput: passthrough('ComboboxInputStub'),
ComboboxItem: passthrough('ComboboxItemStub'),
ComboboxRoot: passthrough('ComboboxRootStub'),
ComboboxTrigger: passthrough('ComboboxTriggerStub'),
ComboboxViewport: passthrough('ComboboxViewportStub'),
}
})
vi.mock('@/components/common/JsonImportInput.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'JsonImportInputStub',
props: {
modelValue: {
type: String,
default: '',
},
dropTitle: {
type: String,
default: '',
},
dropHint: {
type: String,
default: '',
},
manualPlaceholder: {
type: String,
default: '',
},
manualDescription: {
type: String,
default: '',
},
pasteToggleText: {
type: String,
default: '',
},
fileToggleText: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('div', [
h('p', { 'data-testid': 'drop-title' }, props.dropTitle),
h('p', { 'data-testid': 'drop-hint' }, props.dropHint),
h('p', { 'data-testid': 'manual-description' }, props.manualDescription),
h('p', props.pasteToggleText),
h('p', props.fileToggleText),
h('textarea', {
placeholder: props.manualPlaceholder,
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value),
}),
])
},
}),
}
})
vi.mock('@/components/ui/Label.vue', () => ({}))
vi.mock('./ProxyNodeSelect.vue', () => ({}))
vi.mock('@/features/providers/components/ProxyNodeSelect.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'ProxyNodeSelectStub',
setup() {
return () => h('div')
},
}),
}
})
vi.mock('@/stores/proxy-nodes', () => ({
useProxyNodesStore: () => ({
nodes: [],
onlineNodes: [],
loading: false,
ensureLoaded: vi.fn(),
}),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard: vi.fn(),
}),
}))
vi.mock('@/composables/useTotp', () => ({
useTotp: () => ({
code: { value: '' },
remaining: { value: 0 },
start: vi.fn(),
stop: vi.fn(),
}),
}))
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
UserPlus: Icon,
Copy: Icon,
ExternalLink: Icon,
Globe: Icon,
AlertCircle: Icon,
ShieldCheck: Icon,
ChevronsUpDown: Icon,
Check: Icon,
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountDialog(providerType = 'grok') {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(OAuthAccountDialog, {
open: true,
providerId: 'provider-1',
providerType,
})
app.mount(root)
mountedApps.push({ app, root })
return root
}
async function settle() {
await nextTick()
await Promise.resolve()
}
function getButton(root: HTMLElement, text: string) {
return Array.from(root.querySelectorAll('button'))
.find(button => button.textContent?.includes(text))
}
function getImportTextarea(root: HTMLElement) {
const textarea = root.querySelector('textarea')
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error('Expected import textarea to exist')
}
return textarea
}
describe('OAuthAccountDialog Grok import', () => {
beforeEach(() => {
endpointMocks.startProviderLevelOAuth.mockReset()
endpointMocks.completeProviderLevelOAuth.mockReset()
endpointMocks.importProviderRefreshToken.mockReset()
endpointMocks.startBatchImportOAuthTask.mockReset()
endpointMocks.getBatchImportOAuthTaskStatus.mockReset()
endpointMocks.startDeviceAuthorize.mockReset()
endpointMocks.pollDeviceAuthorize.mockReset()
endpointMocks.getAwsRegions.mockReset()
endpointMocks.importProviderRefreshToken.mockResolvedValue({
provider_type: 'grok',
has_refresh_token: false,
email: 'grok@example.com',
replaced: false,
})
endpointMocks.startBatchImportOAuthTask.mockResolvedValue({
task_id: 'task-1',
status: 'submitted',
total: 2,
processed: 0,
success: 0,
failed: 0,
progress_percent: 0,
})
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
it('opens Grok in import mode without starting unsupported OAuth', async () => {
const root = mountDialog('grok')
await settle()
expect(endpointMocks.startProviderLevelOAuth).not.toHaveBeenCalled()
expect(root.textContent).not.toContain('获取授权')
expect(root.querySelector('textarea')?.getAttribute('placeholder')).toContain('Grok sso/session token')
expect(root.textContent).toContain('plan_type / pool_tier')
expect(getButton(root, '导入账号')).toBeTruthy()
})
it('maps a single Grok JSON token into account metadata import payload', async () => {
const root = mountDialog('grok')
await settle()
const textarea = getImportTextarea(root)
textarea.value = JSON.stringify({
token: 'sso-1',
planType: 'super',
tier: 'heavy',
email: 'grok@example.com',
accountName: 'Grok Heavy',
})
textarea.dispatchEvent(new Event('input'))
await settle()
getButton(root, '导入账号')?.click()
await settle()
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', {
access_token: 'sso-1',
account_name: 'Grok Heavy',
email: 'grok@example.com',
plan_type: 'super',
pool_tier: 'heavy',
sso_rw_token: undefined,
cf_cookies: undefined,
cf_clearance: undefined,
user_agent: undefined,
browser_profile: undefined,
proxy_node_id: undefined,
refresh_token: undefined,
expires_at: undefined,
name: undefined,
account_id: undefined,
account_user_id: undefined,
user_id: undefined,
})
})
it('keeps Grok multiline token import on the batch task path', async () => {
const root = mountDialog('grok')
await settle()
const textarea = getImportTextarea(root)
textarea.value = 'sso-1\nsso-2'
textarea.dispatchEvent(new Event('input'))
await settle()
getButton(root, '导入账号')?.click()
await settle()
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
'provider-1',
'sso-1\nsso-2',
undefined,
)
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()
})
it('extracts Grok account fields from a pasted browser cookie header', async () => {
const root = mountDialog('grok')
await settle()
const textarea = getImportTextarea(root)
textarea.value = 'i18nextLng=zh; cf_clearance=cf-1; sso-rw=rw-1; sso=sso-1; x-userid=user-1'
textarea.dispatchEvent(new Event('input'))
await settle()
getButton(root, '导入账号')?.click()
await settle()
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', expect.objectContaining({
access_token: 'sso-1',
sso_rw_token: 'rw-1',
cf_cookies: 'i18nextlng=zh; cf_clearance=cf-1; x-userid=user-1',
cf_clearance: 'cf-1',
user_agent: expect.any(String),
browser_profile: 'chrome136',
user_id: 'user-1',
}))
})
})

View File

@@ -8,4 +8,10 @@ describe('providerTypeUtils', () => {
expect(isOAuthAccountProviderType('ChatGPT_Web')).toBe(true)
expect(isKeyManagedProviderType('chatgpt_web')).toBe(false)
})
it('treats Grok as an OAuth account provider', () => {
expect(isOAuthAccountProviderType('grok')).toBe(true)
expect(isOAuthAccountProviderType('GROK')).toBe(true)
expect(isKeyManagedProviderType('grok')).toBe(false)
})
})

View File

@@ -11,6 +11,7 @@ const oauthAccountProviderTypes = new Set([
'gemini_cli',
'antigravity',
'kiro',
'grok',
])
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>