mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user