feat(providers): add xAI provider with device code OAuth

Add a separate `xai` provider type for xAI Grok CLI subscription accounts.
It is independent of the existing `grok` provider, which reverse-proxies
grok.com with browser cookies; behavior of `grok` is unchanged.

Account binding uses the xAI device code flow, so no local callback
listener is needed and headless deployments can bind accounts. Refresh
tokens can also be imported individually or in batches, and are rotated
on refresh.

OAuth requests default to the cli-chat-proxy Responses API; API keys and
compact stay on api.x.ai. Explicit custom gateways are preserved. Only
`openai:responses` and `openai:responses:compact` are exposed; Chat,
Claude and Gemini clients reach the provider through Aether's existing
cross-format conversion rather than new native endpoints.

Upstream Responses payloads are sanitized for what xAI actually rejects:
`previous_response_id` and `metadata.user_id` are dropped, hosted
`tool_choice` is rewritten, `web_search` is restored for converted
clients, `image_generation` is stripped on older Grok conversation
models, unsupported reasoning effort is removed, and requested
`reasoning.encrypted_content` is preserved with a replay policy keyed on
the configured provider type rather than the model name.

Quota refresh reads /user and /billing?format=credits and stores a
structured usage snapshot; a prepaid balance keeps an account selectable
after the weekly allowance is exhausted. API-key accounts skip the
subscription billing surface. The admin UI shows remaining weekly quota
as a labeled bar in the provider drawer and the pool list.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
stabey
2026-09-14 21:09:03 +08:00
co-authored by Claude Opus 5
parent 60b89cc840
commit e83399db2f
71 changed files with 5495 additions and 148 deletions
+1 -1
View File
@@ -376,7 +376,7 @@ function jsonValueContainsAgentIdentity(value: unknown): boolean {
export interface DeviceAuthorizeRequest {
start_url?: string
region?: string
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser'
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser' | 'device'
login_option?: 'google' | 'github' | 'default'
redirect_uri?: string
proxy_node_id?: string
+19 -1
View File
@@ -462,6 +462,23 @@ export interface GrokUpstreamMetadata {
account_user_id?: string | null
}
export interface XaiUpstreamMetadata {
updated_at?: number
subscription_title?: string
usage_percentage?: number
remaining_percentage?: number
usage_label?: string
usage_limit?: number
current_usage?: number
remaining?: number
next_reset_at?: number
prepaid_balance?: number
on_demand_cap?: number
on_demand_used?: number
on_demand_remaining?: number
period_type?: string
}
export interface GeminiCliTierMetadata {
id?: string | null
tierType?: string | null
@@ -520,6 +537,7 @@ export interface UpstreamMetadata {
chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata
gemini_cli?: GeminiCliUpstreamMetadata
xai?: XaiUpstreamMetadata
}
// 按格式的健康度数据
@@ -758,7 +776,7 @@ export interface HealthRelatedMonitorResponse {
related_providers: HealthRelatedMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai'
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'xai' | 'windsurf' | 'vertex_ai'
export interface ClaudeCodeAdvancedConfig {
// 会话数量控制:null/undefined 表示不限制
@@ -334,7 +334,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Free/Team 优先',
description: '兼容旧配置:优先消耗 Free、Team 或两者',
evidence_hint: '依据 plan_type,保留旧 free_only/team_only/both 语义',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: [
{ value: 'free_only', label: 'Free' },
{ value: 'team_only', label: 'Team' },
@@ -347,7 +347,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Free 优先',
description: '优先消耗 Free 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Free 账号优先调度)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: null,
default_mode: null,
},
@@ -356,7 +356,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Team 优先',
description: '优先消耗 Team 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Team 账号优先调度)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: null,
default_mode: null,
},
@@ -365,7 +365,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Plus 优先',
description: '优先消耗 Plus 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Plus 账号优先调度)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: null,
default_mode: null,
},
@@ -374,7 +374,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Pro 优先',
description: '优先消耗 Pro 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Pro 账号优先调度)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: null,
default_mode: null,
},
@@ -392,7 +392,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: '额度刷新优先',
description: '优先选即将刷新额度的账号',
evidence_hint: '依据账号额度重置倒计时(next_reset / reset_seconds)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
default_enabled_providers: ['codex', 'windsurf'],
modes: null,
default_mode: null,
@@ -244,6 +244,129 @@
</div>
</template>
<!-- xAI: 设备授权 -->
<template v-else-if="isXaiProvider">
<div class="space-y-3">
<div class="h-[265px]">
<div
v-if="device.status === 'error' || device.status === 'expired'"
class="rounded-xl border border-destructive/20 bg-destructive/5 p-5"
>
<div class="flex flex-col items-center text-center space-y-3">
<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle class="w-5 h-5 text-destructive" />
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-destructive">
{{ legacyT(device.status === 'expired' ? '授权已过期' : '授权失败') }}
</p>
<p class="text-xs text-muted-foreground">
{{ legacyT(device.error || '请重试') }}
</p>
</div>
<Button
size="sm"
variant="outline"
@click="resetDevice"
>
{{ legacyT('重新开始') }}
</Button>
</div>
</div>
<div
v-else-if="device.starting && !device.session_id"
class="flex items-center justify-center py-12"
>
<div class="text-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
<p class="text-xs text-muted-foreground">
{{ legacyT('正在准备设备授权...') }}
</p>
</div>
</div>
<div
v-else-if="device.session_id && device.status === 'pending'"
class="rounded-xl border border-border bg-muted/20 p-5"
>
<div class="flex flex-col items-center text-center space-y-4">
<div class="relative">
<div class="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
<div class="relative w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<ExternalLink class="w-5 h-5 text-primary" />
</div>
</div>
<div class="space-y-1">
<p class="text-sm font-medium">
{{ legacyT('在浏览器中输入设备码完成授权') }}
</p>
<p class="text-xs text-muted-foreground">
{{ legacyT('授权完成后此页面将自动更新') }}
</p>
</div>
<div
v-if="device.user_code"
class="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2"
>
<span class="text-lg font-mono font-bold tracking-[0.2em]">{{ device.user_code }}</span>
<button
class="p-1 rounded hover:bg-muted transition-colors"
:title="legacyT('复制设备码')"
@click="copyToClipboard(device.user_code)"
>
<Copy class="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
<span>{{ remainingText }}</span>
</div>
<div class="flex gap-2 w-full">
<Button
class="flex-1"
size="sm"
:disabled="!device.verification_uri_complete && !device.verification_uri"
@click="openDeviceVerificationUrl"
>
<ExternalLink class="w-3.5 h-3.5 mr-1.5" />
{{ legacyT('打开授权页面') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!device.verification_uri_complete && !device.verification_uri"
@click="copyToClipboard(device.verification_uri_complete || device.verification_uri)"
>
<Copy class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-3"
>
<p class="text-xs text-muted-foreground text-center">
{{ legacyT('使用 xAI 设备授权登录 Grok CLI,或改为导入 API Key / Refresh Token。') }}
</p>
<Button
class="w-full"
:disabled="device.starting"
@click="startDeviceAuth"
>
{{ device.starting ? legacyT('正在准备授权...') : legacyT('开始授权') }}
</Button>
</div>
</div>
</div>
</template>
<!-- Kiro: 设备授权模式 -->
<template v-else-if="isKiroProvider">
<div class="space-y-3">
@@ -994,7 +1117,7 @@ let oauthInitRequestId = 0
let oauthCompleteRequestId = 0
// 设备授权状态
type DeviceAuthType = 'default' | 'google' | 'github' | 'builder_id' | 'identity_center'
type DeviceAuthType = 'default' | 'google' | 'github' | 'builder_id' | 'identity_center' | 'device'
type WindsurfLoginOption = 'default' | 'google' | 'github'
interface DeviceAuthState {
@@ -1075,9 +1198,10 @@ const isOpen = computed(() => props.open)
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
const isXaiProvider = computed(() => (props.providerType || '').toLowerCase() === 'xai')
const isCodexProvider = computed(() => (props.providerType || '').toLowerCase() === 'codex')
const isClaudeCodeProvider = computed(() => (props.providerType || '').toLowerCase() === 'claude_code')
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value || isXaiProvider.value)
const showAuthorizationMode = computed(() => !isGrokProvider.value)
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
@@ -1101,7 +1225,7 @@ const isManualDeviceCallbackPending = computed(() =>
const authorizationModeLabel = computed(() => {
if (isWindsurfProvider.value) return legacyT('浏览器登录')
if (isDeviceBrowserProvider.value) return legacyT('设备授权')
if (isXaiProvider.value || isDeviceBrowserProvider.value) return legacyT('设备授权')
return legacyT('获取授权')
})
@@ -1215,6 +1339,9 @@ const importManualPlaceholder = computed(() => {
if (isClaudeCodeProvider.value) {
return legacyT('粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容')
}
if (isXaiProvider.value) {
return legacyT('粘贴 xAI API Key、Access Token,或包含 refresh_token / api_key 的 JSON')
}
if (isWindsurfProvider.value) {
return legacyT('粘贴 show-auth-token Token、API key 或 JSON 内容')
}
@@ -1477,11 +1604,17 @@ function resetDevice() {
totp.stop()
const { auth_type, start_url, region, totp_secret } = device.value
device.value = createInitialDeviceState()
device.value.auth_type = isWindsurfProvider.value ? (auth_type === 'google' || auth_type === 'github' ? auth_type : 'default') : auth_type
device.value.auth_type = isXaiProvider.value
? 'device'
: isWindsurfProvider.value
? (auth_type === 'google' || auth_type === 'github' ? auth_type : 'default')
: auth_type
device.value.start_url = start_url
device.value.region = region
device.value.totp_secret = totp_secret
if (!isWindsurfProvider.value && (device.value.auth_type === 'google' || device.value.auth_type === 'github')) {
if (isXaiProvider.value) {
void ensureXaiDeviceAuth()
} else if (!isWindsurfProvider.value && (device.value.auth_type === 'google' || device.value.auth_type === 'github')) {
void ensureKiroSocialDeviceAuth()
}
}
@@ -1500,6 +1633,8 @@ function resetForm() {
device.value = createInitialDeviceState()
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
} else if (isXaiProvider.value) {
device.value.auth_type = 'device'
}
importText.value = ''
importing.value = false
@@ -1531,6 +1666,8 @@ function switchMode(newMode: DialogMode) {
if (newMode === 'oauth') {
if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else if (isXaiProvider.value) {
void ensureXaiDeviceAuth()
} else if (!oauth.value.authorization_url && !oauth.value.starting) {
initOAuth()
}
@@ -1833,6 +1970,29 @@ function parseImportText(text: string): {
return { refresh_token: trimmed }
}
if (isXaiProvider.value) {
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>
const apiKey = normalizeStringField(obj.api_key) ?? normalizeStringField(obj.apiKey)
const refreshToken = normalizeStringField(obj.refresh_token) ?? normalizeStringField(obj.refreshToken)
const accessToken = normalizeStringField(obj.access_token) ?? normalizeStringField(obj.accessToken) ?? apiKey
if (refreshToken || accessToken) {
return {
refresh_token: refreshToken,
access_token: accessToken,
name: normalizeStringField(obj.name) ?? normalizeStringField(obj.email),
email: normalizeStringField(obj.email),
}
}
}
} catch {
// Raw xAI API keys / access tokens are imported as access_token.
}
return { access_token: trimmed }
}
if (isGrokProvider.value) {
const cookieImport = parseGrokCookieImport(trimmed)
if (cookieImport) {
@@ -2367,17 +2527,20 @@ async function startDeviceAuth() {
device.value.error = ''
try {
const isWindsurf = isWindsurfProvider.value
const isXai = isXaiProvider.value
const isBuilderID = requestedAuthType === 'builder_id'
const isSocial = requestedAuthType === 'google' || requestedAuthType === 'github'
const isSocial = !isXai && (requestedAuthType === 'google' || requestedAuthType === 'github')
const windsurfLoginOption: WindsurfLoginOption = isSocial ? requestedAuthType : 'default'
const authTypeForRequest = isWindsurf
? 'browser'
: (requestedAuthType === 'default' ? 'google' : requestedAuthType)
: isXai
? 'device'
: (requestedAuthType === 'default' ? 'google' : requestedAuthType)
const resp = await startDeviceAuthorize(props.providerId, {
auth_type: authTypeForRequest,
login_option: isWindsurf ? windsurfLoginOption : undefined,
start_url: isWindsurf ? undefined : (isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined))),
region: isWindsurf ? undefined : (isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined)),
start_url: (isWindsurf || isXai) ? undefined : (isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined))),
region: (isWindsurf || isXai) ? undefined : (isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined)),
proxy_node_id: selectedProxyNodeId.value || undefined,
})
if (requestId !== deviceAuthRequestId || device.value.auth_type !== requestedAuthType) return
@@ -2417,6 +2580,13 @@ async function ensureKiroSocialDeviceAuth() {
await startDeviceAuth()
}
async function ensureXaiDeviceAuth() {
if (!props.open || !props.providerId || !isXaiProvider.value) return
if (device.value.starting) return
if (device.value.session_id && (device.value.status === 'pending' || device.value.status === 'authorized')) return
await startDeviceAuth()
}
function scheduleDevicePoll() {
if (devicePollTimer) clearTimeout(devicePollTimer)
devicePollTimer = setTimeout(() => pollDevice(), device.value.interval * 1000)
@@ -2525,6 +2695,9 @@ watch(
}
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
} else if (isXaiProvider.value) {
device.value.auth_type = 'device'
void ensureXaiDeviceAuth()
} else if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else {
@@ -2554,6 +2727,9 @@ watch(
device.value.auth_type = ['default', 'google', 'github'].includes(device.value.auth_type)
? device.value.auth_type
: 'default'
} else if (props.open && isXaiProvider.value && mode.value === 'oauth') {
device.value.auth_type = 'device'
void ensureXaiDeviceAuth()
} else if (props.open && isKiroProvider.value && mode.value === 'oauth') {
void ensureKiroSocialDeviceAuth()
}
@@ -514,6 +514,65 @@
</div>
</template>
</div>
<!-- xAI / Grok Build 订阅额度 -->
<div
v-if="provider.provider_type === 'xai' && hasXaiQuotaDisplayData(key)"
class="mt-2 p-2 rounded-md bg-muted/30"
>
<ProviderQuotaSectionHeader
:title="legacyT('账号配额')"
:loading="refreshingQuota"
:updated-text="getXaiQuotaDisplay(key)?.updated_at ? formatKiroUpdatedAt(getXaiQuotaDisplay(key)?.updated_at || 0) : null"
/>
<div class="space-y-2">
<ProviderQuotaProgressRow
v-if="getXaiQuotaDisplay(key)?.usage_percentage !== undefined || getXaiQuotaDisplay(key)?.remaining_percentage !== undefined"
:label="legacyT(getXaiUsageLabel(key))"
:used-percent="getXaiUsedPercent(key)"
:remaining-percent="getXaiRemainingPercent(key)"
:meter-class="getQuotaRemainingClass(getXaiUsedPercent(key))"
:bar-class="getQuotaRemainingBarColor(getXaiUsedPercent(key))"
:reset-text="getXaiQuotaDisplay(key)?.next_reset_at
? `${formatKiroResetTime(getXaiQuotaDisplay(key)?.next_reset_at)}${legacyT('重置')}`
: null"
>
<template
v-if="getXaiQuotaDisplay(key)?.usage_limit != null"
#footer
>
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
<span>
{{ formatKiroUsage(getXaiQuotaDisplay(key)?.current_usage) }} /
{{ formatKiroUsage(getXaiQuotaDisplay(key)?.usage_limit) }}
</span>
<span v-if="getXaiQuotaDisplay(key)?.next_reset_at">
{{ formatKiroResetTime(getXaiQuotaDisplay(key)?.next_reset_at) }}{{ legacyT('重置') }}
</span>
</div>
</template>
</ProviderQuotaProgressRow>
<div
v-if="getXaiQuotaDisplay(key)?.prepaid_balance != null"
class="text-[10px] text-muted-foreground"
>
{{ legacyT('预付额度') }}: {{ formatKiroUsage(getXaiQuotaDisplay(key)?.prepaid_balance) }}
</div>
<ProviderQuotaProgressRow
v-if="getXaiQuotaDisplay(key)?.on_demand_cap"
:label="legacyT('按需额度')"
:used-percent="getXaiOnDemandUsedPercent(key)"
:meter-class="getQuotaRemainingClass(getXaiOnDemandUsedPercent(key))"
:bar-class="getQuotaRemainingBarColor(getXaiOnDemandUsedPercent(key))"
>
<template #footer>
<div class="text-[9px] text-muted-foreground/70 mt-0.5">
{{ formatKiroUsage(getXaiQuotaDisplay(key)?.on_demand_used) }} /
{{ formatKiroUsage(getXaiQuotaDisplay(key)?.on_demand_cap) }}
</div>
</template>
</ProviderQuotaProgressRow>
</div>
</div>
<!-- Windsurf 上游额度信息 -->
<div
v-if="provider.provider_type === 'windsurf' && (hasWindsurfQuotaDisplayData(key) || isWindsurfUnavailableKey(key) || isWindsurfExhaustedKey(key))"
@@ -1005,6 +1064,7 @@ import type {
GrokUpstreamMetadata,
KiroUpstreamMetadata,
WindsurfUpstreamMetadata,
XaiUpstreamMetadata,
QuotaResetCreditsSnapshot,
QuotaStatusSnapshot,
QuotaWindowSnapshot,
@@ -1858,7 +1918,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
function getQuotaSnapshotForProvider(
key: EndpointAPIKey,
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok' | 'xai',
): QuotaStatusSnapshot | null {
const quota = key.status_snapshot?.quota
if (!quota) return null
@@ -2179,6 +2239,90 @@ function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
}
function getXaiQuotaDisplay(key: EndpointAPIKey): XaiUpstreamMetadata | null {
const quota = getQuotaSnapshotForProvider(key, 'xai')
if (!quota) return null
const display: XaiUpstreamMetadata = {}
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
if (updatedAt !== undefined) display.updated_at = updatedAt
if (quota.plan_type) display.subscription_title = quota.plan_type
const usageWindow =
getQuotaWindow(quota, 'usage')
?? getQuotaWindowByScope(quota, 'account')[0]
?? null
if (usageWindow) {
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
const remainingPercent = getQuotaWindowRemainingPercent(usageWindow)
if (usedPercent !== undefined) display.usage_percentage = usedPercent
if (remainingPercent !== undefined) display.remaining_percentage = remainingPercent
const usageLabel = String(usageWindow.label || '').trim()
if (usageLabel) display.usage_label = usageLabel
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
}
const prepaidWindow = getQuotaWindow(quota, 'prepaid')
if (typeof prepaidWindow?.remaining_value === 'number') {
display.prepaid_balance = prepaidWindow.remaining_value
}
const onDemandWindow = getQuotaWindow(quota, 'on_demand')
if (typeof onDemandWindow?.limit_value === 'number') display.on_demand_cap = onDemandWindow.limit_value
if (typeof onDemandWindow?.used_value === 'number') display.on_demand_used = onDemandWindow.used_value
if (typeof onDemandWindow?.remaining_value === 'number') display.on_demand_remaining = onDemandWindow.remaining_value
return Object.keys(display).length > 0 ? display : null
}
function hasXaiQuotaDisplayData(key: EndpointAPIKey): boolean {
const xai = getXaiQuotaDisplay(key)
return !!xai && (
xai.usage_percentage !== undefined
|| xai.remaining_percentage !== undefined
|| xai.prepaid_balance !== undefined
|| xai.on_demand_cap !== undefined
)
}
function getXaiUsageLabel(key: EndpointAPIKey): string {
const display = getXaiQuotaDisplay(key)
if (display?.usage_label) return display.usage_label
const title = display?.subscription_title
return title ? `使用额度 (${title})` : '使用额度'
}
function getXaiUsedPercent(key: EndpointAPIKey): number {
return Math.min(Math.max(100 - getXaiRemainingPercent(key), 0), 100)
}
function getXaiRemainingPercent(key: EndpointAPIKey): number {
const xai = getXaiQuotaDisplay(key)
if (xai?.remaining_percentage != null && Number.isFinite(xai.remaining_percentage)) {
return Math.min(Math.max(xai.remaining_percentage, 0), 100)
}
if (xai?.usage_percentage != null && Number.isFinite(xai.usage_percentage)) {
return Math.min(Math.max(100 - xai.usage_percentage, 0), 100)
}
return 0
}
function getXaiOnDemandUsedPercent(key: EndpointAPIKey): number {
const xai = getXaiQuotaDisplay(key)
if (!xai?.on_demand_cap || xai.on_demand_cap <= 0) return 0
return Math.max(Math.min(((xai.on_demand_used || 0) / xai.on_demand_cap) * 100, 100), 0)
}
type GrokQuotaDisplay = GrokUpstreamMetadata & {
usage_percentage?: number
usage_limit?: number
@@ -2696,6 +2840,28 @@ function shouldAutoRefreshGrokQuota(): boolean {
return false
}
function shouldAutoRefreshXaiQuota(): boolean {
if (provider.value?.provider_type !== 'xai') 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 (!hasXaiQuotaDisplayData(key)) {
return true
}
const updatedAt = getXaiQuotaDisplay(key)?.updated_at
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
function shouldAutoRefreshWindsurfQuota(): boolean {
if (provider.value?.provider_type !== 'windsurf') return false
const now = Math.floor(Date.now() / 1000)
@@ -2824,7 +2990,7 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
if (refreshingQuota.value) return false
const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return false
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok' && providerType !== 'xai') return false
// 检查是否需要刷新
let shouldRefresh = false
@@ -2838,6 +3004,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
shouldRefresh = shouldAutoRefreshKiroQuota()
} else if (providerType === 'grok') {
shouldRefresh = shouldAutoRefreshGrokQuota()
} else if (providerType === 'xai') {
shouldRefresh = shouldAutoRefreshXaiQuota()
} else if (providerType === 'windsurf') {
shouldRefresh = shouldAutoRefreshWindsurfQuota()
} else if (providerType === 'chatgpt_web') {
@@ -2856,6 +3024,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
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 === 'xai') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasXaiQuotaDisplayData(key))
} else if (providerType === 'windsurf') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasWindsurfQuotaDisplayData(key))
} else if (providerType === 'chatgpt_web') {
@@ -60,6 +60,9 @@
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="xai">
xAI
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -93,6 +96,9 @@
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="xai">
xAI
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -54,6 +54,24 @@ describe('provider quota display components', () => {
unmount()
})
it('fills the remaining bar even when used percent is zero', () => {
const { root, unmount } = mount(ProviderQuotaProgressRow, {
label: '周额度',
usedPercent: 0,
remainingPercent: 86,
meterClass: 'text-green-600',
barClass: 'bg-green-500',
resetText: '5天0小时后重置',
})
expect(root.querySelector('[data-testid="provider-quota-progress-meter"]')?.textContent?.trim()).toBe('86.0%')
expect((root.querySelector('[data-testid="provider-quota-progress-bar"]') as HTMLElement).style.width).toBe('86%')
expect(root.textContent).toContain('周额度')
expect(root.querySelector('[data-testid="provider-quota-progress-reset"]')?.textContent).toBe('5天0小时后重置')
unmount()
})
it('renders section loading and updated state', () => {
const Probe = defineComponent({
setup() {
@@ -1386,6 +1386,7 @@ function formatAuthType(authType: string): string {
if (lowered === 'antigravity') return 'Antigravity OAuth'
if (lowered === 'kiro') return 'Kiro OAuth'
if (lowered === 'grok') return 'Grok OAuth'
if (lowered === 'xai') return 'xAI OAuth'
return authType
}
@@ -39,10 +39,12 @@ const MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([
'vertex_ai',
'antigravity',
'kiro',
'xai',
])
const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
'chatgpt_web',
'xai',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
@@ -16,6 +16,12 @@ describe('providerTypeUtils', () => {
expect(isKeyManagedProviderType('grok')).toBe(false)
})
it('treats xAI as an OAuth account provider', () => {
expect(isOAuthAccountProviderType('xai')).toBe(true)
expect(isOAuthAccountProviderType('xAI')).toBe(true)
expect(isKeyManagedProviderType('xai')).toBe(false)
})
it('treats Windsurf as an OAuth account provider', () => {
expect(isOAuthAccountProviderType('windsurf')).toBe(true)
expect(isOAuthAccountProviderType('Windsurf')).toBe(true)
@@ -12,6 +12,7 @@ const oauthAccountProviderTypes = new Set([
'antigravity',
'kiro',
'grok',
'xai',
'windsurf',
])
+3
View File
@@ -2146,7 +2146,10 @@ const legacyExactEnglishMessages: Record<string, string> = {
'账号不可用': 'Account unavailable',
'日额度': 'Daily quota',
'周额度': 'Weekly quota',
'月额度': 'Monthly quota',
'剩余额度': 'Remaining quota',
'预付额度': 'Prepaid credits',
'按需额度': 'On-demand credits',
'点击编辑优先级': 'Edit priority',
'点击编辑倍率': 'Edit multiplier',
'同步失败': 'Sync failed',
@@ -359,4 +359,30 @@ describe('providerKeyQuota', () => {
},
}, 'windsurf')).toBe('可用模型 3 个')
})
it('formats xAI weekly credits as remaining percent', () => {
expect(getQuotaDisplayText({
status_snapshot: {
oauth: { code: 'valid' },
account: { code: 'ok', blocked: false },
quota: {
provider_type: 'xai',
code: 'ok',
exhausted: false,
windows: [
{
code: 'usage',
scope: 'account',
used_ratio: 0.46,
remaining_ratio: 0.54,
},
{
code: 'prepaid',
remaining_value: 12.5,
},
],
},
},
}, 'xai')).toBe('剩余 54.0% | 预付剩余 12.5')
})
})
+1
View File
@@ -5,6 +5,7 @@ export const OAUTH_ICONS: Record<string, string> = {
google: `<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
gemini_cli: `<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
grok: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="#111827"/><path d="M7 7L17 17M17 7L7 17" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/></svg>`,
xai: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="#111827"/><path d="M7 7L17 17M17 7L7 17" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/></svg>`,
}
// Default icon when provider type is not found
+25
View File
@@ -265,6 +265,29 @@ function getKiroQuotaText(quota: QuotaStatusSnapshot): string | null {
return normalizeText(quota.label)
}
function getXaiQuotaText(quota: QuotaStatusSnapshot): string | null {
const parts: string[] = []
const usageText = getKiroQuotaText(quota)
if (usageText) parts.push(usageText)
const prepaid = getQuotaWindow(quota, 'prepaid')
if (typeof prepaid?.remaining_value === 'number') {
parts.push(`预付剩余 ${formatQuotaValue(prepaid.remaining_value)}`)
}
const onDemand = getQuotaWindow(quota, 'on_demand')
const onDemandRemaining = getQuotaWindowRemainingPercent(onDemand)
if (onDemandRemaining != null) {
const valueText = getQuotaWindowValueText(onDemand)
parts.push(`按需剩余 ${formatPercent(onDemandRemaining)}${valueText ? ` (${valueText})` : ''}`)
} else if (typeof onDemand?.remaining_value === 'number') {
parts.push(`按需剩余 ${formatQuotaValue(onDemand.remaining_value)}`)
}
if (parts.length > 0) return parts.join(' | ')
return normalizeText(quota.label)
}
function getGrokQuotaText(quota: QuotaStatusSnapshot): string | null {
const code = normalizeText(quota.code)?.toLowerCase()
if (code === 'banned') {
@@ -452,6 +475,8 @@ export function getQuotaSnapshotFallbackText(
return getCodexQuotaText(quota)
case 'kiro':
return getKiroQuotaText(quota)
case 'xai':
return getXaiQuotaText(quota)
case 'grok':
return getGrokQuotaText(quota)
case 'windsurf':
+12 -3
View File
@@ -1700,6 +1700,7 @@ const showAccountQuotaColumn = computed(() => {
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
|| selectedProviderType.value === 'xai'
})
const desktopColumnWidths = computed(() => {
@@ -2149,6 +2150,7 @@ const quotaRefreshSupported = computed(() => {
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
|| selectedProviderType.value === 'xai'
})
function canResetCycleStats(_key: PoolKeyDetail): boolean {
@@ -3480,8 +3482,10 @@ function normalizeQuotaLabel(label: string): string {
if (/spark/i.test(normalized) && normalized.includes('周')) return 'Spark周'
if (normalized.includes('5H')) return '5H'
if (normalized.includes('周')) return '周'
if (normalized.includes('月')) return '月'
if (normalized.includes('最低剩余')) return '最低'
if (normalized === '剩余' || normalized.includes('剩余')) return '剩余'
if (normalized === '额度') return '额度'
return normalized
}
@@ -3490,6 +3494,9 @@ function getQuotaProgressLabel(label: string): string {
if (label === '5H') return '5H'
if (label === '周') return '周'
if (label === '月') return '月'
if (label === '周额度') return '周'
if (label === '月额度') return '月'
if (label === '额度') return '额度'
if (label === 'Spark5H') return 'Spark5H'
if (label === 'Spark周') return 'Spark周'
if (label === '最低') return '最低'
@@ -3498,7 +3505,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
const staticResetLabels = ['日', '5H', '周', '月', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
const staticResetLabels = ['日', '5H', '周', '月', '周额度', '月额度', '额度', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
if (!item.allowDynamicReset && !staticResetLabels.includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
@@ -3567,6 +3574,7 @@ function getQuotaLabelOrder(label: string): number {
if (label === 'Prompt') return 12
if (label === 'Flex') return 13
if (label === '剩余') return 14
if (label === '额度') return 14
if (label === '最低') return 15
if (label === '生图') return 16
if (label === '速率') return 17
@@ -3758,7 +3766,7 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'kiro') {
if (providerType === 'kiro' || providerType === 'xai') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const window = getQuotaSnapshotWindow(quota, 'usage')
@@ -3772,12 +3780,13 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
: undefined
return [{
label: '剩余',
label: normalizeQuotaLabel(String(window?.label || '').trim() || '剩余'),
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}]
}