mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(grok): add admin oauth and quota support
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()] || ''
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
// 计费配置
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const oauthAccountProviderTypes = new Set([
|
||||
'gemini_cli',
|
||||
'antigravity',
|
||||
'kiro',
|
||||
'grok',
|
||||
])
|
||||
|
||||
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>
|
||||
|
||||
Reference in New Issue
Block a user