mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -1,4 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { ModelTestCapabilities } from './endpoints/types'
|
||||
import axios from 'axios'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
@@ -420,6 +421,8 @@ export interface ProviderModelsQueryResponse {
|
||||
owned_by?: string
|
||||
display_name?: string
|
||||
api_format?: string
|
||||
api_formats?: string[]
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}>
|
||||
error?: string
|
||||
from_cache?: boolean
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface PoolPresetMeta {
|
||||
export interface PoolKeyDetail {
|
||||
key_id: string
|
||||
key_name: string
|
||||
provider_type?: string | null
|
||||
is_active: boolean
|
||||
auth_type: string
|
||||
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
|
||||
|
||||
@@ -111,6 +111,12 @@ export async function importProviderRefreshToken(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function updateProvider(
|
||||
providerId: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro'
|
||||
provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
|
||||
description: string | null
|
||||
website: string
|
||||
provider_priority: number
|
||||
@@ -126,7 +126,7 @@ export async function updateProvider(
|
||||
export async function createProvider(
|
||||
data: {
|
||||
name: string
|
||||
provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro'
|
||||
provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
|
||||
description?: string
|
||||
website?: string
|
||||
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
|
||||
|
||||
@@ -389,11 +389,25 @@ export interface ChatGPTWebUpstreamMetadata {
|
||||
user_id?: string | null
|
||||
}
|
||||
|
||||
export interface GrokUpstreamMetadata {
|
||||
updated_at?: number // Unix 时间戳(秒)
|
||||
plan_type?: string | null
|
||||
pool_tier?: string | null
|
||||
is_banned?: boolean | null
|
||||
ban_reason?: string | null
|
||||
last_rate_limit_probe_at?: number | null
|
||||
clearance_state?: string | null
|
||||
email?: string | null
|
||||
account_id?: string | null
|
||||
account_user_id?: string | null
|
||||
}
|
||||
|
||||
export interface UpstreamMetadata {
|
||||
codex?: CodexUpstreamMetadata
|
||||
antigravity?: AntigravityUpstreamMetadata
|
||||
kiro?: KiroUpstreamMetadata
|
||||
chatgpt_web?: ChatGPTWebUpstreamMetadata
|
||||
grok?: GrokUpstreamMetadata
|
||||
}
|
||||
|
||||
// 按格式的健康度数据
|
||||
@@ -512,7 +526,7 @@ export interface PublicEndpointStatusMonitorResponse {
|
||||
formats: PublicEndpointStatusMonitor[]
|
||||
}
|
||||
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'vertex_ai'
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'vertex_ai'
|
||||
|
||||
export interface ClaudeCodeAdvancedConfig {
|
||||
// 会话数量控制:null/undefined 表示不限制
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface QuotaStatusSnapshot {
|
||||
reset_at?: number | null
|
||||
reset_seconds?: number | null
|
||||
plan_type?: string | null
|
||||
pool_tier?: string | null
|
||||
credits?: QuotaCreditsSnapshot | null
|
||||
windows?: QuotaWindowSnapshot[] | null
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
30
frontend/src/utils/__tests__/providerKeyAuth.spec.ts
Normal file
30
frontend/src/utils/__tests__/providerKeyAuth.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getProviderMaskedSecretLabel,
|
||||
shouldShowOAuthRefreshControl,
|
||||
} from '@/utils/providerKeyAuth'
|
||||
|
||||
describe('providerKeyAuth', () => {
|
||||
it('renders Grok OAuth-managed cookies as sessions without OAuth refresh controls', () => {
|
||||
const key = {
|
||||
auth_type: 'oauth',
|
||||
oauth_managed: true,
|
||||
can_refresh_oauth: false,
|
||||
}
|
||||
|
||||
expect(getProviderMaskedSecretLabel(key, 'grok')).toBe('[Session Cookie]')
|
||||
expect(shouldShowOAuthRefreshControl(key, 'grok')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps standard OAuth providers on OAuth token semantics', () => {
|
||||
const key = {
|
||||
auth_type: 'oauth',
|
||||
oauth_managed: true,
|
||||
can_refresh_oauth: false,
|
||||
}
|
||||
|
||||
expect(getProviderMaskedSecretLabel(key, 'codex')).toBe('[OAuth Token]')
|
||||
expect(shouldShowOAuthRefreshControl(key, 'codex')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -39,4 +39,69 @@ describe('providerKeyQuota', () => {
|
||||
},
|
||||
}, 'codex')).toBe('周剩余 90.0% | 5H剩余 80.0% | Spark5H剩余 60.0% | Spark周剩余 95.0%')
|
||||
})
|
||||
|
||||
it('formats Grok account quota from structured quota windows', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
oauth: {
|
||||
code: 'valid',
|
||||
},
|
||||
account: {
|
||||
code: 'ok',
|
||||
blocked: false,
|
||||
},
|
||||
quota: {
|
||||
provider_type: 'grok',
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
windows: [
|
||||
{
|
||||
scope: 'account',
|
||||
used_value: 2,
|
||||
limit_value: 10,
|
||||
remaining_ratio: 0.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}, 'grok')).toBe('剩余 80.0% (8/10)')
|
||||
})
|
||||
|
||||
it('formats Grok mode quota from model-scoped windows', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
oauth: {
|
||||
code: 'valid',
|
||||
},
|
||||
account: {
|
||||
code: 'ok',
|
||||
blocked: false,
|
||||
},
|
||||
quota: {
|
||||
provider_type: 'grok',
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
plan_type: 'heavy',
|
||||
windows: [
|
||||
{
|
||||
code: 'model:quota_auto',
|
||||
label: 'auto',
|
||||
scope: 'model',
|
||||
remaining_ratio: 0.4,
|
||||
used_value: 90,
|
||||
limit_value: 150,
|
||||
},
|
||||
{
|
||||
code: 'model:quota_heavy',
|
||||
label: 'heavy',
|
||||
scope: 'model',
|
||||
remaining_ratio: 0,
|
||||
used_value: 20,
|
||||
limit_value: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}, 'grok')).toBe('Auto剩余 40.0% (60/150) | Heavy剩余 0.0% (0/20)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ export const OAUTH_ICONS: Record<string, string> = {
|
||||
github: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`,
|
||||
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>`,
|
||||
}
|
||||
|
||||
// Default icon when provider type is not found
|
||||
|
||||
@@ -8,6 +8,9 @@ const PLAN_TYPE_LABELS: Record<string, string> = {
|
||||
'pro+': 'Pro+',
|
||||
power: 'Power',
|
||||
ultra: 'Ultra',
|
||||
basic: 'Basic',
|
||||
super: 'Super',
|
||||
heavy: 'Heavy',
|
||||
}
|
||||
|
||||
const PLAN_TYPE_CLASS_NAMES: Record<string, string> = {
|
||||
@@ -20,6 +23,9 @@ const PLAN_TYPE_CLASS_NAMES: Record<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',
|
||||
}
|
||||
|
||||
export function normalizeOAuthPlanType(planType?: string | null): string | null {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface ProviderKeyAuthCarrier {
|
||||
provider_type?: string | null
|
||||
auth_type?: string | null
|
||||
credential_kind?: string | null
|
||||
runtime_auth_kind?: string | null
|
||||
@@ -15,6 +16,14 @@ function normalizeText(value: unknown): string | null {
|
||||
return text || null
|
||||
}
|
||||
|
||||
function resolveProviderType(input: ProviderKeyAuthCarrier, providerType?: string | null): string | null {
|
||||
return normalizeText(providerType) ?? normalizeText(input.provider_type)
|
||||
}
|
||||
|
||||
function isGrokSessionCredential(input: ProviderKeyAuthCarrier, providerType?: string | null): boolean {
|
||||
return resolveProviderType(input, providerType) === 'grok' && isOAuthManagedCredential(input)
|
||||
}
|
||||
|
||||
export function getProviderCredentialKind(
|
||||
input: ProviderKeyAuthCarrier,
|
||||
): 'raw_secret' | 'oauth_session' | 'service_account' {
|
||||
@@ -78,7 +87,11 @@ export function canRefreshOAuthCredential(input: ProviderKeyAuthCarrier): boolea
|
||||
return isOAuthManagedCredential(input)
|
||||
}
|
||||
|
||||
export function shouldShowOAuthRefreshControl(input: ProviderKeyAuthCarrier): boolean {
|
||||
export function shouldShowOAuthRefreshControl(
|
||||
input: ProviderKeyAuthCarrier,
|
||||
providerType?: string | null,
|
||||
): boolean {
|
||||
if (isGrokSessionCredential(input, providerType)) return false
|
||||
return isOAuthManagedCredential(input)
|
||||
}
|
||||
|
||||
@@ -103,7 +116,11 @@ export function getProviderAuthLabel(input: ProviderKeyAuthCarrier): string {
|
||||
return getProviderRuntimeAuthKind(input) === 'bearer' ? 'Bearer' : 'API Key'
|
||||
}
|
||||
|
||||
export function getProviderMaskedSecretLabel(input: ProviderKeyAuthCarrier): string {
|
||||
export function getProviderMaskedSecretLabel(
|
||||
input: ProviderKeyAuthCarrier,
|
||||
providerType?: string | null,
|
||||
): string {
|
||||
if (isGrokSessionCredential(input, providerType)) return '[Session Cookie]'
|
||||
if (isOAuthManagedCredential(input)) return '[OAuth Token]'
|
||||
if (isServiceAccountCredential(input)) return '[Service Account]'
|
||||
if (getProviderRuntimeAuthKind(input) === 'mixed') return '[Key]'
|
||||
|
||||
@@ -94,6 +94,37 @@ function formatQuotaValue(value: number | null | undefined): string {
|
||||
return normalized.toFixed(1)
|
||||
}
|
||||
|
||||
function getQuotaWindowValueText(window: QuotaWindowSnapshot | null | undefined): string | null {
|
||||
if (!window || typeof window.limit_value !== 'number' || window.limit_value <= 0) return null
|
||||
if (typeof window.remaining_value === 'number') {
|
||||
return `${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
if (typeof window.used_value === 'number') {
|
||||
return `${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const GROK_QUOTA_MODE_LABELS: Record<string, string> = {
|
||||
quota_auto: 'Auto',
|
||||
auto: 'Auto',
|
||||
quota_fast: 'Fast',
|
||||
fast: 'Fast',
|
||||
quota_expert: 'Expert',
|
||||
expert: 'Expert',
|
||||
quota_heavy: 'Heavy',
|
||||
heavy: 'Heavy',
|
||||
quota_grok_4_3: 'Grok 4.3',
|
||||
'grok-420-computer-use-sa': 'Grok 4.3',
|
||||
}
|
||||
|
||||
function getGrokQuotaWindowLabel(window: QuotaWindowSnapshot): string {
|
||||
const rawCode = normalizeText(window.code)?.replace(/^model:/i, '') || ''
|
||||
const rawLabel = normalizeText(window.label) || normalizeText(window.model) || rawCode
|
||||
const normalized = (rawLabel || rawCode).trim().toLowerCase()
|
||||
return GROK_QUOTA_MODE_LABELS[normalized] || GROK_QUOTA_MODE_LABELS[rawCode.toLowerCase()] || rawLabel || rawCode || '模式'
|
||||
}
|
||||
|
||||
function getCodexQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const parts: string[] = []
|
||||
for (const [label, code] of [
|
||||
@@ -142,6 +173,47 @@ function getKiroQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
return normalizeText(quota.label)
|
||||
}
|
||||
|
||||
function getGrokQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const code = normalizeText(quota.code)?.toLowerCase()
|
||||
if (code === 'banned') {
|
||||
return normalizeText(quota.label) || '账号已封禁'
|
||||
}
|
||||
if (code === 'forbidden') {
|
||||
return normalizeText(quota.label) || '访问受限'
|
||||
}
|
||||
|
||||
const modelWindows = getQuotaWindowsByScope(quota, 'model')
|
||||
const modelParts = modelWindows
|
||||
.map((window) => {
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) return null
|
||||
const valueText = getQuotaWindowValueText(window)
|
||||
return `${getGrokQuotaWindowLabel(window)}剩余 ${formatPercent(remainingPercent)}${valueText ? ` (${valueText})` : ''}`
|
||||
})
|
||||
.filter((value): value is string => value != null)
|
||||
|
||||
if (modelParts.length > 0) return modelParts.join(' | ')
|
||||
|
||||
const window = getQuotaWindow(quota, 'usage') ?? getQuotaWindowsByScope(quota, 'account')[0] ?? null
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (typeof window?.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0 && window.remaining_value <= 0) {
|
||||
return `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
if (remainingPercent != null) {
|
||||
const valueText = getQuotaWindowValueText(window)
|
||||
if (valueText) {
|
||||
return `剩余 ${formatPercent(remainingPercent)} (${valueText})`
|
||||
}
|
||||
return `剩余 ${formatPercent(remainingPercent)}`
|
||||
}
|
||||
|
||||
if (typeof window?.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
return `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
|
||||
return normalizeText(quota.label)
|
||||
}
|
||||
|
||||
function getAntigravityQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const code = normalizeText(quota.code)?.toLowerCase()
|
||||
if (code === 'forbidden') {
|
||||
@@ -238,6 +310,8 @@ export function getQuotaSnapshotFallbackText(
|
||||
return getCodexQuotaText(quota)
|
||||
case 'kiro':
|
||||
return getKiroQuotaText(quota)
|
||||
case 'grok':
|
||||
return getGrokQuotaText(quota)
|
||||
case 'antigravity':
|
||||
return getAntigravityQuotaText(quota)
|
||||
case 'gemini_cli':
|
||||
|
||||
@@ -573,7 +573,7 @@
|
||||
<Copy class="w-2.5 h-2.5" />
|
||||
</Button>
|
||||
<span class="font-mono">
|
||||
{{ getProviderMaskedSecretLabel(key) }}
|
||||
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
|
||||
</span>
|
||||
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
|
||||
<Button
|
||||
@@ -603,7 +603,7 @@
|
||||
</span>
|
||||
</template>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
v-if="keyUiStateMap[key.key_id]?.planLabel"
|
||||
variant="outline"
|
||||
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||
:class="keyUiStateMap[key.key_id]?.planClass || ''"
|
||||
@@ -637,10 +637,11 @@
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
|
||||
<span
|
||||
v-if="getQuotaProgressDisplayText(item)"
|
||||
v-if="getQuotaProgressResetDisplayText(item)"
|
||||
data-testid="pool-quota-reset-text"
|
||||
class="text-muted-foreground/80 tabular-nums truncate"
|
||||
:title="item.detail"
|
||||
>{{ getQuotaProgressDisplayText(item) }}</span>
|
||||
:title="getQuotaProgressResetDisplayText(item)"
|
||||
>{{ getQuotaProgressResetDisplayText(item) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
|
||||
@@ -651,9 +652,10 @@
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
data-testid="pool-quota-meter-text"
|
||||
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
|
||||
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
|
||||
>{{ item.remainingPercent.toFixed(1) }}%</span>
|
||||
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1153,11 +1155,12 @@
|
||||
>
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
|
||||
<span
|
||||
v-if="getQuotaProgressDisplayText(item)"
|
||||
class="text-muted-foreground/80 tabular-nums truncate"
|
||||
:title="item.detail"
|
||||
>{{ getQuotaProgressDisplayText(item) }}</span>
|
||||
<span
|
||||
v-if="getQuotaProgressResetDisplayText(item)"
|
||||
data-testid="pool-quota-reset-text"
|
||||
class="text-muted-foreground/80 tabular-nums truncate"
|
||||
:title="getQuotaProgressResetDisplayText(item)"
|
||||
>{{ getQuotaProgressResetDisplayText(item) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
|
||||
@@ -1168,9 +1171,10 @@
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
data-testid="pool-quota-meter-text"
|
||||
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
|
||||
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
|
||||
>{{ item.remainingPercent.toFixed(1) }}%</span>
|
||||
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2073,6 +2077,7 @@ const showAccountQuotaColumn = computed(() => {
|
||||
|| selectedProviderType.value === 'gemini_cli'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
|| selectedProviderType.value === 'grok'
|
||||
|| selectedProviderType.value === 'chatgpt_web'
|
||||
})
|
||||
|
||||
@@ -2377,8 +2382,9 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
|
||||
const visibleOAuthState = getVisibleOAuthState(key)
|
||||
const oauthOrgBadge = getOAuthOrgBadge(key)
|
||||
const quotaFallbackText = getQuotaFallbackText(key)
|
||||
const planType = resolvePoolKeyPlanType(key)
|
||||
const canRefreshToken = canRefreshOAuthCredential(key)
|
||||
const showOAuthRefreshControl = shouldShowOAuthRefreshControl(key)
|
||||
const showOAuthRefreshControl = shouldShowOAuthRefreshControl(key, selectedProviderType.value)
|
||||
|
||||
map[key.key_id] = {
|
||||
rowClass: getRowClass(key),
|
||||
@@ -2391,8 +2397,8 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
|
||||
oauthRefreshButtonTitle: showOAuthRefreshControl ? getOAuthRefreshButtonTitle(key) : '',
|
||||
showOAuthRefreshControl,
|
||||
canRefreshToken,
|
||||
planLabel: key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : '',
|
||||
planClass: key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : '',
|
||||
planLabel: planType ? formatOAuthPlanType(planType) : '',
|
||||
planClass: planType ? getOAuthPlanTypeClass(planType) : '',
|
||||
quotaFallbackText,
|
||||
quotaTextClass: quotaFallbackText ? getQuotaTextClass(quotaFallbackText) : '',
|
||||
importedAtRelative: formatPoolKeyImportedAt(key),
|
||||
@@ -2470,6 +2476,7 @@ const quotaRefreshSupported = computed(() => {
|
||||
return selectedProviderType.value === 'codex'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
|| selectedProviderType.value === 'grok'
|
||||
|| selectedProviderType.value === 'chatgpt_web'
|
||||
})
|
||||
|
||||
@@ -2589,7 +2596,12 @@ async function refreshCurrentPageQuotaInBackground(
|
||||
|
||||
if (!options.silent) {
|
||||
const skippedText = skippedCount > 0 ? `,冷却跳过 ${skippedCount}` : ''
|
||||
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}${skippedText}`)
|
||||
const firstFailureMessage = result.results.find(item => item.status !== 'success')?.message?.trim()
|
||||
if (successCount === 0 && failedCount > 0 && firstFailureMessage) {
|
||||
showError(`当前页额度刷新失败:${firstFailureMessage}${skippedText}`)
|
||||
} else {
|
||||
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}${skippedText}`)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -2714,7 +2726,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
|
||||
id: key.key_id,
|
||||
provider_id: selectedProviderId.value || '',
|
||||
api_formats: key.api_formats || [],
|
||||
api_key_masked: getProviderMaskedSecretLabel(key),
|
||||
api_key_masked: getProviderMaskedSecretLabel(key, selectedProviderType.value),
|
||||
auth_type: normalizeAuthTypeForEdit(key),
|
||||
auth_type_by_format: key.auth_type_by_format ?? null,
|
||||
credential_kind: key.credential_kind ?? null,
|
||||
@@ -3524,6 +3536,7 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
|
||||
const accountAlert = getAccountAlertLabel(key)
|
||||
const oauthState = getVisibleOAuthState(key)
|
||||
const orgBadge = getOAuthOrgBadge(key)
|
||||
const planType = resolvePoolKeyPlanType(key)
|
||||
|
||||
return buildPoolMobileTagItems({
|
||||
accountStatusLabel: compactPoolStatusLabel(accountAlert),
|
||||
@@ -3532,7 +3545,7 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
|
||||
oauthStatusTone: getMobileOAuthTone(key),
|
||||
priorityLabel: `P${key.internal_priority ?? 50}`,
|
||||
authLabel: getAuthTypeChipLabel(key),
|
||||
planLabel: key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null,
|
||||
planLabel: planType ? formatOAuthPlanType(planType) : null,
|
||||
orgLabel: orgBadge?.label ?? null,
|
||||
proxyLabel: key.proxy?.node_id ? '独立代理' : null,
|
||||
})
|
||||
@@ -3565,6 +3578,9 @@ function formatOAuthPlanType(planType: string): string {
|
||||
ultra: 'Ultra',
|
||||
'pro+': 'Pro+',
|
||||
power: 'Power',
|
||||
basic: 'Basic',
|
||||
super: 'Super',
|
||||
heavy: 'Heavy',
|
||||
}
|
||||
return labelMap[planType.toLowerCase()] || planType
|
||||
}
|
||||
@@ -3580,6 +3596,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()] || ''
|
||||
}
|
||||
@@ -3674,7 +3693,7 @@ function getQuotaProgressLabel(label: string): string {
|
||||
}
|
||||
|
||||
function getQuotaProgressCountdown(item: QuotaProgressItem) {
|
||||
if (!['5H', '周', 'Spark5H', 'Spark周'].includes(item.label)) return null
|
||||
if (!['5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
|
||||
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
|
||||
return getCodexResetCountdown(
|
||||
item.resetAtSeconds,
|
||||
@@ -3704,11 +3723,16 @@ function shouldHideQuotaProgressDetailText(text: string | null | undefined): boo
|
||||
return (text ?? '').trim().includes('已重置')
|
||||
}
|
||||
|
||||
function getQuotaProgressDisplayText(item: QuotaProgressItem): string {
|
||||
function getQuotaProgressResetDisplayText(item: QuotaProgressItem): string {
|
||||
const countdownText = getQuotaProgressCountdownText(item)
|
||||
if (countdownText) return formatCompactQuotaCountdownText(countdownText)
|
||||
return ''
|
||||
}
|
||||
|
||||
function getQuotaProgressMeterDisplayText(item: QuotaProgressItem): string {
|
||||
const detail = item.detail?.trim() || ''
|
||||
return shouldHideQuotaProgressDetailText(detail) ? '' : detail
|
||||
if (!shouldHideQuotaProgressDetailText(detail) && detail) return detail
|
||||
return `${item.remainingPercent.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function getQuotaFallbackText(key: PoolKeyDetail): string | null {
|
||||
@@ -3718,6 +3742,11 @@ function getQuotaFallbackText(key: PoolKeyDetail): string | null {
|
||||
|
||||
|
||||
function getQuotaLabelOrder(label: string): number {
|
||||
if (label === 'Auto') return 0
|
||||
if (label === 'Fast') return 1
|
||||
if (label === 'Expert') return 2
|
||||
if (label === 'Heavy') return 3
|
||||
if (label === 'Grok 4.3') return 4
|
||||
if (label === '5H') return 0
|
||||
if (label === '周') return 1
|
||||
if (label === 'Spark5H') return 2
|
||||
@@ -3770,6 +3799,14 @@ function getQuotaSnapshotUpdatedAtSeconds(quota: QuotaStatusSnapshot | null | un
|
||||
return normalizeUnixSeconds(quota?.updated_at ?? quota?.observed_at ?? null)
|
||||
}
|
||||
|
||||
function getQuotaSnapshotResetAtSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
|
||||
return normalizeUnixSeconds(quota?.reset_at ?? null)
|
||||
}
|
||||
|
||||
function getQuotaSnapshotResetSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
|
||||
return normalizeRemainingSeconds(quota?.reset_seconds ?? null)
|
||||
}
|
||||
|
||||
function getQuotaSnapshotWindow(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
code: string,
|
||||
@@ -3830,6 +3867,47 @@ function formatQuotaValue(value: number | null | undefined): string {
|
||||
return normalized.toFixed(1)
|
||||
}
|
||||
|
||||
function getQuotaWindowValueText(window: QuotaWindowSnapshot | null | undefined): string | undefined {
|
||||
if (!window || typeof window.limit_value !== 'number' || window.limit_value <= 0) return undefined
|
||||
if (typeof window.remaining_value === 'number') {
|
||||
return `${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
if (typeof window.used_value === 'number') {
|
||||
return `${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolvePoolKeyPlanType(key: PoolKeyDetail): string | null {
|
||||
const direct = key.oauth_plan_type?.trim()
|
||||
if (direct) return direct
|
||||
const quota = getQuotaSnapshot(key)
|
||||
const quotaPlan = quota?.plan_type?.trim()
|
||||
if (quotaPlan) return quotaPlan
|
||||
const quotaPoolTier = quota?.pool_tier?.trim()
|
||||
return quotaPoolTier || null
|
||||
}
|
||||
|
||||
const GROK_QUOTA_MODE_LABELS: Record<string, string> = {
|
||||
quota_auto: 'Auto',
|
||||
auto: 'Auto',
|
||||
quota_fast: 'Fast',
|
||||
fast: 'Fast',
|
||||
quota_expert: 'Expert',
|
||||
expert: 'Expert',
|
||||
quota_heavy: 'Heavy',
|
||||
heavy: 'Heavy',
|
||||
quota_grok_4_3: 'Grok 4.3',
|
||||
'grok-420-computer-use-sa': 'Grok 4.3',
|
||||
}
|
||||
|
||||
function getGrokQuotaWindowLabel(window: QuotaWindowSnapshot): string {
|
||||
const code = String(window.code || '').trim().replace(/^model:/i, '')
|
||||
const label = String(window.label || window.model || code).trim()
|
||||
const normalized = (label || code).toLowerCase()
|
||||
return GROK_QUOTA_MODE_LABELS[normalized] || GROK_QUOTA_MODE_LABELS[code.toLowerCase()] || label || code || '模式'
|
||||
}
|
||||
|
||||
function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressItem[] {
|
||||
const quota = getQuotaSnapshot(key)
|
||||
if (!quota) return []
|
||||
@@ -3838,6 +3916,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
|
||||
if (providerType === 'codex') {
|
||||
const items: QuotaProgressItem[] = []
|
||||
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
|
||||
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
|
||||
for (const [label, code] of [
|
||||
['5H', '5h'],
|
||||
['周', 'weekly'],
|
||||
@@ -3850,8 +3930,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
items.push({
|
||||
label,
|
||||
remainingPercent,
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
})
|
||||
}
|
||||
@@ -3859,6 +3939,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
}
|
||||
|
||||
if (providerType === 'kiro') {
|
||||
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
|
||||
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
|
||||
const window = getQuotaSnapshotWindow(quota, 'usage')
|
||||
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
|
||||
?? null
|
||||
@@ -3873,8 +3955,45 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
label: '剩余',
|
||||
remainingPercent,
|
||||
detail,
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
}]
|
||||
}
|
||||
|
||||
if (providerType === 'grok') {
|
||||
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
|
||||
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
|
||||
const modelWindows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||
if (modelWindows.length > 0) {
|
||||
return modelWindows
|
||||
.map((window): QuotaProgressItem | null => {
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) return null
|
||||
return {
|
||||
label: getGrokQuotaWindowLabel(window),
|
||||
remainingPercent,
|
||||
detail: getQuotaWindowValueText(window),
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
}
|
||||
})
|
||||
.filter((item): item is QuotaProgressItem => item != null)
|
||||
}
|
||||
|
||||
const window = getQuotaSnapshotWindow(quota, 'usage')
|
||||
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
|
||||
?? null
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) return []
|
||||
|
||||
return [{
|
||||
label: '剩余',
|
||||
remainingPercent,
|
||||
detail: getQuotaWindowValueText(window),
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
}]
|
||||
}
|
||||
@@ -4075,29 +4194,6 @@ function getQuotaTextClass(quotaText: string): string {
|
||||
return 'text-[11px] text-foreground/90 leading-4'
|
||||
}
|
||||
|
||||
function formatStatInteger(value: number | null | undefined): string {
|
||||
const n = Number(value ?? 0)
|
||||
if (!Number.isFinite(n) || n <= 0) return '0'
|
||||
return Math.round(n).toLocaleString('en-US')
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number | null | undefined): string {
|
||||
const n = Number(value ?? 0)
|
||||
if (!Number.isFinite(n) || n <= 0) return '0'
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(Math.round(n))
|
||||
}
|
||||
|
||||
function formatStatUsd(value: number | string | null | undefined): string {
|
||||
const n = Number(value ?? 0)
|
||||
if (!Number.isFinite(n) || n <= 0) return '$0.00'
|
||||
if (n < 0.01) return `$${n.toFixed(4)}`
|
||||
if (n < 1) return `$${n.toFixed(3)}`
|
||||
if (n < 1000) return `$${n.toFixed(2)}`
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPoolScore(value: number | null | undefined): string {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '-'
|
||||
|
||||
Reference in New Issue
Block a user