mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3310 from heathermhuang/codex/grok-subscription-support
feat: add grok subscription support
This commit is contained in:
@@ -9,12 +9,13 @@ import {
|
||||
type DefaultPlatformQuotasMap,
|
||||
} from "@/api/admin/settings";
|
||||
|
||||
/** 全 null 的 4 平台 map,用于断言归一化默认值 */
|
||||
/** 全 null 的 5 平台 map,用于断言归一化默认值 */
|
||||
const allNullQuotas: DefaultPlatformQuotasMap = {
|
||||
anthropic: { daily: null, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: null, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: null },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
grok: { daily: null, weekly: null, monthly: null },
|
||||
}
|
||||
|
||||
describe("admin settings auth source defaults helpers", () => {
|
||||
@@ -236,11 +237,12 @@ describe("normalizePlatformQuotasMap", () => {
|
||||
expect(result.openai).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(result.gemini).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(result.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(result.grok).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
|
||||
it("无参数时返回全 4 平台全 null", () => {
|
||||
it("无参数时返回全 5 平台全 null", () => {
|
||||
const result = normalizePlatformQuotasMap();
|
||||
expect(Object.keys(result)).toHaveLength(4);
|
||||
expect(Object.keys(result)).toHaveLength(5);
|
||||
for (const v of Object.values(result)) {
|
||||
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
}
|
||||
@@ -288,7 +290,7 @@ describe("sanitizePlatformQuotasMap", () => {
|
||||
|
||||
it("缺失平台填充为全 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({});
|
||||
expect(Object.keys(result)).toHaveLength(4);
|
||||
expect(Object.keys(result)).toHaveLength(5);
|
||||
for (const v of Object.values(result)) {
|
||||
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Admin Grok/xAI API endpoints
|
||||
* Handles xAI OAuth flows for administrators.
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface GrokAuthUrlResponse {
|
||||
auth_url: string
|
||||
session_id: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface GrokAuthUrlRequest {
|
||||
proxy_id?: number
|
||||
redirect_uri?: string
|
||||
}
|
||||
|
||||
export interface GrokExchangeCodeRequest {
|
||||
session_id: string
|
||||
state: string
|
||||
code: string
|
||||
proxy_id?: number
|
||||
redirect_uri?: string
|
||||
}
|
||||
|
||||
export interface GrokTokenInfo {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
id_token?: string
|
||||
expires_at?: number | string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
client_id?: string
|
||||
email?: string
|
||||
subscription_tier?: string
|
||||
entitlement_status?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface GrokQuotaWindow {
|
||||
limit?: number | null
|
||||
remaining?: number | null
|
||||
reset_unix?: number | null
|
||||
reset_at?: string | null
|
||||
}
|
||||
|
||||
export interface GrokQuotaSnapshot {
|
||||
requests?: GrokQuotaWindow | null
|
||||
tokens?: GrokQuotaWindow | null
|
||||
retry_after_seconds?: number | null
|
||||
subscription_tier?: string
|
||||
entitlement_status?: string
|
||||
status_code?: number
|
||||
headers?: Record<string, string>
|
||||
headers_observed: boolean
|
||||
observation_source?: string
|
||||
last_probe_at?: string
|
||||
last_headers_seen_at?: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GrokQuotaProbeResult {
|
||||
source: 'active_probe'
|
||||
snapshot?: GrokQuotaSnapshot | null
|
||||
status_code?: number
|
||||
headers_observed: boolean
|
||||
reset_supported: boolean
|
||||
fetched_at: number
|
||||
}
|
||||
|
||||
export interface GrokQuotaResetResult {
|
||||
supported: boolean
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function generateAuthUrl(
|
||||
payload: GrokAuthUrlRequest
|
||||
): Promise<GrokAuthUrlResponse> {
|
||||
const { data } = await apiClient.post<GrokAuthUrlResponse>(
|
||||
'/admin/grok/oauth/auth-url',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeCode(payload: GrokExchangeCodeRequest): Promise<GrokTokenInfo> {
|
||||
const { data } = await apiClient.post<GrokTokenInfo>(
|
||||
'/admin/grok/oauth/exchange-code',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function refreshGrokToken(
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo> {
|
||||
const payload: Record<string, unknown> = { refresh_token: refreshToken }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
|
||||
const { data } = await apiClient.post<GrokTokenInfo>(
|
||||
'/admin/grok/oauth/refresh-token',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function queryQuota(id: number): Promise<GrokQuotaProbeResult> {
|
||||
const { data } = await apiClient.get<GrokQuotaProbeResult>(`/admin/grok/accounts/${id}/quota`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetQuota(id: number): Promise<GrokQuotaResetResult> {
|
||||
const { data } = await apiClient.post<GrokQuotaResetResult>(`/admin/grok/accounts/${id}/reset-quota`)
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota }
|
||||
@@ -17,6 +17,7 @@ import subscriptionsAPI from './subscriptions'
|
||||
import usageAPI from './usage'
|
||||
import geminiAPI from './gemini'
|
||||
import antigravityAPI from './antigravity'
|
||||
import grokAPI from './grok'
|
||||
import userAttributesAPI from './userAttributes'
|
||||
import opsAPI from './ops'
|
||||
import errorPassthroughAPI from './errorPassthrough'
|
||||
@@ -51,6 +52,7 @@ export const adminAPI = {
|
||||
usage: usageAPI,
|
||||
gemini: geminiAPI,
|
||||
antigravity: antigravityAPI,
|
||||
grok: grokAPI,
|
||||
userAttributes: userAttributesAPI,
|
||||
ops: opsAPI,
|
||||
errorPassthrough: errorPassthroughAPI,
|
||||
@@ -83,6 +85,7 @@ export {
|
||||
usageAPI,
|
||||
geminiAPI,
|
||||
antigravityAPI,
|
||||
grokAPI,
|
||||
userAttributesAPI,
|
||||
opsAPI,
|
||||
errorPassthroughAPI,
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface DefaultSubscriptionSetting {
|
||||
}
|
||||
|
||||
// ── 平台限额类型 ──────────────────────────────────────────────────
|
||||
export type PlatformType = "anthropic" | "openai" | "gemini" | "antigravity"
|
||||
export type PlatformType = "anthropic" | "openai" | "gemini" | "antigravity" | "grok"
|
||||
export type QuotaWindowType = "daily" | "weekly" | "monthly"
|
||||
|
||||
/** 单平台三档限额;null = 不限制,undefined = 未填(等价 null) */
|
||||
@@ -30,7 +30,7 @@ export interface PlatformQuotaLimits {
|
||||
/** 全平台默认限额 map(key = PlatformType) */
|
||||
export type DefaultPlatformQuotasMap = Partial<Record<PlatformType, PlatformQuotaLimits>>
|
||||
|
||||
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity"]
|
||||
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok"]
|
||||
|
||||
/** 归一化为全 4 平台 × 3 窗口(缺失填 null),供模板非空绑定 */
|
||||
export function normalizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
|
||||
|
||||
@@ -307,7 +307,7 @@ export async function bindUserAuthIdentity(
|
||||
/**
|
||||
* Platform quota types
|
||||
*/
|
||||
export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
|
||||
export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
export interface PlatformQuotaItem {
|
||||
|
||||
@@ -102,7 +102,11 @@
|
||||
</div>
|
||||
|
||||
<!-- No data yet -->
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
<!-- Always allow on-demand upstream quota probe, even before passive headers exist. -->
|
||||
<GrokQuotaProbeCell :account="account" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- OpenAI OAuth accounts: single source from /usage API -->
|
||||
@@ -320,6 +324,78 @@
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<!-- Grok OAuth accounts: passive xAI quota headers + local Sub2API usage -->
|
||||
<template v-else-if="account.platform === 'grok' && account.type === 'oauth'">
|
||||
<div v-if="loading" class="space-y-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="error" class="text-xs text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="needsReauth" class="space-y-1">
|
||||
<span class="inline-block rounded px-1.5 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">
|
||||
{{ t('admin.accounts.needsReauth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="isForbidden" class="space-y-1">
|
||||
<span class="inline-block rounded px-1.5 py-0.5 text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||
{{ grokEntitlementLabel || t('admin.accounts.forbidden') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="usageInfo" class="space-y-1">
|
||||
<div v-if="grokEntitlementLabel" class="mb-0.5">
|
||||
<span class="inline-block rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{{ grokEntitlementLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="grokLocalUsage" class="mb-0.5 flex items-center">
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatWindowRequests(grokLocalUsage) }} req
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatWindowTokens(grokLocalUsage) }}
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
|
||||
A ${{ formatWindowCost(grokLocalUsage) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<UsageProgressBar
|
||||
v-if="grokRequestQuotaBar"
|
||||
:label="t('admin.accounts.usageWindow.grokRequests')"
|
||||
:utilization="grokRequestQuotaBar.utilization"
|
||||
:resets-at="grokRequestQuotaBar.resetsAt"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
v-if="grokTokenQuotaBar"
|
||||
:label="t('admin.accounts.usageWindow.grokTokens')"
|
||||
:utilization="grokTokenQuotaBar.utilization"
|
||||
:resets-at="grokTokenQuotaBar.resetsAt"
|
||||
color="emerald"
|
||||
/>
|
||||
<div v-if="grokRetryAfterLabel" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.usageWindow.grokRetryAfter', { time: grokRetryAfterLabel }) }}
|
||||
</div>
|
||||
<div v-if="grokQuotaUnknown" class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{{ grokQuotaUnknownLabel }}
|
||||
</div>
|
||||
<div v-else-if="usageInfo.error" class="truncate text-xs text-amber-600 dark:text-amber-400 max-w-[200px]" :title="usageInfo.error">
|
||||
{{ usageErrorLabel }}
|
||||
</div>
|
||||
<div v-if="grokQuotaStatusLine" class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{{ grokQuotaStatusLine }}
|
||||
</div>
|
||||
<GrokQuotaProbeCell :account="account" />
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<!-- Gemini platform: show quota + local usage window -->
|
||||
<template v-else-if="account.platform === 'gemini'">
|
||||
<!-- Auth Type + Tier Badge (first line) -->
|
||||
@@ -511,10 +587,11 @@ import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import { formatCompactNumber, formatRelativeTime } from '@/utils/format'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
|
||||
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
|
||||
|
||||
// Module-level cache shared across all AccountUsageCell instances
|
||||
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
@@ -573,6 +650,9 @@ const shouldFetchUsage = computed(() => {
|
||||
if (props.account.platform === 'antigravity') {
|
||||
return props.account.type === 'oauth'
|
||||
}
|
||||
if (props.account.platform === 'grok') {
|
||||
return props.account.type === 'oauth'
|
||||
}
|
||||
if (props.account.platform === 'openai') {
|
||||
return props.account.type === 'oauth'
|
||||
}
|
||||
@@ -933,6 +1013,72 @@ const geminiUsageBars = computed(() => {
|
||||
return bars
|
||||
})
|
||||
|
||||
interface GrokQuotaBarInfo {
|
||||
utilization: number
|
||||
resetsAt: string | null
|
||||
}
|
||||
|
||||
const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number | null; reset_at?: string | null } | null): GrokQuotaBarInfo | null => {
|
||||
if (!quota || quota.limit == null || quota.remaining == null || quota.limit <= 0) return null
|
||||
const used = Math.max(0, quota.limit - quota.remaining)
|
||||
return {
|
||||
utilization: Math.min(100, (used / quota.limit) * 100),
|
||||
resetsAt: quota.reset_at || null
|
||||
}
|
||||
}
|
||||
|
||||
const grokRequestQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_request_quota))
|
||||
const grokTokenQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_token_quota))
|
||||
const grokQuotaUnknown = computed(() => {
|
||||
if (props.account.platform !== 'grok') return false
|
||||
if (grokRequestQuotaBar.value || grokTokenQuotaBar.value) return false
|
||||
return usageInfo.value?.grok_quota_snapshot_state !== 'observed'
|
||||
})
|
||||
const grokQuotaUnknownLabel = computed(() => {
|
||||
return usageInfo.value?.grok_quota_snapshot_state === 'no_headers'
|
||||
? t('admin.accounts.usageWindow.grokNoHeaders')
|
||||
: t('admin.accounts.usageWindow.grokUnknown')
|
||||
})
|
||||
const grokQuotaStatusLine = computed(() => {
|
||||
if (props.account.platform !== 'grok') return null
|
||||
const parts: string[] = []
|
||||
const status = usageInfo.value?.grok_last_status_code
|
||||
if (status) {
|
||||
parts.push(t('admin.accounts.usageWindow.grokLastStatus', { status }))
|
||||
}
|
||||
if (usageInfo.value?.grok_last_quota_probe_at) {
|
||||
parts.push(
|
||||
t('admin.accounts.usageWindow.grokLastProbe', {
|
||||
time: formatRelativeTime(usageInfo.value.grok_last_quota_probe_at)
|
||||
})
|
||||
)
|
||||
}
|
||||
if (usageInfo.value?.grok_last_headers_seen_at) {
|
||||
parts.push(
|
||||
t('admin.accounts.usageWindow.grokLastHeadersSeen', {
|
||||
time: formatRelativeTime(usageInfo.value.grok_last_headers_seen_at)
|
||||
})
|
||||
)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' | ') : null
|
||||
})
|
||||
const grokLocalUsage = computed(() => usageInfo.value?.grok_local_usage || props.todayStats || null)
|
||||
const grokEntitlementLabel = computed(() => {
|
||||
const status = (usageInfo.value?.grok_entitlement_status || '').trim()
|
||||
return status || null
|
||||
})
|
||||
const grokRetryAfterLabel = computed(() => {
|
||||
const seconds = usageInfo.value?.grok_retry_after_seconds
|
||||
if (seconds == null || seconds <= 0) return null
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.ceil(seconds / 60)
|
||||
return `${minutes}m`
|
||||
})
|
||||
|
||||
const formatWindowRequests = (stats: WindowStats) => formatCompactNumber(stats.requests, { allowBillions: false })
|
||||
const formatWindowTokens = (stats: WindowStats) => formatCompactNumber(stats.tokens)
|
||||
const formatWindowCost = (stats: WindowStats) => stats.cost.toFixed(2)
|
||||
|
||||
// 账户类型显示标签
|
||||
const antigravityTierLabel = computed(() => {
|
||||
switch (antigravityTier.value) {
|
||||
@@ -1039,7 +1185,9 @@ const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const fetchFn = () => adminAPI.accounts.getUsage(props.account.id, options?.source)
|
||||
const fetchFn = () => options?.source
|
||||
? adminAPI.accounts.getUsage(props.account.id, options.source)
|
||||
: adminAPI.accounts.getUsage(props.account.id)
|
||||
const result = await enqueueUsageRequest(props.account, fetchFn)
|
||||
if (!unmounted.value) {
|
||||
usageInfo.value = result
|
||||
@@ -1226,6 +1374,7 @@ watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
if (!prevKey || nextKey === prevKey) return
|
||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
||||
|
||||
_usageCache.delete(props.account.id)
|
||||
requestAutoLoad()
|
||||
})
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<!-- Platform Selection - Segmented Control Style -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.platform') }}</label>
|
||||
<div class="mt-2 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700" data-tour="account-form-platform">
|
||||
<div class="mt-2 flex flex-wrap rounded-lg bg-gray-100 p-1 dark:bg-dark-700" data-tour="account-form-platform">
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'anthropic'"
|
||||
@@ -147,6 +147,19 @@
|
||||
<Icon name="cloud" size="sm" />
|
||||
Antigravity
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'grok'"
|
||||
:class="[
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
||||
form.platform === 'grok'
|
||||
? 'bg-white text-slate-700 shadow-sm dark:bg-dark-600 dark:text-slate-200'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
<PlatformIcon platform="grok" size="sm" />
|
||||
Grok
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,6 +352,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Grok - OAuth only) -->
|
||||
<div v-if="form.platform === 'grok'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-type">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'border-slate-500 bg-slate-50 dark:bg-slate-900/20'
|
||||
: 'border-gray-200 hover:border-slate-300 dark:border-dark-600 dark:hover:border-slate-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<PlatformIcon platform="grok" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.grokOauth') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.oauth.grok.oauthOnlyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Gemini) -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1780,7 +1828,7 @@
|
||||
|
||||
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
|
||||
v-if="(form.platform === 'openai' || form.platform === 'grok') && accountCategory === 'oauth-based'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
@@ -2873,10 +2921,10 @@
|
||||
:loading="currentOAuthLoading"
|
||||
:error="currentOAuthError"
|
||||
:show-help="form.platform === 'anthropic'"
|
||||
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
|
||||
:show-proxy-warning="form.platform !== 'openai' && form.platform !== 'grok' && !!form.proxy_id"
|
||||
:allow-multiple="form.platform === 'anthropic'"
|
||||
:show-cookie-option="form.platform === 'anthropic'"
|
||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity'"
|
||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity' || form.platform === 'grok'"
|
||||
:show-mobile-refresh-token-option="form.platform === 'openai'"
|
||||
:show-session-token-option="false"
|
||||
:show-access-token-option="false"
|
||||
@@ -3231,6 +3279,7 @@ import {
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import { useGrokOAuth } from '@/composables/useGrokOAuth'
|
||||
import type {
|
||||
Proxy,
|
||||
AdminGroup,
|
||||
@@ -3246,6 +3295,7 @@ import type {
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
|
||||
@@ -3291,6 +3341,7 @@ const oauthStepTitle = computed(() => {
|
||||
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title')
|
||||
if (form.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.title')
|
||||
if (form.platform === 'grok') return t('admin.accounts.oauth.grok.title')
|
||||
return t('admin.accounts.oauth.title')
|
||||
})
|
||||
|
||||
@@ -3298,12 +3349,14 @@ const oauthStepTitle = computed(() => {
|
||||
const baseUrlHint = computed(() => {
|
||||
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
|
||||
if (form.platform === 'grok') return t('admin.accounts.grok.baseUrlHint')
|
||||
return t('admin.accounts.baseUrlHint')
|
||||
})
|
||||
|
||||
const apiKeyHint = computed(() => {
|
||||
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
|
||||
if (form.platform === 'grok') return t('admin.accounts.grok.apiKeyHint')
|
||||
return t('admin.accounts.apiKeyHint')
|
||||
})
|
||||
|
||||
@@ -3326,12 +3379,14 @@ const oauth = useAccountOAuth() // For Anthropic OAuth
|
||||
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
|
||||
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
|
||||
const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth
|
||||
const grokOAuth = useGrokOAuth() // For Grok OAuth
|
||||
|
||||
// Computed: current OAuth state for template binding
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.authUrl.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.authUrl.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value
|
||||
if (form.platform === 'grok') return grokOAuth.authUrl.value
|
||||
return oauth.authUrl.value
|
||||
})
|
||||
|
||||
@@ -3339,6 +3394,7 @@ const currentSessionId = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.sessionId.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.sessionId.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value
|
||||
if (form.platform === 'grok') return grokOAuth.sessionId.value
|
||||
return oauth.sessionId.value
|
||||
})
|
||||
|
||||
@@ -3346,6 +3402,7 @@ const currentOAuthLoading = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.loading.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.loading.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.loading.value
|
||||
if (form.platform === 'grok') return grokOAuth.loading.value
|
||||
return oauth.loading.value
|
||||
})
|
||||
|
||||
@@ -3353,6 +3410,7 @@ const currentOAuthError = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.error.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.error.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.error.value
|
||||
if (form.platform === 'grok') return grokOAuth.error.value
|
||||
return oauth.error.value
|
||||
})
|
||||
|
||||
@@ -3750,6 +3808,9 @@ const canExchangeCode = computed(() => {
|
||||
if (form.platform === 'antigravity') {
|
||||
return authCode.trim() && antigravityOAuth.sessionId.value && !antigravityOAuth.loading.value
|
||||
}
|
||||
if (form.platform === 'grok') {
|
||||
return authCode.trim() && grokOAuth.sessionId.value && !grokOAuth.loading.value
|
||||
}
|
||||
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
|
||||
})
|
||||
|
||||
@@ -3799,7 +3860,7 @@ watch(
|
||||
if ((form.platform === 'gemini' || form.platform === 'anthropic') && category === 'service_account') {
|
||||
form.type = 'service_account' as AccountType
|
||||
} else if (category === 'oauth-based') {
|
||||
form.type = method as AccountType // 'oauth' or 'setup-token'
|
||||
form.type = form.platform === 'anthropic' ? method as AccountType : 'oauth'
|
||||
} else {
|
||||
form.type = 'apikey'
|
||||
}
|
||||
@@ -3817,7 +3878,9 @@ watch(
|
||||
? 'https://api.openai.com'
|
||||
: newPlatform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: newPlatform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
// Clear model-related settings
|
||||
allowedModels.value = []
|
||||
modelMappings.value = []
|
||||
@@ -3837,6 +3900,13 @@ watch(
|
||||
antigravityModelMappings.value = []
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
}
|
||||
if (newPlatform === 'grok') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
addMethod.value = 'oauth'
|
||||
modelRestrictionMode.value = 'mapping'
|
||||
form.concurrency = 1
|
||||
form.load_factor = null
|
||||
}
|
||||
if (newPlatform !== 'gemini' && newPlatform !== 'anthropic' && accountCategory.value === 'service_account') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
}
|
||||
@@ -3877,6 +3947,7 @@ watch(
|
||||
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
grokOAuth.resetState()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4308,6 +4379,7 @@ const resetForm = () => {
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
grokOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
antigravityMixedChannelConfirmed.value = false
|
||||
clearMixedChannelDialog()
|
||||
@@ -4702,6 +4774,7 @@ const goBackToBasicInfo = () => {
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
grokOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
@@ -4717,6 +4790,8 @@ const handleGenerateUrl = async () => {
|
||||
)
|
||||
} else if (form.platform === 'antigravity') {
|
||||
await antigravityOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else if (form.platform === 'grok') {
|
||||
await grokOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else {
|
||||
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
|
||||
}
|
||||
@@ -4727,6 +4802,8 @@ const handleValidateRefreshToken = (rt: string) => {
|
||||
handleOpenAIValidateRT(rt)
|
||||
} else if (form.platform === 'antigravity') {
|
||||
handleAntigravityValidateRT(rt)
|
||||
} else if (form.platform === 'grok') {
|
||||
handleGrokValidateRT(rt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4790,6 +4867,17 @@ const createAccountAndFinish = async (
|
||||
delete credentials.compact_model_mapping
|
||||
}
|
||||
}
|
||||
if (platform === 'grok') {
|
||||
if (!credentials.base_url) {
|
||||
credentials.base_url = apiKeyBaseUrl.value.trim() || 'https://api.x.ai/v1'
|
||||
}
|
||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
} else {
|
||||
delete credentials.model_mapping
|
||||
}
|
||||
}
|
||||
await doCreateAccount({
|
||||
name: form.name,
|
||||
notes: form.notes,
|
||||
@@ -4808,6 +4896,95 @@ const createAccountAndFinish = async (
|
||||
})
|
||||
}
|
||||
|
||||
// Grok 手动 RT 批量验证和创建
|
||||
const handleGrokValidateRT = async (refreshTokenInput: string) => {
|
||||
if (!refreshTokenInput.trim()) return
|
||||
|
||||
const refreshTokens = refreshTokenInput
|
||||
.split('\n')
|
||||
.map((rt) => rt.trim())
|
||||
.filter((rt) => rt)
|
||||
|
||||
if (refreshTokens.length === 0) {
|
||||
grokOAuth.error.value = t('admin.accounts.oauth.grok.pleaseEnterRefreshToken')
|
||||
return
|
||||
}
|
||||
|
||||
grokOAuth.loading.value = true
|
||||
grokOAuth.error.value = ''
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < refreshTokens.length; i++) {
|
||||
try {
|
||||
const tokenInfo = await grokOAuth.validateRefreshToken(refreshTokens[i], form.proxy_id)
|
||||
if (!tokenInfo) {
|
||||
failedCount++
|
||||
errors.push(`#${i + 1}: ${grokOAuth.error.value || 'Validation failed'}`)
|
||||
grokOAuth.error.value = ''
|
||||
continue
|
||||
}
|
||||
|
||||
const credentials = grokOAuth.buildCredentials(tokenInfo)
|
||||
const extra = grokOAuth.buildExtraInfo(tokenInfo)
|
||||
const accountName = refreshTokens.length > 1 ? `${form.name || tokenInfo.email || 'Grok OAuth Account'} #${i + 1}` : (form.name || tokenInfo.email || 'Grok OAuth Account')
|
||||
|
||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
}
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: accountName,
|
||||
notes: form.notes,
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
load_factor: form.load_factor ?? undefined,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
successCount++
|
||||
} catch (error: any) {
|
||||
failedCount++
|
||||
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
|
||||
errors.push(`#${i + 1}: ${errMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 && failedCount === 0) {
|
||||
appStore.showSuccess(
|
||||
refreshTokens.length > 1
|
||||
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
|
||||
: t('admin.accounts.accountCreated')
|
||||
)
|
||||
emit('created')
|
||||
handleClose()
|
||||
} else if (successCount > 0) {
|
||||
appStore.showWarning(t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount }))
|
||||
grokOAuth.error.value = errors.join('\n')
|
||||
emit('created')
|
||||
} else {
|
||||
grokOAuth.error.value = errors.join('\n')
|
||||
appStore.showError(t('admin.accounts.oauth.batchFailed'))
|
||||
}
|
||||
} finally {
|
||||
grokOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI OAuth 授权码兑换
|
||||
const handleOpenAIExchange = async (authCode: string) => {
|
||||
const oauthClient = openaiOAuth
|
||||
@@ -5341,6 +5518,41 @@ const handleAntigravityExchange = async (authCode: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Grok OAuth 授权码兑换
|
||||
const handleGrokExchange = async (authCode: string) => {
|
||||
if (!authCode.trim() || !grokOAuth.sessionId.value) return
|
||||
|
||||
grokOAuth.loading.value = true
|
||||
grokOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const stateFromInput = oauthFlowRef.value?.oauthState || ''
|
||||
const stateToUse = stateFromInput || grokOAuth.state.value
|
||||
if (!stateToUse) {
|
||||
grokOAuth.error.value = t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(grokOAuth.error.value)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = await grokOAuth.exchangeAuthCode({
|
||||
code: authCode.trim(),
|
||||
sessionId: grokOAuth.sessionId.value,
|
||||
state: stateToUse,
|
||||
proxyId: form.proxy_id
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = grokOAuth.buildCredentials(tokenInfo)
|
||||
const extra = grokOAuth.buildExtraInfo(tokenInfo)
|
||||
await createAccountAndFinish('grok', 'oauth', credentials, extra)
|
||||
} catch (error: any) {
|
||||
grokOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(grokOAuth.error.value)
|
||||
} finally {
|
||||
grokOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic OAuth 授权码兑换
|
||||
const handleAnthropicExchange = async (authCode: string) => {
|
||||
if (!authCode.trim() || !oauth.sessionId.value) return
|
||||
@@ -5441,6 +5653,8 @@ const handleExchangeCode = async () => {
|
||||
return handleGeminiExchange(authCode)
|
||||
case 'antigravity':
|
||||
return handleAntigravityExchange(authCode)
|
||||
case 'grok':
|
||||
return handleGrokExchange(authCode)
|
||||
default:
|
||||
return handleAnthropicExchange(authCode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div v-if="visible" class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-cyan-700 transition-colors hover:bg-cyan-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-cyan-300 dark:hover:bg-cyan-900/30"
|
||||
:disabled="loading"
|
||||
:title="t('admin.accounts.usageWindow.grokProbeTooltip')"
|
||||
@click="handleProbe"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.usageWindow.grokProbe') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-not-allowed items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-gray-400 opacity-70 dark:text-gray-500"
|
||||
disabled
|
||||
:title="t('admin.accounts.usageWindow.grokResetUnsupportedTooltip')"
|
||||
>
|
||||
{{ t('admin.accounts.usageWindow.grokResetUnsupported') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="text-[10px] text-gray-600 dark:text-gray-300">
|
||||
{{ summary }}
|
||||
</div>
|
||||
<div v-if="error" class="truncate text-[10px] text-red-600 dark:text-red-400" :title="error">
|
||||
{{ truncatedError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { GrokQuotaProbeResult, GrokQuotaWindow } from '@/api/admin/grok'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = computed(() => props.account.platform === 'grok' && props.account.type === 'oauth')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<GrokQuotaProbeResult | null>(null)
|
||||
|
||||
const extractErrorMessage = (e: unknown): string => {
|
||||
const err = e as {
|
||||
message?: string
|
||||
reason?: string
|
||||
response?: { data?: { message?: string; error?: string } }
|
||||
}
|
||||
return (
|
||||
err?.message ||
|
||||
err?.reason ||
|
||||
err?.response?.data?.message ||
|
||||
err?.response?.data?.error ||
|
||||
t('common.error')
|
||||
)
|
||||
}
|
||||
|
||||
const formatWindow = (label: string, window?: GrokQuotaWindow | null): string | null => {
|
||||
if (!window || window.limit == null || window.remaining == null) return null
|
||||
return `${label} ${window.remaining}/${window.limit}`
|
||||
}
|
||||
|
||||
const retryAfterLabel = computed(() => {
|
||||
const seconds = data.value?.snapshot?.retry_after_seconds
|
||||
if (seconds == null || seconds <= 0) return null
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
return `${Math.ceil(seconds / 60)}m`
|
||||
})
|
||||
|
||||
const summary = computed(() => {
|
||||
const snapshot = data.value?.snapshot
|
||||
if (!data.value) return ''
|
||||
if (!snapshot) return t('admin.accounts.usageWindow.grokNoHeaders')
|
||||
const parts = [
|
||||
formatWindow(t('admin.accounts.usageWindow.grokRequests'), snapshot.requests),
|
||||
formatWindow(t('admin.accounts.usageWindow.grokTokens'), snapshot.tokens)
|
||||
].filter(Boolean)
|
||||
if (retryAfterLabel.value) {
|
||||
parts.push(t('admin.accounts.usageWindow.grokRetryAfter', { time: retryAfterLabel.value }))
|
||||
}
|
||||
if (snapshot.entitlement_status) {
|
||||
parts.push(snapshot.entitlement_status)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' | ') : t('admin.accounts.usageWindow.grokNoHeaders')
|
||||
})
|
||||
|
||||
const truncatedError = computed(() => {
|
||||
if (!error.value) return ''
|
||||
return error.value.length > 80 ? `${error.value.slice(0, 80)}...` : error.value
|
||||
})
|
||||
|
||||
const handleProbe = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
data.value = await adminAPI.grok.queryQuota(props.account.id)
|
||||
} catch (e) {
|
||||
error.value = extractErrorMessage(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.account.id,
|
||||
() => {
|
||||
data.value = null
|
||||
error.value = null
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -181,7 +181,7 @@ const normalizedPlatforms = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const upstreamSyncPlatforms = new Set(['anthropic', 'openai', 'gemini', 'antigravity'])
|
||||
const upstreamSyncPlatforms = new Set(['anthropic', 'openai', 'gemini', 'antigravity', 'grok'])
|
||||
const canSyncUpstream = computed(() => {
|
||||
if (props.accountId) {
|
||||
if (normalizedPlatforms.value.length === 0) return true
|
||||
|
||||
@@ -616,9 +616,9 @@
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ oauthOpenUrlDesc }}
|
||||
</p>
|
||||
<!-- OpenAI Important Notice -->
|
||||
<!-- Local callback notice -->
|
||||
<div
|
||||
v-if="isOpenAI"
|
||||
v-if="showLocalCallbackNotice"
|
||||
class="mt-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<p
|
||||
@@ -776,13 +776,14 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpenAI = computed(() => props.platform === 'openai')
|
||||
const showLocalCallbackNotice = computed(() => props.platform === 'openai' || props.platform === 'grok')
|
||||
|
||||
// Get translation key based on platform
|
||||
const getOAuthKey = (key: string) => {
|
||||
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
|
||||
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
|
||||
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
|
||||
if (props.platform === 'grok') return `admin.accounts.oauth.grok.${key}`
|
||||
return `admin.accounts.oauth.${key}`
|
||||
}
|
||||
|
||||
@@ -801,6 +802,7 @@ const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
|
||||
const oauthImportantNotice = computed(() => {
|
||||
if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice')
|
||||
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
|
||||
if (props.platform === 'grok') return t('admin.accounts.oauth.grok.importantNotice')
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -853,20 +855,20 @@ watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
})
|
||||
|
||||
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
|
||||
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity/Grok)
|
||||
// e.g., http://localhost:8085/callback?code=xxx...&state=...
|
||||
watch(authCodeInput, (newVal) => {
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity' && props.platform !== 'grok') return
|
||||
|
||||
const trimmed = newVal.trim()
|
||||
// Check if it looks like a URL with code parameter
|
||||
if (trimmed.includes('?') && trimmed.includes('code=')) {
|
||||
if (trimmed.includes('code=')) {
|
||||
try {
|
||||
// Try to parse as URL
|
||||
const url = new URL(trimmed)
|
||||
const url = trimmed.includes('?') ? new URL(trimmed) : new URL(`http://localhost/callback?${trimmed.replace(/^\?/, '')}`)
|
||||
const code = url.searchParams.get('code')
|
||||
const stateParam = url.searchParams.get('state')
|
||||
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
|
||||
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity' || props.platform === 'grok') && stateParam) {
|
||||
oauthState.value = stateParam
|
||||
}
|
||||
if (code && code !== trimmed) {
|
||||
@@ -877,7 +879,7 @@ watch(authCodeInput, (newVal) => {
|
||||
// If URL parsing fails, try regex extraction
|
||||
const match = trimmed.match(/[?&]code=([^&]+)/)
|
||||
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
|
||||
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
|
||||
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity' || props.platform === 'grok') && stateMatch && stateMatch[1]) {
|
||||
oauthState.value = stateMatch[1]
|
||||
}
|
||||
if (match && match[1] && match[1] !== trimmed) {
|
||||
|
||||
@@ -489,7 +489,8 @@ const platformOptions = [
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' }
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'grok', label: 'Grok' }
|
||||
]
|
||||
|
||||
// Load rules when dialog opens
|
||||
|
||||
@@ -25,7 +25,7 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
|
||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
|
||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'grok', label: 'Grok' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
|
||||
const privacyOpts = computed(() => [
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
? 'from-blue-500 to-blue-600'
|
||||
: isAntigravity
|
||||
? 'from-purple-500 to-purple-600'
|
||||
: 'from-orange-500 to-orange-600'
|
||||
: isGrok
|
||||
? 'from-slate-600 to-cyan-600'
|
||||
: 'from-orange-500 to-orange-600'
|
||||
]"
|
||||
>
|
||||
<Icon name="sparkles" size="md" class="text-white" />
|
||||
@@ -37,7 +39,9 @@
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: isAntigravity
|
||||
? t('admin.accounts.antigravityAccount')
|
||||
: t('admin.accounts.claudeCodeAccount')
|
||||
: isGrok
|
||||
? t('admin.accounts.grokAccount')
|
||||
: t('admin.accounts.claudeCodeAccount')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -128,7 +132,7 @@
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : isGrok ? 'grok' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@@ -192,6 +196,7 @@ import {
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import { useGrokOAuth } from '@/composables/useGrokOAuth'
|
||||
import type { Account } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -227,6 +232,7 @@ const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth()
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
const antigravityOAuth = useAntigravityOAuth()
|
||||
const grokOAuth = useGrokOAuth()
|
||||
|
||||
// Refs
|
||||
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
|
||||
@@ -241,37 +247,42 @@ const isOpenAILike = computed(() => isOpenAI.value)
|
||||
const isGemini = computed(() => props.account?.platform === 'gemini')
|
||||
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
|
||||
const isGrok = computed(() => props.account?.platform === 'grok')
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (isOpenAILike.value) return openaiOAuth.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||
if (isGrok.value) return grokOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAILike.value) return openaiOAuth.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||
if (isGrok.value) return grokOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAILike.value) return openaiOAuth.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||
if (isGrok.value) return grokOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAILike.value) return openaiOAuth.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||
if (isGrok.value) return grokOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
})
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || isGrok.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
@@ -316,6 +327,7 @@ const resetState = () => {
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
grokOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
@@ -335,6 +347,8 @@ const handleGenerateUrl = async () => {
|
||||
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
|
||||
} else if (isAntigravity.value) {
|
||||
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
} else if (isGrok.value) {
|
||||
await grokOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
} else {
|
||||
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
|
||||
}
|
||||
@@ -449,6 +463,39 @@ const handleExchangeCode = async () => {
|
||||
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(antigravityOAuth.error.value)
|
||||
}
|
||||
} else if (isGrok.value) {
|
||||
const sessionId = grokOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
const stateFromInput = oauthFlowRef.value?.oauthState || ''
|
||||
const stateToUse = stateFromInput || grokOAuth.state.value
|
||||
if (!stateToUse) return
|
||||
|
||||
const tokenInfo = await grokOAuth.exchangeAuthCode({
|
||||
code: authCode.trim(),
|
||||
sessionId,
|
||||
state: stateToUse,
|
||||
proxyId: props.account.proxy_id
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = grokOAuth.buildCredentials(tokenInfo)
|
||||
const extra = grokOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
try {
|
||||
const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, {
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
grokOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(grokOAuth.error.value)
|
||||
}
|
||||
} else {
|
||||
// Claude OAuth flow
|
||||
const sessionId = claudeOAuth.sessionId.value
|
||||
|
||||
@@ -196,6 +196,7 @@ export function getPlatformTagClass(platform: string): string {
|
||||
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
case 'grok': return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
|
||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
@@ -207,6 +208,7 @@ export function getPlatformTextClass(platform: string): string {
|
||||
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
|
||||
case 'gemini': return 'text-blue-700 dark:text-blue-400'
|
||||
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
|
||||
case 'grok': return 'text-slate-700 dark:text-slate-300'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
|
||||
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
|
||||
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
|
||||
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
@@ -315,7 +315,7 @@
|
||||
<span class="font-medium text-pink-300">{{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isImageUsage(tooltipData)">
|
||||
<template v-else-if="tooltipData && isImageUsage(tooltipData)">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
|
||||
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
|
||||
|
||||
@@ -128,7 +128,7 @@ const emit = defineEmits(['close', 'success'])
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
|
||||
|
||||
interface QuotaRow {
|
||||
platform: PlatformQuotaPlatform
|
||||
|
||||
@@ -79,13 +79,14 @@ describe('UserPlatformQuotaModal', () => {
|
||||
expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('空数据渲染 4 个 platform 行', async () => {
|
||||
it('空数据渲染 5 个 platform 行', async () => {
|
||||
const w = await mountAndOpen()
|
||||
const html = w.html()
|
||||
expect(html).toContain('anthropic')
|
||||
expect(html).toContain('openai')
|
||||
expect(html).toContain('gemini')
|
||||
expect(html).toContain('antigravity')
|
||||
expect(html).toContain('grok')
|
||||
})
|
||||
|
||||
it('已有数据正确填充 limit input', async () => {
|
||||
@@ -97,13 +98,13 @@ describe('UserPlatformQuotaModal', () => {
|
||||
})
|
||||
const w = await mountAndOpen()
|
||||
const inputs = w.findAll('input[type=number]')
|
||||
// 4 platforms × 3 windows = 12 inputs
|
||||
expect(inputs.length).toBe(12)
|
||||
// 5 platforms × 3 windows = 15 inputs
|
||||
expect(inputs.length).toBe(15)
|
||||
// 第一个 input 是 anthropic.daily = 10
|
||||
expect((inputs[0].element as HTMLInputElement).value).toBe('10')
|
||||
})
|
||||
|
||||
it('保存提交完整 4 platform payload', async () => {
|
||||
it('保存提交完整 5 platform payload', async () => {
|
||||
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
|
||||
platform_quotas: [
|
||||
{ platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null,
|
||||
@@ -120,7 +121,7 @@ describe('UserPlatformQuotaModal', () => {
|
||||
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
|
||||
const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0]
|
||||
expect(uid).toBe(99)
|
||||
expect(payload).toHaveLength(4) // 4 platforms always submitted
|
||||
expect(payload).toHaveLength(5) // 5 platforms always submitted
|
||||
const openai = payload.find((p: any) => p.platform === 'openai')
|
||||
expect(openai.weekly_limit_usd).toBe(20)
|
||||
})
|
||||
|
||||
@@ -185,7 +185,7 @@ const displayGroupStats = computed(() => {
|
||||
if (!props.groupStats?.length) return []
|
||||
|
||||
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
|
||||
return [...props.groupStats].sort((a, b) => b[metricKey] - a[metricKey])
|
||||
return [...props.groupStats].sort((a, b) => toFiniteNumber(b[metricKey]) - toFiniteNumber(a[metricKey]))
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
@@ -195,7 +195,7 @@ const chartData = computed(() => {
|
||||
labels: displayGroupStats.value.map((g) => g.group_name || String(g.group_id)),
|
||||
datasets: [
|
||||
{
|
||||
data: displayGroupStats.value.map((g) => props.metric === 'actual_cost' ? g.actual_cost : g.total_tokens),
|
||||
data: displayGroupStats.value.map((g) => toFiniteNumber(props.metric === 'actual_cost' ? g.actual_cost : g.total_tokens)),
|
||||
backgroundColor: chartColors.slice(0, displayGroupStats.value.length),
|
||||
borderWidth: 0
|
||||
}
|
||||
@@ -238,17 +238,23 @@ const formatTokens = (value: number): string => {
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
return toFiniteNumber(value).toLocaleString()
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
const toFiniteNumber = (value: unknown): number => {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue : 0
|
||||
}
|
||||
|
||||
const formatCost = (value: number | null | undefined): string => {
|
||||
const safeValue = toFiniteNumber(value)
|
||||
if (safeValue >= 1000) {
|
||||
return (safeValue / 1000).toFixed(2) + 'K'
|
||||
} else if (safeValue >= 1) {
|
||||
return safeValue.toFixed(2)
|
||||
} else if (safeValue >= 0.01) {
|
||||
return safeValue.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
return safeValue.toFixed(4)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -354,7 +354,7 @@ const displayModelStats = computed(() => {
|
||||
if (!sourceStats?.length) return []
|
||||
|
||||
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
|
||||
return [...sourceStats].sort((a, b) => b[metricKey] - a[metricKey])
|
||||
return [...sourceStats].sort((a, b) => toFiniteNumber(b[metricKey]) - toFiniteNumber(a[metricKey]))
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
@@ -364,7 +364,7 @@ const chartData = computed(() => {
|
||||
labels: displayModelStats.value.map((m) => m.model),
|
||||
datasets: [
|
||||
{
|
||||
data: displayModelStats.value.map((m) => props.metric === 'actual_cost' ? m.actual_cost : m.total_tokens),
|
||||
data: displayModelStats.value.map((m) => toFiniteNumber(props.metric === 'actual_cost' ? m.actual_cost : m.total_tokens)),
|
||||
backgroundColor: chartColors.slice(0, displayModelStats.value.length),
|
||||
borderWidth: 0
|
||||
}
|
||||
@@ -376,7 +376,7 @@ const rankingChartData = computed(() => {
|
||||
if (!props.rankingItems?.length) return null
|
||||
|
||||
const labels = props.rankingItems.map((item, index) => `#${index + 1} ${getRankingUserLabel(item)}`)
|
||||
const data = props.rankingItems.map((item) => item.actual_cost)
|
||||
const data = props.rankingItems.map((item) => toFiniteNumber(item.actual_cost))
|
||||
const backgroundColor = chartColors.slice(0, props.rankingItems.length)
|
||||
|
||||
if (otherRankingItem.value) {
|
||||
@@ -400,9 +400,9 @@ const rankingChartData = computed(() => {
|
||||
const otherRankingItem = computed<RankingDisplayItem | null>(() => {
|
||||
if (!props.rankingItems?.length) return null
|
||||
|
||||
const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
|
||||
const rankedRequests = props.rankingItems.reduce((sum, item) => sum + item.requests, 0)
|
||||
const rankedTokens = props.rankingItems.reduce((sum, item) => sum + item.tokens, 0)
|
||||
const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + toFiniteNumber(item.actual_cost), 0)
|
||||
const rankedRequests = props.rankingItems.reduce((sum, item) => sum + toFiniteNumber(item.requests), 0)
|
||||
const rankedTokens = props.rankingItems.reduce((sum, item) => sum + toFiniteNumber(item.tokens), 0)
|
||||
|
||||
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedActualCost, 0)
|
||||
const otherRequests = Math.max((props.rankingTotalRequests || 0) - rankedRequests, 0)
|
||||
@@ -482,7 +482,7 @@ const formatTokens = (value: number): string => {
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
return toFiniteNumber(value).toLocaleString()
|
||||
}
|
||||
|
||||
const getRankingUserLabel = (item: UserSpendingRankingItem): string => {
|
||||
@@ -495,14 +495,20 @@ const getRankingRowLabel = (item: RankingDisplayItem): string => {
|
||||
return getRankingUserLabel(item)
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
const toFiniteNumber = (value: unknown): number => {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue : 0
|
||||
}
|
||||
|
||||
const formatCost = (value: number | null | undefined): string => {
|
||||
const safeValue = toFiniteNumber(value)
|
||||
if (safeValue >= 1000) {
|
||||
return (safeValue / 1000).toFixed(2) + 'K'
|
||||
} else if (safeValue >= 1) {
|
||||
return safeValue.toFixed(2)
|
||||
} else if (safeValue >= 0.01) {
|
||||
return safeValue.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
return safeValue.toFixed(4)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
|
||||
</svg>
|
||||
<!-- Grok/xAI logo mark -->
|
||||
<svg v-else-if="platform === 'grok'" :class="sizeClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 18 18 4" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5h11v11" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 6l13 13" />
|
||||
</svg>
|
||||
<!-- Fallback: generic platform icon -->
|
||||
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
||||
@@ -76,6 +76,7 @@ const platformLabel = computed(() => {
|
||||
if (props.platform === 'anthropic') return 'Anthropic'
|
||||
if (props.platform === 'openai') return 'OpenAI'
|
||||
if (props.platform === 'antigravity') return 'Antigravity'
|
||||
if (props.platform === 'grok') return 'Grok'
|
||||
return 'Gemini'
|
||||
})
|
||||
|
||||
@@ -126,6 +127,9 @@ const platformClass = computed(() => {
|
||||
if (props.platform === 'antigravity') {
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}
|
||||
if (props.platform === 'grok') {
|
||||
return 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
|
||||
@@ -139,6 +143,9 @@ const typeClass = computed(() => {
|
||||
if (props.platform === 'antigravity') {
|
||||
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}
|
||||
if (props.platform === 'grok') {
|
||||
return 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { PlatformQuotaItem, PlatformQuotaPlatform } from '@/api/admin/users
|
||||
const props = defineProps<{ quotas?: PlatformQuotaItem[] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const PLATFORM_ORDER: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
const PLATFORM_ORDER: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
|
||||
|
||||
// 仅展示「至少一档限额非空」的平台(配额列,非用量列)
|
||||
const configured = computed(() => {
|
||||
|
||||
@@ -278,7 +278,7 @@ const platformCards = computed<FusedPlatformCard[]>(() => {
|
||||
// 无需显式排除;__other__ 由下方差值补差逻辑单独追加。
|
||||
const platforms = new Set<string>([...byPlat.keys(), ...byQuota.keys()])
|
||||
|
||||
const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
|
||||
const cards: FusedPlatformCard[] = []
|
||||
|
||||
for (const p of platforms) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { GrokTokenInfo } from '@/api/admin/grok'
|
||||
|
||||
export function useGrokOAuth() {
|
||||
const appStore = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const state = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const resetState = () => {
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
state.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const generateAuthUrl = async (proxyId: number | null | undefined): Promise<boolean> => {
|
||||
loading.value = true
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
state.value = ''
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
|
||||
const response = await adminAPI.grok.generateAuthUrl(payload)
|
||||
authUrl.value = response.auth_url
|
||||
sessionId.value = response.session_id
|
||||
state.value = response.state
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToGenerateUrl')
|
||||
appStore.showError(error.value)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeAuthCode = async (params: {
|
||||
code: string
|
||||
sessionId: string
|
||||
state: string
|
||||
proxyId?: number | null
|
||||
}): Promise<GrokTokenInfo | null> => {
|
||||
const code = params.code?.trim()
|
||||
if (!code || !params.sessionId || !params.state) {
|
||||
error.value = t('admin.accounts.oauth.grok.missingExchangeParams')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
session_id: params.sessionId,
|
||||
state: params.state,
|
||||
code
|
||||
}
|
||||
if (params.proxyId) payload.proxy_id = params.proxyId
|
||||
|
||||
return await adminAPI.grok.exchangeCode(payload as any)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToExchangeCode')
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validateRefreshToken = async (
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo | null> => {
|
||||
if (!refreshToken.trim()) {
|
||||
error.value = t('admin.accounts.oauth.grok.pleaseEnterRefreshToken')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
return await adminAPI.grok.refreshGrokToken(refreshToken.trim(), proxyId)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToValidateRT')
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildCredentials = (tokenInfo: GrokTokenInfo): Record<string, unknown> => {
|
||||
const credentials: Record<string, unknown> = {
|
||||
access_token: tokenInfo.access_token,
|
||||
token_type: tokenInfo.token_type,
|
||||
expires_at: tokenInfo.expires_at,
|
||||
client_id: tokenInfo.client_id,
|
||||
scope: tokenInfo.scope,
|
||||
email: tokenInfo.email,
|
||||
subscription_tier: tokenInfo.subscription_tier,
|
||||
entitlement_status: tokenInfo.entitlement_status
|
||||
}
|
||||
if (tokenInfo.refresh_token) credentials.refresh_token = tokenInfo.refresh_token
|
||||
if (tokenInfo.id_token) credentials.id_token = tokenInfo.id_token
|
||||
return Object.fromEntries(Object.entries(credentials).filter(([, value]) => value !== undefined && value !== ''))
|
||||
}
|
||||
|
||||
const buildExtraInfo = (tokenInfo: GrokTokenInfo): Record<string, unknown> => {
|
||||
const extra: Record<string, unknown> = {}
|
||||
if (tokenInfo.email) extra.email = tokenInfo.email
|
||||
if (tokenInfo.subscription_tier) extra.subscription_tier = tokenInfo.subscription_tier
|
||||
if (tokenInfo.entitlement_status) extra.entitlement_status = tokenInfo.entitlement_status
|
||||
return extra
|
||||
}
|
||||
|
||||
return {
|
||||
authUrl,
|
||||
sessionId,
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
resetState,
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
validateRefreshToken,
|
||||
buildCredentials,
|
||||
buildExtraInfo
|
||||
}
|
||||
}
|
||||
@@ -131,10 +131,16 @@ const metaModels = [
|
||||
|
||||
// xAI Grok
|
||||
const xaiModels = [
|
||||
'grok-4', 'grok-4-0709',
|
||||
'grok-3-beta', 'grok-3-mini-beta', 'grok-3-fast-beta',
|
||||
'grok-2', 'grok-2-vision', 'grok-2-image',
|
||||
'grok-beta', 'grok-vision-beta'
|
||||
'grok-4.3',
|
||||
'grok-build-0.1',
|
||||
'grok-4.20-0309-reasoning',
|
||||
'grok-4.20-0309-non-reasoning',
|
||||
'grok-4.20-multi-agent-0309',
|
||||
'grok',
|
||||
'grok-latest',
|
||||
'grok-build',
|
||||
'grok-4.20-reasoning',
|
||||
'grok-4.20-non-reasoning'
|
||||
]
|
||||
|
||||
// Cohere
|
||||
@@ -273,6 +279,14 @@ const geminiPresetMappings = [
|
||||
{ label: '3.1 Image', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }
|
||||
]
|
||||
|
||||
const grokPresetMappings = [
|
||||
{ label: 'Grok 4.3', from: 'grok-4.3', to: 'grok-4.3', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' },
|
||||
{ label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }
|
||||
]
|
||||
|
||||
// Antigravity 预设映射(支持通配符)
|
||||
const antigravityPresetMappings = [
|
||||
// Claude 通配符映射
|
||||
@@ -371,7 +385,8 @@ export function getModelsByPlatform(platform: string): string[] {
|
||||
case 'deepseek': return deepseekModels
|
||||
case 'mistral': return mistralModels
|
||||
case 'meta': return metaModels
|
||||
case 'xai': return xaiModels
|
||||
case 'xai':
|
||||
case 'grok': return xaiModels
|
||||
case 'cohere': return cohereModels
|
||||
case 'yi': return yiModels
|
||||
case 'moonshot': return moonshotModels
|
||||
@@ -389,6 +404,7 @@ export function getModelsByPlatform(platform: string): string[] {
|
||||
export function getPresetMappingsByPlatform(platform: string) {
|
||||
if (platform === 'openai') return openaiPresetMappings
|
||||
if (platform === 'gemini') return geminiPresetMappings
|
||||
if (platform === 'grok' || platform === 'xai') return grokPresetMappings
|
||||
if (platform === 'antigravity') return antigravityPresetMappings
|
||||
if (platform === 'bedrock') return bedrockPresetMappings
|
||||
return anthropicPresetMappings
|
||||
|
||||
@@ -3,6 +3,10 @@ import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/uti
|
||||
const STORAGE_KEY = 'table-page-size'
|
||||
|
||||
export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
|
||||
if (typeof window !== 'undefined' && window.__APP_CONFIG__?.table_default_page_size !== undefined) {
|
||||
return normalizeTablePageSize(getConfiguredTableDefaultPageSize())
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
@@ -2219,6 +2219,7 @@ export default {
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
grok: 'Grok',
|
||||
},
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
||||
@@ -3135,6 +3136,7 @@ export default {
|
||||
googleOauth: 'Google OAuth',
|
||||
codeAssist: 'Code Assist',
|
||||
antigravityOauth: 'Antigravity OAuth',
|
||||
grokOauth: 'Grok OAuth',
|
||||
antigravityApikey: 'Connect via Base URL + API Key',
|
||||
upstream: 'Upstream',
|
||||
upstreamDesc: 'Connect via Base URL + API Key'
|
||||
@@ -3493,6 +3495,10 @@ export default {
|
||||
testModeCompact: 'Compact probe',
|
||||
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
|
||||
},
|
||||
grok: {
|
||||
baseUrlHint: 'Grok OAuth accounts forward to the official xAI API base URL.',
|
||||
apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: 'Auto passthrough (auth only)',
|
||||
apiKeyPassthroughDesc:
|
||||
@@ -3831,6 +3837,31 @@ export default {
|
||||
pleaseEnterRefreshToken: 'Please enter Refresh Token',
|
||||
pleaseEnterSessionToken: 'Please enter Session Token'
|
||||
},
|
||||
grok: {
|
||||
title: 'Grok Account Authorization',
|
||||
followSteps: 'Follow these steps to authorize your xAI/Grok account:',
|
||||
step1GenerateUrl: 'Generate the xAI authorization URL',
|
||||
generateAuthUrl: 'Generate Auth URL',
|
||||
step2OpenUrl: 'Open the URL in your browser and complete authorization',
|
||||
openUrlDesc: 'Open the authorization URL in a new tab, sign in to xAI, and authorize API access.',
|
||||
importantNotice: 'When the browser reaches the local callback URL, copy the full URL or the code query parameter back here.',
|
||||
step3EnterCode: 'Enter Authorization URL or Code',
|
||||
authCodeDesc: 'After authorization, paste the callback URL, query string, or authorization code:',
|
||||
authCode: 'Authorization URL or Code',
|
||||
authCodePlaceholder: 'Paste the full callback URL, ?code=... query string, or code value',
|
||||
authCodeHint: 'Full callback URLs, query strings, and bare codes are accepted.',
|
||||
refreshTokenAuth: 'Manual RT Input',
|
||||
refreshTokenDesc: 'Enter existing xAI refresh token(s). Supports batch input, one per line.',
|
||||
refreshTokenPlaceholder: 'Paste your xAI refresh token...\nSupports multiple, one per line',
|
||||
validating: 'Validating...',
|
||||
validateAndCreate: 'Validate & Create Account',
|
||||
pleaseEnterRefreshToken: 'Please enter Refresh Token',
|
||||
failedToGenerateUrl: 'Failed to generate Grok auth URL',
|
||||
missingExchangeParams: 'Missing authorization code, state, or OAuth session',
|
||||
failedToExchangeCode: 'Failed to exchange Grok authorization code',
|
||||
failedToValidateRT: 'Failed to validate Grok refresh token',
|
||||
oauthOnlyHint: 'Initial Grok support is OAuth subscription-backed Responses API text and reasoning traffic only.'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
title: 'Gemini Account Authorization',
|
||||
@@ -4052,6 +4083,7 @@ export default {
|
||||
openaiAccount: 'OpenAI Account',
|
||||
geminiAccount: 'Gemini Account',
|
||||
antigravityAccount: 'Antigravity Account',
|
||||
grokAccount: 'Grok Account',
|
||||
inputMethod: 'Input Method',
|
||||
reAuthorizedSuccess: 'Account re-authorized successfully',
|
||||
// Test Modal
|
||||
@@ -4126,6 +4158,18 @@ export default {
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G31FI',
|
||||
claude: 'Claude',
|
||||
grokRequests: 'Req',
|
||||
grokTokens: 'Tok',
|
||||
grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.',
|
||||
grokRetryAfter: 'Retry after {time}',
|
||||
grokProbe: 'Probe',
|
||||
grokProbeTooltip: 'Send a minimal xAI Responses probe and read quota headers',
|
||||
grokResetUnsupported: 'Reset unsupported',
|
||||
grokResetUnsupportedTooltip: 'xAI does not expose reset credits for Grok OAuth accounts',
|
||||
grokNoHeaders: 'No quota headers observed',
|
||||
grokLastStatus: 'Status {status}',
|
||||
grokLastProbe: 'Probe {time}',
|
||||
grokLastHeadersSeen: 'Headers {time}',
|
||||
passiveSampled: 'Passive',
|
||||
activeQuery: 'Query'
|
||||
},
|
||||
|
||||
@@ -3315,6 +3315,7 @@ export default {
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
grok: 'Grok',
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
@@ -3323,6 +3324,7 @@ export default {
|
||||
googleOauth: 'Google OAuth',
|
||||
codeAssist: 'Code Assist',
|
||||
antigravityOauth: 'Antigravity OAuth',
|
||||
grokOauth: 'Grok OAuth',
|
||||
antigravityApikey: '通过 Base URL + API Key 连接',
|
||||
upstream: '对接上游',
|
||||
upstreamDesc: '通过 Base URL + API Key 连接上游',
|
||||
@@ -3406,6 +3408,18 @@ export default {
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G31FI',
|
||||
claude: 'Claude',
|
||||
grokRequests: '请求',
|
||||
grokTokens: 'Token',
|
||||
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
|
||||
grokRetryAfter: '{time} 后重试',
|
||||
grokProbe: '探测',
|
||||
grokProbeTooltip: '发送最小 xAI Responses 探测请求并读取配额响应头',
|
||||
grokResetUnsupported: '不支持重置',
|
||||
grokResetUnsupportedTooltip: 'xAI 未向 Grok OAuth 账号开放重置额度接口',
|
||||
grokNoHeaders: '未观察到配额响应头',
|
||||
grokLastStatus: '状态 {status}',
|
||||
grokLastProbe: '探测 {time}',
|
||||
grokLastHeadersSeen: '响应头 {time}',
|
||||
passiveSampled: '被动采样',
|
||||
activeQuery: '查询'
|
||||
},
|
||||
@@ -3649,6 +3663,10 @@ export default {
|
||||
testModeCompact: 'Compact 探测',
|
||||
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
|
||||
},
|
||||
grok: {
|
||||
baseUrlHint: 'Grok OAuth 账号会转发到官方 xAI API Base URL。',
|
||||
apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: '自动透传(仅替换认证)',
|
||||
apiKeyPassthroughDesc:
|
||||
@@ -3976,6 +3994,31 @@ export default {
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token',
|
||||
pleaseEnterSessionToken: '请输入 Session Token'
|
||||
},
|
||||
grok: {
|
||||
title: 'Grok 账号授权',
|
||||
followSteps: '请按照以下步骤授权您的 xAI/Grok 账号:',
|
||||
step1GenerateUrl: '生成 xAI 授权链接',
|
||||
generateAuthUrl: '生成授权链接',
|
||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||
openUrlDesc: '在新标签页中打开授权链接,登录 xAI 并授权 API 访问。',
|
||||
importantNotice: '当浏览器跳转到本地 callback URL 后,请复制完整 URL 或 code 参数回填到这里。',
|
||||
step3EnterCode: '输入授权链接或 Code',
|
||||
authCodeDesc: '授权完成后,粘贴 callback URL、查询字符串或授权码:',
|
||||
authCode: '授权链接或 Code',
|
||||
authCodePlaceholder: '粘贴完整 callback URL、?code=... 查询字符串或 code 值',
|
||||
authCodeHint: '支持完整 callback URL、查询字符串或裸 code。',
|
||||
refreshTokenAuth: '手动输入 RT',
|
||||
refreshTokenDesc: '输入已有的 xAI refresh token,支持批量输入(每行一个)。',
|
||||
refreshTokenPlaceholder: '粘贴您的 xAI refresh token...\n支持多个,每行一个',
|
||||
validating: '验证中...',
|
||||
validateAndCreate: '验证并创建账号',
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token',
|
||||
failedToGenerateUrl: '生成 Grok 授权链接失败',
|
||||
missingExchangeParams: '缺少授权码、state 或 OAuth 会话',
|
||||
failedToExchangeCode: 'Grok 授权码兑换失败',
|
||||
failedToValidateRT: '验证 Grok refresh token 失败',
|
||||
oauthOnlyHint: '首版 Grok 支持仅包含 OAuth 订阅的 Responses API 文本/推理转发。'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
title: 'Gemini 账户授权',
|
||||
@@ -4192,6 +4235,7 @@ export default {
|
||||
openaiAccount: 'OpenAI 账号',
|
||||
geminiAccount: 'Gemini 账号',
|
||||
antigravityAccount: 'Antigravity 账号',
|
||||
grokAccount: 'Grok 账号',
|
||||
inputMethod: '输入方式',
|
||||
reAuthorizedSuccess: '账号重新授权成功',
|
||||
// Test Modal
|
||||
|
||||
@@ -487,7 +487,7 @@ export interface PaginationConfig {
|
||||
|
||||
// ==================== API Key & Group Types ====================
|
||||
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
|
||||
|
||||
export type SubscriptionType = 'standard' | 'subscription'
|
||||
|
||||
@@ -690,7 +690,7 @@ export interface UpdateGroupRequest {
|
||||
|
||||
// ==================== Account & Proxy Types ====================
|
||||
|
||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
|
||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'service_account'
|
||||
export type OAuthAddMethod = 'oauth' | 'setup-token'
|
||||
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
|
||||
@@ -944,6 +944,13 @@ export interface AntigravityModelQuota {
|
||||
reset_time: string // 重置时间 ISO8601
|
||||
}
|
||||
|
||||
export interface GrokQuotaWindow {
|
||||
limit?: number
|
||||
remaining?: number
|
||||
reset_unix?: number
|
||||
reset_at?: string
|
||||
}
|
||||
|
||||
export interface AccountUsageInfo {
|
||||
source?: 'passive' | 'active'
|
||||
updated_at: string | null
|
||||
@@ -957,6 +964,15 @@ export interface AccountUsageInfo {
|
||||
gemini_pro_minute?: UsageProgress | null
|
||||
gemini_flash_minute?: UsageProgress | null
|
||||
antigravity_quota?: Record<string, AntigravityModelQuota> | null
|
||||
grok_request_quota?: GrokQuotaWindow | null
|
||||
grok_token_quota?: GrokQuotaWindow | null
|
||||
grok_retry_after_seconds?: number | null
|
||||
grok_entitlement_status?: string
|
||||
grok_quota_snapshot_state?: string
|
||||
grok_last_quota_probe_at?: string
|
||||
grok_last_headers_seen_at?: string
|
||||
grok_last_status_code?: number
|
||||
grok_local_usage?: WindowStats | null
|
||||
ai_credits?: Array<{
|
||||
credit_type?: string
|
||||
amount?: number
|
||||
|
||||
@@ -29,6 +29,9 @@ export function isImageUsage(row: Pick<ImageBillingRow, 'image_count' | 'billing
|
||||
}
|
||||
|
||||
export function getDisplayBillingMode(row: Pick<ImageBillingRow, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
|
||||
if ((row?.image_count ?? 0) > 0 && !row?.billing_mode) {
|
||||
return BILLING_MODE_IMAGE
|
||||
}
|
||||
return row?.billing_mode
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* instead of defining their own color mappings.
|
||||
*/
|
||||
|
||||
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
|
||||
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini' | 'grok'
|
||||
|
||||
// ── Badge (bg + text + border, for inline badges with border) ───────
|
||||
const BADGE: Record<Platform, string> = {
|
||||
@@ -13,6 +13,7 @@ const BADGE: Record<Platform, string> = {
|
||||
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
|
||||
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
|
||||
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
|
||||
grok: 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-300',
|
||||
}
|
||||
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
|
||||
|
||||
@@ -22,6 +23,7 @@ const BADGE_LIGHT: Record<Platform, string> = {
|
||||
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
|
||||
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
|
||||
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
|
||||
grok: 'bg-slate-500/10 text-slate-600 dark:bg-slate-500/10 dark:text-slate-300',
|
||||
}
|
||||
|
||||
// ── Border ──────────────────────────────────────────────────────────
|
||||
@@ -30,6 +32,7 @@ const BORDER: Record<Platform, string> = {
|
||||
openai: 'border-green-500/20 dark:border-green-500/20',
|
||||
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
|
||||
gemini: 'border-blue-500/20 dark:border-blue-500/20',
|
||||
grok: 'border-slate-500/20 dark:border-slate-500/20',
|
||||
}
|
||||
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
|
||||
|
||||
@@ -39,6 +42,7 @@ const ACCENT_BAR: Record<Platform, string> = {
|
||||
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
|
||||
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
|
||||
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
|
||||
grok: 'bg-gradient-to-r from-slate-500 to-cyan-500',
|
||||
}
|
||||
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
|
||||
|
||||
@@ -48,6 +52,7 @@ const TEXT: Record<Platform, string> = {
|
||||
openai: 'text-emerald-600 dark:text-emerald-400',
|
||||
antigravity: 'text-purple-600 dark:text-purple-400',
|
||||
gemini: 'text-blue-600 dark:text-blue-400',
|
||||
grok: 'text-slate-700 dark:text-slate-300',
|
||||
}
|
||||
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
|
||||
|
||||
@@ -57,6 +62,7 @@ const ICON: Record<Platform, string> = {
|
||||
openai: 'text-emerald-500 dark:text-emerald-400',
|
||||
antigravity: 'text-purple-500 dark:text-purple-400',
|
||||
gemini: 'text-blue-500 dark:text-blue-400',
|
||||
grok: 'text-slate-500 dark:text-slate-300',
|
||||
}
|
||||
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
|
||||
|
||||
@@ -66,6 +72,7 @@ const BUTTON: Record<Platform, string> = {
|
||||
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
|
||||
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
|
||||
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
|
||||
grok: 'bg-slate-700 text-white hover:bg-slate-800 active:bg-slate-900 dark:bg-slate-600 dark:hover:bg-slate-500',
|
||||
}
|
||||
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
|
||||
|
||||
@@ -75,6 +82,7 @@ const DISCOUNT: Record<Platform, string> = {
|
||||
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
grok: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
}
|
||||
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
|
||||
@@ -84,6 +92,7 @@ const GRADIENT: Record<Platform, string> = {
|
||||
openai: 'from-emerald-500 to-emerald-600',
|
||||
antigravity: 'from-purple-500 to-purple-600',
|
||||
gemini: 'from-blue-500 to-blue-600',
|
||||
grok: 'from-slate-600 to-cyan-600',
|
||||
}
|
||||
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
|
||||
|
||||
@@ -93,6 +102,7 @@ const GRADIENT_TEXT: Record<Platform, string> = {
|
||||
openai: 'text-emerald-100',
|
||||
antigravity: 'text-purple-100',
|
||||
gemini: 'text-blue-100',
|
||||
grok: 'text-slate-100',
|
||||
}
|
||||
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
|
||||
|
||||
@@ -101,13 +111,14 @@ const GRADIENT_SUBTEXT: Record<Platform, string> = {
|
||||
openai: 'text-emerald-200',
|
||||
antigravity: 'text-purple-200',
|
||||
gemini: 'text-blue-200',
|
||||
grok: 'text-slate-200',
|
||||
}
|
||||
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
function isPlatform(p: string): p is Platform {
|
||||
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
|
||||
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini' || p === 'grok'
|
||||
}
|
||||
|
||||
export function platformBadgeClass(p: string): string {
|
||||
@@ -160,6 +171,7 @@ export function platformLabel(p: string): string {
|
||||
case 'openai': return 'OpenAI'
|
||||
case 'antigravity': return 'Antigravity'
|
||||
case 'gemini': return 'Gemini'
|
||||
case 'grok': return 'Grok'
|
||||
default: return p || 'API'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,7 +760,7 @@ const form = reactive({
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// ── Platform config ──
|
||||
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
|
||||
|
||||
// ── Helpers ──
|
||||
function formatDate(value: string): string {
|
||||
|
||||
@@ -533,19 +533,25 @@ const formatTokens = (value: number | undefined): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
const toFiniteNumber = (value: unknown): number => {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue : 0
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
const formatNumber = (value: number | null | undefined): string => {
|
||||
return toFiniteNumber(value).toLocaleString()
|
||||
}
|
||||
|
||||
const formatCost = (value: number | null | undefined): string => {
|
||||
const safeValue = toFiniteNumber(value)
|
||||
if (safeValue >= 1000) {
|
||||
return (safeValue / 1000).toFixed(2) + 'K'
|
||||
} else if (safeValue >= 1) {
|
||||
return safeValue.toFixed(2)
|
||||
} else if (safeValue >= 0.01) {
|
||||
return safeValue.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
return safeValue.toFixed(4)
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
|
||||
@@ -3138,6 +3138,7 @@ const platformOptions = computed(() => [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "antigravity", label: "Antigravity" },
|
||||
{ value: "grok", label: "Grok" },
|
||||
]);
|
||||
|
||||
const platformFilterOptions = computed(() => [
|
||||
@@ -3146,6 +3147,7 @@ const platformFilterOptions = computed(() => [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "antigravity", label: "Antigravity" },
|
||||
{ value: "grok", label: "Grok" },
|
||||
]);
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
|
||||
@@ -3287,7 +3287,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="space-y-2">
|
||||
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity'] as const)" :key="p" class="align-top">
|
||||
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] as const)" :key="p" class="align-top">
|
||||
<td class="pr-4 py-1">
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
|
||||
</td>
|
||||
@@ -3622,7 +3622,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity'] as const)" :key="`${authSource.source}-pq-${p}`" class="align-top">
|
||||
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] as const)" :key="`${authSource.source}-pq-${p}`" class="align-top">
|
||||
<td class="pr-4 py-1">
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
|
||||
</td>
|
||||
|
||||
@@ -1149,7 +1149,7 @@ describe("admin SettingsView platform quota matrix", () => {
|
||||
getProviders.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 4 平台行", async () => {
|
||||
it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 5 平台行", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
@@ -1164,7 +1164,7 @@ describe("admin SettingsView platform quota matrix", () => {
|
||||
expect(html).toContain("antigravity");
|
||||
});
|
||||
|
||||
it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 4 平台)", async () => {
|
||||
it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 5 平台)", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
@@ -1180,7 +1180,7 @@ describe("admin SettingsView platform quota matrix", () => {
|
||||
// 应携带嵌套对象,而非扁平字段
|
||||
expect(payload).toHaveProperty("default_platform_quotas");
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, unknown>;
|
||||
const platforms = ["anthropic", "openai", "gemini", "antigravity"];
|
||||
const platforms = ["anthropic", "openai", "gemini", "antigravity", "grok"];
|
||||
for (const p of platforms) {
|
||||
expect(quotas).toHaveProperty(p);
|
||||
const pq = quotas[p] as Record<string, unknown>;
|
||||
@@ -1194,7 +1194,7 @@ describe("admin SettingsView platform quota matrix", () => {
|
||||
expect(payload).not.toHaveProperty("default_platform_quota_openai_weekly");
|
||||
});
|
||||
|
||||
it("加载后 form.default_platform_quotas 含全 4 平台,从嵌套 JSON 正确读取数值", async () => {
|
||||
it("加载后 form.default_platform_quotas 含全 5 平台,从嵌套 JSON 正确读取数值", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
default_platform_quotas: {
|
||||
|
||||
@@ -111,7 +111,8 @@ const platformOptions = computed(() => [
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' }
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'grok', label: 'Grok' }
|
||||
])
|
||||
|
||||
const timeRangeOptions = computed(() => [
|
||||
|
||||
@@ -500,17 +500,25 @@ async function handleVerify(): Promise<void> {
|
||||
}
|
||||
|
||||
if (isPendingOAuthFlow()) {
|
||||
const payload: Record<string, unknown> = {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
|
||||
}
|
||||
if (invitationCode.value) {
|
||||
payload.invitation_code = invitationCode.value
|
||||
}
|
||||
if (pendingAdoptionDecision.value?.adoptDisplayName !== undefined) {
|
||||
payload.adopt_display_name = pendingAdoptionDecision.value.adoptDisplayName
|
||||
}
|
||||
if (pendingAdoptionDecision.value?.adoptAvatar !== undefined) {
|
||||
payload.adopt_avatar = pendingAdoptionDecision.value.adoptAvatar
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
|
||||
'/auth/oauth/pending/create-account',
|
||||
{
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
invitation_code: invitationCode.value || undefined,
|
||||
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
|
||||
adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName,
|
||||
adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar
|
||||
}
|
||||
payload
|
||||
)
|
||||
if (isPendingOAuthSessionResponse(data)) {
|
||||
sessionStorage.removeItem('register_data')
|
||||
|
||||
@@ -137,7 +137,6 @@ describe('EmailVerifyView', () => {
|
||||
JSON.stringify({
|
||||
email: 'fresh@example.com',
|
||||
password: 'secret-123',
|
||||
aff_code: 'AFF123',
|
||||
})
|
||||
)
|
||||
|
||||
@@ -305,6 +304,7 @@ describe('EmailVerifyView', () => {
|
||||
JSON.stringify({
|
||||
email: 'fresh@example.com',
|
||||
password: 'secret-123',
|
||||
aff_code: 'AFF123',
|
||||
})
|
||||
)
|
||||
apiClientPostMock.mockResolvedValue({
|
||||
|
||||
@@ -513,7 +513,7 @@
|
||||
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
|
||||
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
|
||||
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
|
||||
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
|
||||
Reference in New Issue
Block a user