Merge origin/main into feat/channel-monitor-v2-ops-ui

Resolves three conflicts, all of the "both branches appended to the same
block" shape. Every one is resolved as a union of both sides; nothing from
either parent is dropped.

- handler/admin/setting_handler_update.go: keep ChannelMonitorHideThroughput
  (V2) alongside GrokDefaultTextModel / GrokCrossClientModelMapEnabled /
  GrokDefaultBaseURLMode (#5408). UpdateSettings writes every key on each
  save, so dropping either side would reset those settings to zero values.
- service/domain_constants.go: keep SettingKeyChannelMonitorHideThroughput
  and the three SettingKeyGrok* constants.
- repository/migrations_runner.go: keep the 195 checksum rule (V2) and the
  218/219/220 rules (#5408).
This commit is contained in:
shaw
2026-08-09 12:11:35 +08:00
352 changed files with 25184 additions and 2128 deletions
@@ -14,15 +14,19 @@
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
<template v-else>
<button
v-if="isTempUnschedulable"
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<div v-if="isTempUnschedulable" class="flex flex-col items-center gap-1">
<button
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<span class="max-w-[180px] text-center text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{{ tempUnschedRecoveryText }}
</span>
</div>
<span v-else :class="['badge text-xs', statusClass]">
{{ statusText }}
</span>
@@ -302,6 +306,13 @@ const overloadCountdown = computed(() => {
return formatCountdownWithSuffix(props.account.overload_until)
})
const tempUnschedRecoveryText = computed(() => {
if (!isTempUnschedulable.value || !props.account.temp_unschedulable_until) return ''
return t('admin.accounts.status.tempUnschedulableUntil', {
time: formatDateTime(props.account.temp_unschedulable_until)
})
})
// Computed: status badge class
const statusClass = computed(() => {
if (hasError.value) {
@@ -113,8 +113,6 @@
<!-- No data yet -->
<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>
@@ -356,82 +354,72 @@
</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') }}
{{ usageInfo?.grok_entitlement_status || 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-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
{{ 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>
<!-- Free: only rolling 24h soft-gate bar. Paid: 7d + 30d + prepaid money. -->
<template v-if="grokIsFree">
<UsageProgressBar
v-if="grokFreeTokenBar"
label="24h"
:title="t('admin.accounts.usageWindow.grokFreeQuota24hHint', { limit: formatCompactNumber(grokFreeTokenBar.limit) })"
:utilization="grokFreeTokenBar.utilization"
:show-now-when-idle="true"
color="emerald"
/>
<div v-else-if="grokQuotaUnknown" class="text-[10px] text-gray-500 dark:text-gray-400">
{{ grokQuotaUnknownLabel }}
</div>
</template>
<template v-else>
<UsageProgressBar
v-if="grokWeeklyBillingBar"
label="7d"
:utilization="grokWeeklyBillingBar.utilization"
:resets-at="grokWeeklyBillingBar.resetsAt"
:show-now-when-idle="true"
color="indigo"
/>
<UsageProgressBar
v-if="grokMonthlyBillingBar"
label="30d"
:utilization="grokMonthlyBillingBar.utilization"
:resets-at="grokMonthlyBillingBar.resetsAt"
:show-now-when-idle="true"
color="indigo"
/>
<div
v-if="grokPrepaidMoneyLine"
class="flex flex-wrap items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400"
>
<span
v-if="grokLocalUsage.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
:title="t('usage.userBilled')"
class="rounded bg-emerald-50 px-1 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
:title="t('admin.accounts.usageWindow.grokPrepaid')"
>
U ${{ formatWindowUserCost(grokLocalUsage) }}
{{ t('admin.accounts.usageWindow.grokPrepaid') }} ${{ grokPrepaidMoneyLine.prepaid }}
</span>
<span :title="t('admin.accounts.usageWindow.grokMonthlyLimit')">
{{ t('admin.accounts.usageWindow.grokUsed') }}
{{ grokPrepaidMoneyLine.used }}/{{ grokPrepaidMoneyLine.limit }}
</span>
</div>
<div v-if="grokQuotaUnknown" class="text-[10px] text-gray-500 dark:text-gray-400">
{{ grokQuotaUnknownLabel }}
</div>
</template>
<div v-if="usageInfo.error" class="truncate text-xs text-amber-600 dark:text-amber-400 max-w-[200px]" :title="usageInfo.error">
{{ usageErrorLabel }}
</div>
<UsageProgressBar
v-if="grokWeeklyBillingBar"
label="7d"
:utilization="grokWeeklyBillingBar.utilization"
:resets-at="grokWeeklyBillingBar.resetsAt"
:show-now-when-idle="true"
color="indigo"
/>
<UsageProgressBar
v-if="!grokWeeklyBillingBar && !grokIsFree && grokRequestQuotaBar"
:label="t('admin.accounts.usageWindow.grokRequests')"
:utilization="grokRequestQuotaBar.utilization"
:resets-at="grokRequestQuotaBar.resetsAt"
:remaining-capacity="true"
color="indigo"
/>
<UsageProgressBar
v-if="!grokWeeklyBillingBar && !grokIsFree && grokTokenQuotaBar"
:label="t('admin.accounts.usageWindow.grokTokens')"
:utilization="grokTokenQuotaBar.utilization"
:resets-at="grokTokenQuotaBar.resetsAt"
:remaining-capacity="true"
color="emerald"
/>
<UsageProgressBar
v-if="grokFreeTokenBar"
label="24h"
:title="t('admin.accounts.usageWindow.grokFreeQuota24hHint', { limit: formatCompactNumber(grokFreeTokenBar.limit) })"
:utilization="grokFreeTokenBar.utilization"
:show-now-when-idle="true"
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" @probed="handleGrokProbed" />
<GrokQuotaProbeCell :account="account" compact @probed="handleGrokProbed" />
</div>
<div v-else class="space-y-1">
<div class="text-xs text-gray-400">-</div>
<GrokQuotaProbeCell :account="account" compact @probed="handleGrokProbed" />
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
<!-- Gemini platform: show quota + local usage window -->
@@ -629,11 +617,10 @@
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { GrokQuotaProbeResult } from '@/api/admin/grok'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber, formatRelativeTime } from '@/utils/format'
import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
@@ -652,16 +639,25 @@ const props = withDefaults(
todayStats?: WindowStats | null
todayStatsLoading?: boolean
manualRefreshToken?: number
batchedUsage?: AccountUsageInfo | null
batchedUsageError?: string | null
batchedUsageLoading?: boolean
requestBatchedUsage?: ((account: Account, options?: { force?: boolean }) => void) | null
}>(),
{
todayStats: null,
todayStatsLoading: false,
manualRefreshToken: 0
manualRefreshToken: 0,
batchedUsage: null,
batchedUsageError: null,
batchedUsageLoading: false,
requestBatchedUsage: null
}
)
const emit = defineEmits<{
'account-updated': [account: Account]
'usage-loaded': [usage: AccountUsageInfo]
}>()
const { t } = useI18n()
@@ -674,6 +670,9 @@ const loading = ref(false)
const activeQueryLoading = ref(false)
const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
watch(usageInfo, (usage) => {
if (usage) emit('usage-loaded', usage)
})
const suppressOpenAIUsageRefreshUntil = ref(0)
const rootRef = ref<HTMLElement | null>(null)
const isDesktopViewport = ref(
@@ -713,6 +712,8 @@ const shouldFetchUsage = computed(() => {
return false
})
const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function')
const showGeminiTodayStats = computed(() => {
return props.account.platform === 'gemini' && props.account.type === 'service_account'
})
@@ -1072,17 +1073,6 @@ interface GrokQuotaBarInfo {
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 remaining = Math.min(quota.limit, Math.max(0, quota.remaining))
return {
utilization: (remaining / 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 grokBilling = computed(() => usageInfo.value?.grok_billing || null)
const grokWeeklyBillingBar = computed((): GrokQuotaBarInfo | null => {
const billing = grokBilling.value
@@ -1094,6 +1084,63 @@ const grokWeeklyBillingBar = computed((): GrokQuotaBarInfo | null => {
resetsAt: billing.period_end || null
}
})
// Monthly used/limit % from billing probe (used_percent or derived from cents).
const grokMonthlyBillingBar = computed((): GrokQuotaBarInfo | null => {
const billing = grokBilling.value
if (!billing) return null
let utilization: number | null = null
if (billing.used_percent != null && Number.isFinite(billing.used_percent)) {
utilization = billing.used_percent
} else if (
billing.monthly_limit_cents != null &&
billing.monthly_limit_cents > 0 &&
billing.used_cents != null
) {
utilization = (billing.used_cents / billing.monthly_limit_cents) * 100
}
if (utilization == null) return null
// Avoid duplicating the weekly bar when period_type is weekly-only without monthly.
if (billing.period_type?.toLowerCase() === 'weekly' && billing.monthly_limit_cents == null) {
return null
}
return {
utilization: Math.min(100, Math.max(0, utilization)),
resetsAt: billing.billing_period_end || billing.period_end || null
}
})
const formatGrokMoney = (value?: number | null) => {
if (value == null || Number.isNaN(value)) return '0'
if (value >= 1000) return formatCompactNumber(value)
if (value >= 100) return value.toFixed(0)
if (value >= 10) return value.toFixed(1)
return value.toFixed(2)
}
// Prepaid money line for paid Grok: show when prepaid_balance is present.
// Monthly used/limit numbers are optional context; primary progress is the 30d bar.
const grokPrepaidMoneyLine = computed(() => {
const billing = grokBilling.value
if (!billing) return null
const prepaid = billing.prepaid_balance
// "只针对预付": only render when prepaid field exists (including $0.00).
if (prepaid == null || !Number.isFinite(prepaid)) return null
const used =
billing.monthly_used != null
? billing.monthly_used
: billing.used_cents != null
? billing.used_cents / 100
: 0
const limit =
billing.monthly_limit != null
? billing.monthly_limit
: billing.monthly_limit_cents != null
? billing.monthly_limit_cents / 100
: 0
return {
prepaid: formatGrokMoney(prepaid),
used: formatGrokMoney(used),
limit: formatGrokMoney(limit)
}
})
const grokPlanLabelIsFree = (value: string) => value.includes('free') || value.includes('basic')
const grokPlanLabelIsPaid = (value: string) => {
return value !== '' && !grokPlanLabelIsFree(value) && !value.includes('unknown')
@@ -1119,14 +1166,6 @@ const grokIsFree = computed(() => {
return billing != null
})
const grokFreeQuotaUsage = computed(() => usageInfo.value?.grok_local_usage_24h || null)
const grokLocalUsage = computed(() => {
if (grokIsFree.value) return grokFreeQuotaUsage.value
return props.todayStats ||
usageInfo.value?.grok_local_usage ||
usageInfo.value?.grok_local_usage_7d ||
usageInfo.value?.grok_local_usage_monthly ||
null
})
const grokFreeTokenBar = computed(() => {
if (!grokIsFree.value || !grokFreeQuotaUsage.value) return null
const limit = usageInfo.value?.grok_free_token_limit
@@ -1136,7 +1175,12 @@ const grokFreeTokenBar = computed(() => {
})
const grokQuotaUnknown = computed(() => {
if (props.account.platform !== 'grok') return false
if (grokBilling.value || grokFreeTokenBar.value || grokRequestQuotaBar.value || grokTokenQuotaBar.value) return false
if (grokIsFree.value) {
return !grokFreeTokenBar.value
}
if (grokWeeklyBillingBar.value || grokMonthlyBillingBar.value || grokPrepaidMoneyLine.value) {
return false
}
return usageInfo.value?.grok_quota_snapshot_state !== 'observed'
})
const grokQuotaUnknownLabel = computed(() => {
@@ -1144,33 +1188,6 @@ const grokQuotaUnknownLabel = computed(() => {
? 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 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
@@ -1179,11 +1196,6 @@ const grokRetryAfterLabel = computed(() => {
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 formatWindowUserCost = (stats: WindowStats) => (stats.user_cost ?? 0).toFixed(2)
// 账户类型显示标签
const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) {
@@ -1273,8 +1285,24 @@ const isAnthropicOAuthOrSetupToken = computed(() => {
return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')
})
const requestParentBatchUsage = (options?: { force?: boolean }) => {
if (!isBatchManaged.value || !shouldFetchUsage.value) return
props.requestBatchedUsage?.(props.account, options)
}
const syncManagedUsageState = () => {
if (!isBatchManaged.value) return
usageInfo.value = props.batchedUsage ?? null
error.value = props.batchedUsageError ?? null
loading.value = props.batchedUsageLoading === true
}
const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?: boolean }) => {
if (!shouldFetchUsage.value) return
if (isBatchManaged.value) {
requestParentBatchUsage({ force: options?.bypassCache === true })
return
}
// Check cache
if (!options?.bypassCache) {
@@ -1290,9 +1318,9 @@ const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?
error.value = null
try {
const fetchFn = () => options?.source
? adminAPI.accounts.getUsage(props.account.id, options.source)
: adminAPI.accounts.getUsage(props.account.id)
const fetchFn = () => options?.source
? adminAPI.accounts.getUsage(props.account.id, options.source, options.bypassCache === true)
: adminAPI.accounts.getUsage(props.account.id)
const result = await enqueueUsageRequest(props.account, fetchFn)
if (!unmounted.value) {
usageInfo.value = result
@@ -1369,54 +1397,10 @@ const loadActiveUsage = async () => {
}
}
const handleGrokProbed = (result: GrokQuotaProbeResult) => {
const current = usageInfo.value
if (!current) return
const snapshot = result.snapshot
const statusCode = snapshot?.status_code ?? result.status_code
const hasActiveProbeSnapshot = snapshot != null && (
result.source === 'active_probe' ||
result.source === 'hybrid_probe' ||
snapshot.observation_source === 'active_probe'
)
const probeSucceeded = hasActiveProbeSnapshot &&
statusCode != null && statusCode >= 200 && statusCode < 300
const snapshotEntitlement = snapshot?.entitlement_status?.trim()
const currentEntitlement = current.grok_entitlement_status?.trim()
const entitlementStatus = snapshotEntitlement || (
probeSucceeded && currentEntitlement?.toLowerCase() === 'forbidden'
? undefined
: current.grok_entitlement_status
)
const merged: AccountUsageInfo = {
...current,
grok_billing: result.billing ?? current.grok_billing,
grok_local_usage_24h: result.local_usage_24h ?? current.grok_local_usage_24h,
grok_local_usage_7d: result.local_usage_7d ?? current.grok_local_usage_7d,
grok_local_usage_monthly: result.local_usage_monthly ?? current.grok_local_usage_monthly,
grok_request_quota: snapshot?.requests ?? current.grok_request_quota,
grok_token_quota: snapshot?.tokens ?? current.grok_token_quota,
grok_retry_after_seconds: snapshot?.retry_after_seconds ?? current.grok_retry_after_seconds,
grok_entitlement_status: entitlementStatus,
grok_quota_snapshot_state: result.billing
? 'billing_observed'
: snapshot?.headers_observed
? 'observed'
: current.grok_quota_snapshot_state,
grok_last_quota_probe_at: result.billing?.fetched_at ?? snapshot?.last_probe_at ?? current.grok_last_quota_probe_at,
grok_last_headers_seen_at: snapshot?.last_headers_seen_at ?? current.grok_last_headers_seen_at,
grok_last_status_code: result.status_code ?? snapshot?.status_code ?? current.grok_last_status_code,
is_forbidden: probeSucceeded ? false : current.is_forbidden,
forbidden_reason: probeSucceeded ? undefined : current.forbidden_reason,
forbidden_type: probeSucceeded ? undefined : current.forbidden_type,
validation_url: probeSucceeded ? undefined : current.validation_url,
needs_verify: probeSucceeded ? false : current.needs_verify,
is_banned: probeSucceeded ? false : current.is_banned,
error: result.billing || snapshot ? undefined : current.error,
error_code: result.billing || snapshot ? undefined : current.error_code
}
usageInfo.value = merged
_usageCache.set(props.account.id, { data: merged, ts: Date.now() })
// The probe persists upstream quota state; refresh this cell so its compact
// bars and entitlement status reflect the newly observed snapshot.
const handleGrokProbed = async () => {
await loadUsage({ source: 'active', bypassCache: true })
}
// ===== API Key quota progress bars =====
@@ -1529,11 +1513,49 @@ onMounted(() => {
}
}
if (isBatchManaged.value) {
syncManagedUsageState()
requestParentBatchUsage()
return
}
if (!shouldAutoLoadUsageOnMount.value) return
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
requestAutoLoad(source)
})
watch(
() => [props.batchedUsage, props.batchedUsageError, props.batchedUsageLoading, isBatchManaged.value] as const,
() => {
syncManagedUsageState()
},
{ immediate: true, deep: true }
)
watch(isBatchManaged, (managed, wasManaged) => {
if (managed && !wasManaged) {
syncManagedUsageState()
requestParentBatchUsage()
}
})
watch(
() => [props.account.id, props.account.platform, props.account.type, isBatchManaged.value] as const,
([accountID, platform, accountType, managed], [previousAccountID, previousPlatform, previousAccountType]) => {
if (
accountID === previousAccountID &&
platform === previousPlatform &&
accountType === previousAccountType
) {
return
}
if (!managed || !shouldFetchUsage.value) return
syncManagedUsageState()
requestParentBatchUsage()
},
{ flush: 'post' }
)
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
if (!prevKey || nextKey === prevKey) return
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
@@ -1542,6 +1564,11 @@ watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
return
}
if (isBatchManaged.value) {
requestParentBatchUsage({ force: true })
return
}
_usageCache.delete(props.account.id)
requestAutoLoad()
})
@@ -1552,6 +1579,11 @@ watch(
if (nextToken === prevToken) return
if (!shouldFetchUsage.value) return
if (isBatchManaged.value) {
requestParentBatchUsage({ force: true })
return
}
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
_usageCache.delete(props.account.id)
loadUsage({ source, bypassCache: true }).catch((e) => {
@@ -3212,6 +3212,7 @@
:show-agent-identity-option="form.platform === 'openai'"
:show-codex-pat-option="form.platform === 'openai'"
:show-sso-option="form.platform === 'grok'"
:show-email-password-option="false"
:show-manual-option="true"
:initial-input-method="'manual'"
:platform="form.platform"
@@ -3224,6 +3225,7 @@
@import-codex-session="handleOpenAIImportCodexSession"
@import-codex-pat="handleOpenAIImportCodexPAT"
@import-sso="handleGrokImportSSO"
@authorize-password="handleGrokAuthorizePassword"
/>
</div>
@@ -3314,8 +3316,8 @@
<BaseDialog
:show="showGeminiHelpDialog"
:title="t('admin.accounts.gemini.helpDialog.title')"
width="wide"
@close="showGeminiHelpDialog = false"
max-width="max-w-3xl"
>
<div class="space-y-6">
<!-- Setup Guide Section -->
@@ -5476,6 +5478,116 @@ const handleGrokImportSSO = async (ssoInput: string) => {
}
}
/**
* Grok password login: each line is email----password.
* Password is only used for the authorize API call; buildCredentials never stores it.
*/
const handleGrokAuthorizePassword = async (emailPasswordInput: string) => {
if (!emailPasswordInput.trim()) return
if (!validateGrokOAuthUpstreamConfig()) return
const lines = emailPasswordInput
.split('\n')
// Keep the password portion byte-for-byte; trim is only for determining
// whether this textarea line is blank.
.filter((line) => line.trim() && line.includes('----'))
if (lines.length === 0) {
grokOAuth.error.value = t(
'admin.accounts.oauth.grok.pleaseEnterPassword',
'Please enter email----password (one per line)'
)
return
}
grokOAuth.loading.value = true
grokOAuth.error.value = ''
let successCount = 0
let failedCount = 0
const errors: string[] = []
try {
for (let i = 0; i < lines.length; i++) {
try {
const tokenInfo = await grokOAuth.authorizePassword(lines[i], form.proxy_id)
if (!tokenInfo) {
failedCount++
errors.push(`#${i + 1}: ${grokOAuth.error.value || 'Authorization failed'}`)
grokOAuth.error.value = ''
continue
}
const credentials = grokOAuth.buildCredentials(tokenInfo)
applyGrokOAuthUpstreamConfig(credentials)
const extra = grokOAuth.buildExtraInfo(tokenInfo)
const accountName =
lines.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(
lines.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
@@ -1375,6 +1375,40 @@
</div>
</div>
<div
v-if="supportsAccountSchedulingThresholdOverride"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
data-testid="account-scheduling-threshold-section"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.accountSchedulingThresholdOverride') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.accountSchedulingThresholdOverrideHint') }}
</p>
</div>
<input
v-model="accountSchedulingThresholdOverrideEnabled"
data-testid="account-scheduling-threshold-override-enabled"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div v-if="accountSchedulingThresholdOverrideEnabled">
<label class="input-label">{{ t('admin.accounts.accountSchedulingThresholdOverrideValue') }}</label>
<input
v-model.number="accountSchedulingThresholdOverrideValue"
data-testid="account-scheduling-threshold-override-value"
type="number"
min="1"
max="100"
class="input"
/>
<p class="input-hint">{{ t('admin.accounts.accountSchedulingThresholdOverrideDisabledHint') }}</p>
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
<div
v-if="account?.platform === 'anthropic' || account?.platform === 'antigravity'"
@@ -2867,6 +2901,12 @@ const antigravityWhitelistModels = ref<string[]>([])
const antigravityModelMappings = ref<ModelMapping[]>([])
const isSyncingAntigravityUpstream = ref(false)
const tempUnschedEnabled = ref(false)
const accountSchedulingThresholdOverrideEnabled = ref(false)
const accountSchedulingThresholdOverrideValue = ref(100)
const ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY = 'account_scheduling_threshold'
const supportsAccountSchedulingThresholdOverride = computed(() =>
supportsAccountSchedulingThresholdOverridePlatform(props.account?.platform)
)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
@@ -3512,6 +3552,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
loadQuotaControlSettings(newAccount)
loadTempUnschedRules(credentials)
loadAccountSchedulingThresholdOverride(newAccount.platform, credentials)
// Load header override state (anthropic/openai apikey + grok apikey/oauth)
headerOverrideEnabled.value = false
@@ -3874,6 +3915,69 @@ const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
return true
}
function supportsAccountSchedulingThresholdOverridePlatform(platform: Account['platform'] | undefined) {
return platform === 'openai' || platform === 'anthropic' || platform === 'grok'
}
function normalizeAccountSchedulingThresholdOverride(value: unknown): number | null {
if (value === null || value === undefined || value === '') {
return null
}
const numeric = Number(value)
if (!Number.isFinite(numeric)) {
return null
}
const integer = Math.trunc(numeric)
if (integer < 1 || integer > 100) {
return null
}
return integer
}
function clampAccountSchedulingThresholdOverride(value: unknown): number {
return Math.min(100, Math.max(1, Math.trunc(Number(value) || 100)))
}
function loadAccountSchedulingThresholdOverride(
platform: Account['platform'] | undefined,
credentials: Record<string, unknown> | undefined
) {
if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) {
accountSchedulingThresholdOverrideEnabled.value = false
accountSchedulingThresholdOverrideValue.value = 100
return
}
const value = normalizeAccountSchedulingThresholdOverride(
credentials?.[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY]
)
accountSchedulingThresholdOverrideEnabled.value = value !== null
accountSchedulingThresholdOverrideValue.value = value ?? 100
}
const applyAccountSchedulingThresholdOverridePatch = (
credentials: Record<string, unknown>,
currentCredentials: Record<string, unknown>,
platform: Account['platform'] | undefined = props.account?.platform
) => {
if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) {
return
}
const current = normalizeAccountSchedulingThresholdOverride(
currentCredentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY]
)
if (!accountSchedulingThresholdOverrideEnabled.value) {
if (current !== null) {
credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = null
}
return
}
const next = clampAccountSchedulingThresholdOverride(accountSchedulingThresholdOverrideValue.value)
if (current !== next) {
credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = next
}
}
function loadTempUnschedRules(credentials?: Record<string, unknown>) {
tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true
const rawRules = credentials?.temp_unschedulable_rules
@@ -4233,6 +4337,7 @@ const handleSubmit = async () => {
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -4251,6 +4356,7 @@ const handleSubmit = async () => {
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -4299,6 +4405,7 @@ const handleSubmit = async () => {
}
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -4356,6 +4463,7 @@ const handleSubmit = async () => {
}
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -4367,6 +4475,7 @@ const handleSubmit = async () => {
const newCredentials: Record<string, unknown> = { ...currentCredentials }
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -24,18 +24,13 @@
</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">
<!-- Compact mode: parent already shows 7d/30d/prepaid or 24h — only surface errors. -->
<div
v-if="!compact && 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">
@@ -48,12 +43,17 @@
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 { GrokQuotaProbeResult } from '@/api/admin/grok'
import type { Account } from '@/types'
const props = defineProps<{
account: Account
}>()
const props = withDefaults(
defineProps<{
account: Account
/** When true, only show the probe button (+ errors). No duplicate weekly summary. */
compact?: boolean
}>(),
{ compact: false }
)
const emit = defineEmits<{ probed: [result: GrokQuotaProbeResult] }>()
@@ -79,42 +79,16 @@ const extractErrorMessage = (e: unknown): string => {
)
}
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 (props.compact || !data.value) return ''
// Non-compact fallback (rarely used): brief weekly percent if present.
const billing = data.value.billing
const parts: Array<string | null> = []
if (billing?.period_type?.toLowerCase() === 'weekly' && billing.usage_percent != null) {
parts.push(t('admin.accounts.usageWindow.grokWeeklyUsage', {
return t('admin.accounts.usageWindow.grokWeeklyUsage', {
percent: Math.round(Math.min(100, Math.max(0, billing.usage_percent)))
}))
})
}
if (snapshot) {
parts.push(
formatWindow(t('admin.accounts.usageWindow.grokRequests'), snapshot.requests),
formatWindow(t('admin.accounts.usageWindow.grokTokens'), snapshot.tokens)
)
}
if (retryAfterLabel.value) {
parts.push(t('admin.accounts.usageWindow.grokRetryAfter', { time: retryAfterLabel.value }))
}
if (snapshot?.entitlement_status) {
parts.push(snapshot.entitlement_status)
}
const visibleParts = parts.filter((part): part is string => Boolean(part))
return visibleParts.length > 0 ? visibleParts.join(' | ') : t('admin.accounts.usageWindow.grokNoHeaders')
return ''
})
const truncatedError = computed(() => {
@@ -59,6 +59,17 @@
t(getOAuthKey('ssoCookieAuth'))
}}</span>
</label>
<label v-if="emailPasswordOptionEnabled" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="email_password"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t(getOAuthKey('emailPasswordAuth'))
}}</span>
</label>
<label v-if="showMobileRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
@@ -287,6 +298,79 @@
</div>
</div>
<!-- Grok email + password → ephemeral SSO → Build OAuth (password never stored) -->
<div v-if="inputMethod === 'email_password'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t(getOAuthKey('emailPasswordDesc')) }}
</p>
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="user" size="sm" class="text-blue-500" />
{{ t(getOAuthKey('emailPasswordInputLabel')) }}
<span
v-if="parsedEmailPasswordCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedEmailPasswordCount }) }}
</span>
</label>
<textarea
v-model="emailPasswordInput"
rows="4"
class="input w-full resize-y font-mono text-sm"
:placeholder="t(getOAuthKey('emailPasswordPlaceholder'))"
spellcheck="false"
autocomplete="off"
></textarea>
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
{{ t(getOAuthKey('emailPasswordHint')) }}
</p>
</div>
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !emailPasswordInput.trim()"
@click="handleAuthorizePassword"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{ loading ? t(getOAuthKey('validating')) : t(getOAuthKey('validateAndCreate')) }}
</button>
</div>
</div>
<!-- Codex auth.json / session credential batch import -->
<div v-if="inputMethod === 'codex_session' || inputMethod === 'agent_identity'" class="space-y-4">
<div
@@ -816,6 +900,7 @@ import { useClipboard } from '@/composables/useClipboard'
import Icon from '@/components/icons/Icon.vue'
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
import type { AccountPlatform } from '@/types'
import { adminAPI } from '@/api/admin'
interface Props {
addMethod: AddMethod
@@ -836,8 +921,15 @@ interface Props {
showAgentIdentityOption?: boolean
showCodexPatOption?: boolean
showSsoOption?: boolean
/** Grok email----password login (admin; password never persisted). */
showEmailPasswordOption?: boolean
showManualOption?: boolean
initialInputMethod?: AuthInputMethod
/**
* Prefill for Grok email----password reauth. Password is never stored;
* pass only the email (or "email----") so the operator types the password.
*/
initialEmailPassword?: string
platform?: AccountPlatform // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
@@ -860,8 +952,10 @@ const props = withDefaults(defineProps<Props>(), {
showAgentIdentityOption: false,
showCodexPatOption: false,
showSsoOption: false,
showEmailPasswordOption: false,
showManualOption: true,
initialInputMethod: 'manual',
initialEmailPassword: '',
platform: 'anthropic',
showProjectId: true
})
@@ -877,10 +971,15 @@ const emit = defineEmits<{
'import-codex-session': [content: string]
'import-codex-pat': [accessToken: string]
'import-sso': [content: string]
'authorize-password': [emailPasswordInput: string]
'update:inputMethod': [method: AuthInputMethod]
}>()
const { t } = useI18n()
const passwordAuthEnabled = ref(false)
const emailPasswordOptionEnabled = computed(
() => props.showEmailPasswordOption && props.platform === 'grok' && passwordAuthEnabled.value
)
const showLocalCallbackNotice = computed(() => props.platform === 'openai' || props.platform === 'grok')
@@ -922,10 +1021,30 @@ const sessionTokenInput = ref('')
const codexSessionInput = ref('')
const codexPATInput = ref('')
const ssoCookieInput = ref('')
const emailPasswordInput = ref(props.initialEmailPassword || '')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
watch(
() => [props.platform, props.showEmailPasswordOption] as const,
async ([platform, requested]) => {
passwordAuthEnabled.value = false
if (platform !== 'grok' || !requested) return
try {
const capabilities = await adminAPI.grok.getCapabilities()
passwordAuthEnabled.value = capabilities.password_auth_enabled
} catch {
// Fail closed; the backend enforces the same capability.
}
},
{ immediate: true }
)
watch(emailPasswordOptionEnabled, (enabled) => {
if (!enabled && inputMethod.value === 'email_password') inputMethod.value = 'manual'
})
// Computed: show method selection only when there is something to choose.
const methodOptionCount = computed(() => [
props.showManualOption,
@@ -937,7 +1056,8 @@ const methodOptionCount = computed(() => [
props.showCodexSessionImportOption,
props.showAgentIdentityOption,
props.showCodexPatOption,
props.showSsoOption
props.showSsoOption,
emailPasswordOptionEnabled.value
].filter(Boolean).length)
const showMethodSelection = computed(() => methodOptionCount.value > 1)
@@ -977,11 +1097,34 @@ const parsedSSOCount = computed(() => {
.filter((item) => item).length
})
const parsedEmailPasswordCount = computed(() => {
return emailPasswordInput.value
.split('\n')
.map((item) => item.trim())
.filter((item) => item && item.includes('----')).length
})
const handleAuthorizePassword = () => {
if (emailPasswordInput.value.trim()) {
emit('authorize-password', emailPasswordInput.value)
}
}
// Watchers
watch(() => props.initialInputMethod, (newVal) => {
inputMethod.value = newVal
})
watch(
() => props.initialEmailPassword,
(newVal) => {
// Only prefill when the field is empty so we never overwrite operator input.
if (newVal && !emailPasswordInput.value.trim()) {
emailPasswordInput.value = newVal
}
}
)
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
})
@@ -1081,6 +1224,7 @@ defineExpose({
codexSession: codexSessionInput,
codexPAT: codexPATInput,
ssoCookie: ssoCookieInput,
emailPassword: emailPasswordInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
@@ -1092,6 +1236,7 @@ defineExpose({
codexSessionInput.value = ''
codexPATInput.value = ''
ssoCookieInput.value = ''
emailPasswordInput.value = ''
inputMethod.value = props.initialInputMethod
showHelpDialog.value = false
}
@@ -101,6 +101,14 @@
{{ state?.error_message || '-' }}
</div>
</div>
<div
v-if="hasThresholdEvidence"
class="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300"
data-testid="temp-unsched-trigger-evidence"
>
{{ triggerEvidenceText }}
</div>
</div>
</div>
@@ -176,10 +184,28 @@ const isActive = computed(() => {
})
const ruleIndexDisplay = computed(() => {
if (!state.value) return '-'
if (!state.value || !state.value.matched_keyword || state.value.rule_index < 0) return '-'
return state.value.rule_index + 1
})
const hasThresholdEvidence = computed(() => (state.value?.trigger_count || 0) > 1)
const triggerEvidenceText = computed(() => {
const count = state.value?.trigger_count || 0
const threshold = state.value?.trigger_threshold || 0
const minutes = state.value?.trigger_window_minutes || 0
if (threshold > 0 && minutes > 0) {
return t('admin.accounts.tempUnschedulable.multipleErrorTrigger', { count, threshold, minutes })
}
if (threshold > 0) {
return t('admin.accounts.tempUnschedulable.multipleErrorTriggerNoWindow', { count, threshold })
}
if (minutes > 0) {
return t('admin.accounts.tempUnschedulable.multipleErrorCountInWindow', { count, minutes })
}
return t('admin.accounts.tempUnschedulable.multipleErrorCount', { count })
})
const triggeredAtText = computed(() => {
if (!state.value?.triggered_at_unix) return '-'
return formatDateTime(new Date(state.value.triggered_at_unix * 1000))
@@ -651,7 +651,7 @@ describe('AccountUsageCell', () => {
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
it('Grok OAuth 会展示本地 user billed 用量并把耗尽配额显示为 0% 剩余', async () => {
it('Grok OAuth compact UI drops local chips and header quota bars', async () => {
getUsage.mockResolvedValue({
grok_local_usage: {
requests: 4,
@@ -670,12 +670,7 @@ describe('AccountUsageCell', () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3861,
platform: 'grok',
type: 'oauth',
extra: {}
})
account: makeAccount({ id: 3861, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
@@ -683,66 +678,49 @@ describe('AccountUsageCell', () => {
props: ['label', 'utilization', 'resetsAt', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(3861)
expect(wrapper.text()).toContain('4 req')
expect(wrapper.text()).toContain('1.2K')
expect(wrapper.text()).toContain('A $0.12')
expect(wrapper.text()).toContain('U $0.34')
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|0|2026-07-09T16:00:00Z')
const badges = wrapper.findAll('span[title]')
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
expect(wrapper.text()).not.toContain('4 req')
expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokRequests|')
})
it('Grok OAuth 配额条按剩余容量显示 100% 满格和 25% 低量', async () => {
it('Grok paid monthly limits show 30d bar without free 24h', async () => {
getUsage.mockResolvedValue({
grok_request_quota: {
limit: 100,
remaining: 100,
reset_at: '2026-07-09T16:00:00Z'
grok_billing: {
period_type: 'weekly',
usage_percent: null,
used_percent: 12,
monthly_limit_cents: 25_000,
used_cents: 3_000,
plan: ''
},
grok_token_quota: {
limit: 1000,
remaining: 250,
reset_at: '2026-07-09T16:00:00Z'
},
grok_quota_snapshot_state: 'observed'
grok_entitlement_status: 'free',
grok_token_quota: { limit: 1_000, remaining: 250 }
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 4073,
platform: 'grok',
type: 'oauth',
extra: {}
})
account: makeAccount({ id: 4402, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color', 'remainingCapacity'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ remainingCapacity }}</div>'
props: ['label', 'utilization'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|100|true')
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25|true')
expect(wrapper.text()).toContain('30d|')
expect(wrapper.text()).not.toContain('24h|')
})
it('Grok OAuth uses the official weekly billing percentage when available', async () => {
@@ -775,7 +753,6 @@ describe('AccountUsageCell', () => {
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}|{{ remainingCapacity }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
@@ -789,11 +766,11 @@ describe('AccountUsageCell', () => {
})
it.each([
{ tokens: 0, expected: 0, compact: '0' },
{ tokens: 500_000, expected: 50, compact: '500.0K' },
{ tokens: 1_000_000, expected: 100, compact: '1.0M' },
{ tokens: 1_100_000, expected: 100, compact: '1.1M' }
])('Grok Free derives its 1M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected, compact }) => {
{ tokens: 0, expected: 0 },
{ tokens: 500_000, expected: 50 },
{ tokens: 1_000_000, expected: 100 },
{ tokens: 1_100_000, expected: 100 }
])('Grok Free derives its 1M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected }) => {
getUsage.mockResolvedValue({
grok_free_token_limit: 1_000_000,
grok_billing: {
@@ -823,7 +800,6 @@ describe('AccountUsageCell', () => {
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
@@ -831,10 +807,10 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(wrapper.text()).toContain(`24h|${expected}`)
expect(wrapper.findAll('span').filter((node) => node.text() === compact)).toHaveLength(1)
expect(wrapper.findAll('.usage-bar')).toHaveLength(1)
expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokRequests|')
expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokTokens|')
expect(wrapper.text()).not.toContain('7d|')
})
it('Grok Free uses rolling 24h usage instead of today-only usage', async () => {
@@ -872,7 +848,6 @@ describe('AccountUsageCell', () => {
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ title }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
@@ -880,7 +855,6 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(wrapper.text()).toContain('24h|75|admin.accounts.usageWindow.grokFreeQuota24hHint')
expect(wrapper.text()).toContain('750.0K')
expect(wrapper.text()).not.toContain('7d|')
expect(wrapper.text()).not.toContain('200.0K')
expect(wrapper.text()).not.toContain('250.0K')
@@ -917,7 +891,6 @@ describe('AccountUsageCell', () => {
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
@@ -930,85 +903,6 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).not.toContain('250.0K')
})
it('Grok paid plans are not mistaken for Free when weekly usage is temporarily missing', async () => {
getUsage.mockResolvedValue({
grok_billing: {
period_type: 'weekly',
usage_percent: null,
plan: 'SuperGrok Heavy'
},
grok_entitlement_status: 'free',
grok_local_usage: {
requests: 2,
tokens: 2_000_000,
cost: 1,
standard_cost: 1
},
grok_token_quota: { limit: 1_000, remaining: 250 }
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4401, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25')
expect(wrapper.text()).not.toContain('2M|')
})
it('Grok custom paid monthly limits override stale Free entitlement', async () => {
getUsage.mockResolvedValue({
grok_billing: {
period_type: 'weekly',
usage_percent: null,
monthly_limit_cents: 25_000,
plan: ''
},
grok_entitlement_status: 'free',
grok_local_usage: {
requests: 2,
tokens: 2_000_000,
cost: 1,
standard_cost: 1
},
grok_token_quota: { limit: 1_000, remaining: 250 }
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4402, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25')
expect(wrapper.text()).not.toContain('2M|')
})
it('Grok credential Free tier keeps the 1M fallback when billing is unavailable', async () => {
getUsage.mockResolvedValue({
grok_free_token_limit: 1_000_000,
@@ -1032,7 +926,6 @@ describe('AccountUsageCell', () => {
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
@@ -1042,257 +935,6 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('24h|100')
})
it('Grok paid manual probes keep the weekly/local summary when 24h usage is returned', async () => {
getUsage.mockResolvedValue({
grok_quota_snapshot_state: 'no_headers',
error: 'stale error',
error_code: 'quota_unknown'
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4501, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: {
emits: ['probed'],
template: `<button class="probe" @click="$emit('probed', {
source: 'hybrid_probe',
billing: { period_type: 'weekly', usage_percent: 42, period_end: '2026-07-17T00:00:00Z' },
snapshot: {
headers_observed: true,
updated_at: '2026-07-13T00:00:00Z',
entitlement_status: 'ACTIVE',
requests: { limit: 100, remaining: 20 }
},
local_usage_24h: { requests: 3, tokens: 750000, cost: 0.75, standard_cost: 0.75, user_cost: 0.25 },
local_usage_7d: { requests: 4, tokens: 1000000, cost: 1, standard_cost: 1, user_cost: 0.5 },
local_usage_monthly: { requests: 7, tokens: 1500000, cost: 2, standard_cost: 2, user_cost: 1 },
status_code: 200,
headers_observed: true,
reset_supported: false,
fetched_at: 1
})">probe</button>`
}
}
}
})
await flushPromises()
await wrapper.get('.probe').trigger('click')
expect(wrapper.text()).toContain('7d|42|2026-07-17T00:00:00Z')
expect(wrapper.text()).toContain('1.0M')
expect(wrapper.text()).not.toContain('750.0K')
expect(wrapper.text()).toContain('ACTIVE')
expect(wrapper.text()).not.toContain('stale error')
})
it('Grok successful probes immediately clear stale forbidden state', async () => {
getUsage.mockResolvedValue({
is_forbidden: true,
forbidden_reason: 'stale forbidden response',
forbidden_type: 'validation',
validation_url: 'https://example.com/verify',
needs_verify: true,
is_banned: true,
grok_entitlement_status: 'forbidden',
grok_quota_snapshot_state: 'no_headers',
error: 'stale forbidden response',
error_code: 'forbidden'
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4503, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('forbidden')
const setupState = wrapper.vm.$.setupState as {
handleGrokProbed: (result: Record<string, unknown>) => void
usageInfo: Record<string, unknown> | null
}
setupState.handleGrokProbed({
source: 'active_probe',
snapshot: {
headers_observed: false,
updated_at: '2026-07-18T00:00:00Z',
status_code: 200
},
status_code: 200,
headers_observed: false,
reset_supported: false,
fetched_at: 1
})
await wrapper.vm.$nextTick()
expect(setupState.usageInfo).toMatchObject({
is_forbidden: false,
needs_verify: false,
is_banned: false,
grok_last_status_code: 200
})
expect(setupState.usageInfo?.forbidden_reason).toBeUndefined()
expect(setupState.usageInfo?.forbidden_type).toBeUndefined()
expect(setupState.usageInfo?.validation_url).toBeUndefined()
expect(setupState.usageInfo?.grok_entitlement_status).toBeUndefined()
expect(wrapper.text()).not.toContain('admin.accounts.forbidden')
})
it('Grok successful probes preserve the entitlement reported by the latest snapshot', async () => {
getUsage.mockResolvedValue({
is_forbidden: true,
grok_entitlement_status: 'forbidden'
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4504, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
const setupState = wrapper.vm.$.setupState as {
handleGrokProbed: (result: Record<string, unknown>) => void
usageInfo: Record<string, unknown> | null
}
setupState.handleGrokProbed({
source: 'active_probe',
snapshot: {
headers_observed: true,
updated_at: '2026-07-18T00:00:00Z',
entitlement_status: 'ACTIVE',
status_code: 200
},
status_code: 200,
headers_observed: true,
reset_supported: false,
fetched_at: 1
})
await wrapper.vm.$nextTick()
expect(setupState.usageInfo?.grok_entitlement_status).toBe('ACTIVE')
expect(wrapper.text()).toContain('ACTIVE')
expect(wrapper.text()).not.toContain('admin.accounts.forbidden')
})
it('Grok billing-only success does not clear an active-probe forbidden state', async () => {
getUsage.mockResolvedValue({
is_forbidden: true,
forbidden_type: 'forbidden',
needs_verify: true,
is_banned: true,
grok_entitlement_status: 'forbidden'
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4505, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
const setupState = wrapper.vm.$.setupState as {
handleGrokProbed: (result: Record<string, unknown>) => void
usageInfo: Record<string, unknown> | null
}
setupState.handleGrokProbed({
source: 'billing_probe',
billing: {
period_type: 'weekly',
usage_percent: 10,
plan: 'SuperGrok'
},
status_code: 200,
headers_observed: false,
reset_supported: false,
fetched_at: 1
})
await wrapper.vm.$nextTick()
expect(setupState.usageInfo).toMatchObject({
is_forbidden: true,
forbidden_type: 'forbidden',
needs_verify: true,
is_banned: true,
grok_entitlement_status: 'forbidden'
})
expect(wrapper.text()).toContain('forbidden')
})
it('Grok Free manual probes merge rolling 24h usage', async () => {
getUsage.mockResolvedValue({
grok_free_token_limit: 1_000_000,
subscription_tier: 'FREE',
grok_quota_snapshot_state: 'no_headers'
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4502, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: {
emits: ['probed'],
template: `<button class="probe" @click="$emit('probed', {
source: 'hybrid_probe',
billing: { period_type: 'weekly', usage_percent: null, plan: '' },
local_usage_24h: { requests: 12, tokens: 750000, cost: 0, standard_cost: 0 },
headers_observed: false,
reset_supported: false,
fetched_at: 1
})">probe</button>`
}
}
}
})
await flushPromises()
await wrapper.get('.probe').trigger('click')
expect(wrapper.text()).toContain('24h|75')
expect(wrapper.text()).toContain('750.0K')
expect(wrapper.text()).not.toContain('7d|')
})
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
@@ -1424,7 +1066,6 @@ describe('AccountUsageCell', () => {
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
@@ -1468,7 +1109,6 @@ describe('AccountUsageCell', () => {
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
@@ -23,9 +23,13 @@ describe('CreateAccountModal Grok account types', () => {
expect(source).toContain('form.platform === \'grok\' && isOAuthFlow')
})
it('validates and applies upstream config on all three Grok OAuth create paths', () => {
// 授权码兑换 / RT 批量 / SSO 批量 3 处调用(定义为箭头函数,不计入)
expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBe(3)
expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBe(3)
it('validates and applies upstream config on Grok OAuth create paths', () => {
// 授权码兑换 / RT 批量 / SSO 批量(密码授权已隐藏)
expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBeGreaterThanOrEqual(3)
expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBeGreaterThanOrEqual(3)
})
it('hides Grok password authorize option in the create flow', () => {
expect(source).toContain(':show-email-password-option="false"')
})
})
@@ -41,13 +41,28 @@
</span>
</div>
<div class="space-y-1.5">
<!-- Grok: mode first, then optional model / mode params -->
<div v-if="isGrokAccount" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.testMode') }}
</label>
<Select
v-model="grokTestMode"
:options="grokTestModeOptions"
:disabled="status === 'connecting'"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.grok.testModeHint') }}
</p>
</div>
<div v-if="showModelSelect" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }}
</label>
<Select
v-model="selectedModelId"
:options="availableModels"
:options="modelOptionsForMode"
:disabled="loadingModels || status === 'connecting'"
value-key="id"
label-key="display_name"
@@ -66,16 +81,94 @@
/>
</div>
<div v-if="supportsImageTest" class="space-y-1.5">
<div v-if="supportsPromptInput" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.imagePromptLabel')"
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
:hint="t('admin.accounts.imageTestHint')"
:label="promptInputLabel"
:placeholder="promptInputPlaceholder"
:hint="promptInputHint"
:disabled="status === 'connecting'"
rows="3"
/>
</div>
<p
v-else-if="isGrokAccount && promptInputHint"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ promptInputHint }}
</p>
<!-- Optional media uploads for real generation / transcription -->
<div v-if="supportsImageUpload" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ imageUploadLabel }}
</label>
<div class="flex items-center gap-3">
<button
type="button"
class="btn btn-secondary btn-sm shrink-0"
:disabled="status === 'connecting'"
@click="imageFileInput?.click()"
>
{{ t('admin.accounts.grok.chooseImageFile') }}
</button>
<span class="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{{
uploadImageName
? t('common.selectedFile', { name: uploadImageName })
: t('common.noFileSelected')
}}
</span>
<input
ref="imageFileInput"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
class="hidden"
:disabled="status === 'connecting'"
@change="onImageFileChange"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ imageUploadHint }}</p>
<div v-if="uploadImagePreview" class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-500">
<img
:src="uploadImagePreview"
:alt="t('admin.accounts.grok.uploadPreviewAlt')"
class="max-h-40 w-full object-contain bg-gray-50 dark:bg-dark-700"
/>
</div>
</div>
<div v-if="supportsAudioUpload" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.audioUploadLabel') }}
</label>
<div class="flex items-center gap-3">
<button
type="button"
class="btn btn-secondary btn-sm shrink-0"
:disabled="status === 'connecting'"
@click="audioFileInput?.click()"
>
{{ t('admin.accounts.grok.chooseAudioFile') }}
</button>
<span class="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{{
uploadAudioName
? t('common.selectedFile', { name: uploadAudioName })
: t('common.noFileSelected')
}}
</span>
<input
ref="audioFileInput"
type="file"
accept="audio/*,.wav,.mp3,.m4a,.ogg,.webm"
class="hidden"
:disabled="status === 'connecting'"
@change="onAudioFileChange"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.audioUploadHint') }}</p>
</div>
<!-- Terminal Output -->
<div class="group relative">
@@ -142,7 +235,11 @@
class="group/img relative cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
@click="previewImageUrl = image.url"
>
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
<img
:src="image.url"
:alt="t('admin.accounts.imagePreviewAlt', { index: index + 1 })"
class="max-h-[360px] w-full object-contain"
/>
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
</div>
@@ -153,6 +250,36 @@
</div>
</div>
<div v-if="generatedAudios.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.audioPreview') }}
</div>
<div
v-for="(audio, index) in generatedAudios"
:key="`audio-${index}`"
class="rounded-xl border border-gray-200 bg-white p-3 dark:border-dark-500 dark:bg-dark-700"
>
<audio :src="audio.url" controls class="w-full" :type="audio.mimeType" />
<div class="mt-1 text-xs text-gray-500 dark:text-gray-300">{{ audio.mimeType || 'audio/*' }}</div>
</div>
</div>
<div v-if="generatedVideos.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.videoPreview') }}
</div>
<div
v-for="(video, index) in generatedVideos"
:key="`video-${index}`"
class="overflow-hidden rounded-xl border border-gray-200 bg-black dark:border-dark-500"
>
<video :src="video.url" controls class="max-h-[360px] w-full" :type="video.mimeType" />
<div class="border-t border-gray-100 bg-white px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-300">
{{ video.mimeType || 'video/*' }}
</div>
</div>
</div>
<!-- Image Lightbox -->
<Teleport to="body">
<Transition name="fade">
@@ -169,7 +296,7 @@
</button>
<img
:src="previewImageUrl"
alt="preview"
:alt="t('admin.accounts.imageLightboxAlt')"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
/>
</div>
@@ -186,11 +313,7 @@
</div>
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
supportsImageTest
? t('admin.accounts.imageTestMode')
: t('admin.accounts.testPrompt')
}}
{{ testModeSummary }}
</span>
</div>
</div>
@@ -205,10 +328,10 @@
</button>
<button
@click="startTest"
:disabled="status === 'connecting' || !selectedModelId"
:disabled="!canStartTest"
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
!canStartTest
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
@@ -262,7 +385,7 @@ interface OutputLine {
class: string
}
interface PreviewImage {
interface PreviewMedia {
url: string
mimeType?: string
}
@@ -286,14 +409,34 @@ const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const generatedImages = ref<PreviewMedia[]>([])
const generatedAudios = ref<PreviewMedia[]>([])
const generatedVideos = ref<PreviewMedia[]>([])
const previewImageUrl = ref('')
const testMode = ref<'default' | 'compact'>('default')
const grokTestMode = ref<'text' | 'image' | 'video' | 'search' | 'tts' | 'stt' | 'realtime'>('text')
const uploadImageDataURL = ref('')
const uploadImagePreview = ref('')
const uploadImageName = ref('')
const uploadAudioDataURL = ref('')
const uploadAudioName = ref('')
const imageFileInput = ref<HTMLInputElement | null>(null)
const audioFileInput = ref<HTMLInputElement | null>(null)
const isOpenAIAccount = computed(() => props.account?.platform === 'openai')
const isGrokAccount = computed(() => props.account?.platform === 'grok')
const openAITestModeOptions = computed(() => [
{ value: 'default', label: t('admin.accounts.openai.testModeDefault') },
{ value: 'compact', label: t('admin.accounts.openai.testModeCompact') }
])
const grokTestModeOptions = computed(() => [
{ value: 'text', label: t('admin.accounts.grok.testModeText') },
{ value: 'image', label: t('admin.accounts.grok.testModeImage') },
{ value: 'video', label: t('admin.accounts.grok.testModeVideo') },
{ value: 'search', label: t('admin.accounts.grok.testModeSearch') },
{ value: 'tts', label: t('admin.accounts.grok.testModeTTS') },
{ value: 'stt', label: t('admin.accounts.grok.testModeSTT') },
{ value: 'realtime', label: t('admin.accounts.grok.testModeRealtime') }
])
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-3.5-flash', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
@@ -308,7 +451,243 @@ const supportsOpenAIImageTest = computed(() => {
return props.account?.platform === 'openai'
})
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
const isGrokImageModel = (id: string) => {
const modelID = id.toLowerCase()
return (
modelID === 'grok-imagine' ||
modelID === 'grok-imagine-edit' ||
modelID.startsWith('grok-imagine-image')
)
}
const isGrokVideoModel = (id: string) => {
const modelID = id.toLowerCase()
return modelID.startsWith('grok-imagine-video') || modelID.startsWith('grok-video')
}
const isGrokTextModel = (id: string) => !isGrokImageModel(id) && !isGrokVideoModel(id)
const supportsGrokImageTest = computed(
() => isGrokAccount.value && grokTestMode.value === 'image'
)
const supportsGrokVideoTest = computed(
() => isGrokAccount.value && grokTestMode.value === 'video'
)
const supportsImageTest = computed(
() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value || supportsGrokImageTest.value
)
// Model select only when the mode needs a model.
const showModelSelect = computed(() => {
if (!isGrokAccount.value) return true
return grokTestMode.value === 'text' || grokTestMode.value === 'image' || grokTestMode.value === 'video'
})
const modelOptionsForMode = computed(() => {
if (!isGrokAccount.value) return availableModels.value
if (grokTestMode.value === 'image') {
return availableModels.value.filter((m) => isGrokImageModel(m.id))
}
if (grokTestMode.value === 'video') {
return availableModels.value.filter((m) => isGrokVideoModel(m.id))
}
if (grokTestMode.value === 'text') {
return availableModels.value.filter((m) => isGrokTextModel(m.id))
}
return []
})
const supportsPromptInput = computed(() => {
if (!isGrokAccount.value) {
return supportsImageTest.value
}
return (
grokTestMode.value === 'image' ||
grokTestMode.value === 'video' ||
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts'
)
})
const supportsImageUpload = computed(
() => isGrokAccount.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')
)
const supportsAudioUpload = computed(() => isGrokAccount.value && grokTestMode.value === 'stt')
const imageUploadLabel = computed(() =>
grokTestMode.value === 'video'
? t('admin.accounts.grok.videoFirstFrameLabel')
: t('admin.accounts.grok.imageUploadLabel')
)
const imageUploadHint = computed(() =>
grokTestMode.value === 'video'
? t('admin.accounts.grok.videoFirstFrameHint')
: t('admin.accounts.grok.imageUploadHint')
)
const readFileAsDataURL = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error(t('admin.accounts.grok.fileReadFailed')))
reader.readAsDataURL(file)
})
const onImageFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
uploadImageName.value = ''
return
}
if (file.size > 6 * 1024 * 1024) {
errorMessage.value = t('admin.accounts.grok.mediaTooLarge')
status.value = 'error'
input.value = ''
return
}
try {
const dataURL = await readFileAsDataURL(file)
uploadImageDataURL.value = dataURL
uploadImagePreview.value = dataURL
uploadImageName.value = file.name
} catch {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
uploadImageName.value = ''
errorMessage.value = t('admin.accounts.grok.fileReadFailed')
status.value = 'error'
}
}
const onAudioFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
return
}
if (file.size > 6 * 1024 * 1024) {
errorMessage.value = t('admin.accounts.grok.mediaTooLarge')
status.value = 'error'
input.value = ''
return
}
try {
uploadAudioDataURL.value = await readFileAsDataURL(file)
uploadAudioName.value = file.name
} catch {
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
errorMessage.value = t('admin.accounts.grok.fileReadFailed')
status.value = 'error'
}
}
const clearMediaUploads = () => {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
uploadImageName.value = ''
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
if (imageFileInput.value) imageFileInput.value.value = ''
if (audioFileInput.value) audioFileInput.value.value = ''
}
const promptInputLabel = computed(() => {
if (supportsGrokVideoTest.value || grokTestMode.value === 'video') {
return t('admin.accounts.videoPromptLabel')
}
if (supportsImageTest.value || grokTestMode.value === 'image') {
return t('admin.accounts.imagePromptLabel')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchQueryLabel')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTextLabel')
}
return t('admin.accounts.imagePromptLabel')
})
const promptInputPlaceholder = computed(() => {
if (grokTestMode.value === 'video') {
return t('admin.accounts.videoPromptPlaceholder')
}
if (grokTestMode.value === 'image' || supportsImageTest.value) {
return t('admin.accounts.imagePromptPlaceholder')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchQueryPlaceholder')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTextPlaceholder')
}
return ''
})
const promptInputHint = computed(() => {
if (grokTestMode.value === 'video') {
return t('admin.accounts.videoTestHint')
}
if (grokTestMode.value === 'image' || supportsImageTest.value) {
return t('admin.accounts.imageTestHint')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchTestHint')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTestHint')
}
if (grokTestMode.value === 'stt') {
return t('admin.accounts.grok.sttTestHint')
}
if (grokTestMode.value === 'realtime') {
return t('admin.accounts.grok.realtimeTestHint')
}
return ''
})
const testModeSummary = computed(() => {
if (isGrokAccount.value) {
switch (grokTestMode.value) {
case 'video':
return t('admin.accounts.videoTestMode')
case 'image':
return t('admin.accounts.imageTestMode')
case 'search':
return t('admin.accounts.grok.searchTestMode')
case 'tts':
return t('admin.accounts.grok.ttsTestMode')
case 'stt':
return t('admin.accounts.grok.sttTestMode')
case 'realtime':
return t('admin.accounts.grok.realtimeTestMode')
default:
return t('admin.accounts.grok.textTestMode')
}
}
if (supportsImageTest.value) return t('admin.accounts.imageTestMode')
return t('admin.accounts.testPrompt')
})
const canStartTest = computed(() => {
if (status.value === 'connecting') return false
if (isGrokAccount.value) {
if (
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts' ||
grokTestMode.value === 'stt' ||
grokTestMode.value === 'realtime'
) {
return true // standalone modes (prompt/model optional)
}
return Boolean(selectedModelId.value)
}
return Boolean(selectedModelId.value)
})
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
@@ -322,24 +701,63 @@ const sortTestModels = (models: ClaudeModel[]) => {
}
// Load available models when modal opens
const applyDefaultPromptForMode = () => {
if (!supportsPromptInput.value) return
if (testPrompt.value.trim()) return
if (grokTestMode.value === 'video') {
testPrompt.value = t('admin.accounts.videoPromptDefault')
} else if (grokTestMode.value === 'image' || supportsImageTest.value) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
} else if (grokTestMode.value === 'search') {
testPrompt.value = t('admin.accounts.grok.searchQueryDefault')
} else if (grokTestMode.value === 'tts') {
testPrompt.value = t('admin.accounts.grok.ttsTextDefault')
}
}
const pickDefaultModelForMode = () => {
const opts = modelOptionsForMode.value
if (!opts.length) {
selectedModelId.value = ''
return
}
if (opts.some((m) => m.id === selectedModelId.value)) return
if (grokTestMode.value === 'text') {
const preferred =
opts.find((m) => m.id.includes('grok-4.5')) ||
opts.find((m) => m.id === 'grok') ||
opts[0]
selectedModelId.value = preferred.id
return
}
selectedModelId.value = opts[0].id
}
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
testPrompt.value = ''
testMode.value = 'default'
grokTestMode.value = 'text'
resetState()
await loadAvailableModels()
if (isGrokAccount.value) {
pickDefaultModelForMode()
applyDefaultPromptForMode()
}
} else {
abortStream()
}
}
)
watch(selectedModelId, () => {
if (supportsImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
}
watch(grokTestMode, () => {
if (!isGrokAccount.value) return
testPrompt.value = ''
clearMediaUploads()
pickDefaultModelForMode()
applyDefaultPromptForMode()
})
const loadAvailableModels = async () => {
@@ -378,6 +796,8 @@ const resetState = () => {
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
generatedAudios.value = []
generatedVideos.value = []
previewImageUrl.value = ''
}
@@ -406,12 +826,17 @@ const scrollToBottom = async () => {
}
const startTest = async () => {
if (!props.account || !selectedModelId.value) return
if (!props.account || !canStartTest.value) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
if (isGrokAccount.value) {
const modeLabel =
grokTestModeOptions.value.find((o) => o.value === grokTestMode.value)?.label || grokTestMode.value
addLine(t('admin.accounts.grok.selectedTestMode', { mode: modeLabel }), 'text-gray-400')
}
addLine('', 'text-gray-300')
abortStream()
@@ -422,14 +847,35 @@ const startTest = async () => {
const requestBody: {
model_id: string
prompt: string
mode?: 'default' | 'compact'
mode?: string
image_data_url?: string
audio_data_url?: string
} = {
model_id: selectedModelId.value,
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
model_id: showModelSelect.value ? selectedModelId.value : '',
prompt: supportsPromptInput.value ? testPrompt.value.trim() : ''
}
if (isOpenAIAccount.value) {
requestBody.mode = testMode.value
}
if (isGrokAccount.value) {
// Always send explicit Grok mode. search/tts/stt/realtime are standalone
// endpoints (no free-form model select). text/image/video use optional model.
requestBody.mode = grokTestMode.value
if (
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts' ||
grokTestMode.value === 'stt' ||
grokTestMode.value === 'realtime'
) {
requestBody.model_id = ''
}
if (uploadImageDataURL.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')) {
requestBody.image_data_url = uploadImageDataURL.value
}
if (uploadAudioDataURL.value && grokTestMode.value === 'stt') {
requestBody.audio_data_url = uploadAudioDataURL.value
}
}
// Use the configured API base; EventSource does not support POST.
const url = buildApiUrl(`/admin/accounts/${props.account.id}/test`)
@@ -452,7 +898,7 @@ const startTest = async () => {
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
throw new Error(t('admin.accounts.grok.noResponseBody'))
}
const decoder = new TextDecoder()
@@ -486,9 +932,9 @@ const startTest = async () => {
return
}
status.value = 'error'
const msg = error instanceof Error ? error.message : 'Unknown error'
const msg = error instanceof Error ? error.message : t('common.unknownError')
errorMessage.value = msg
addLine(`Error: ${msg}`, 'text-red-400')
addLine(t('admin.accounts.errorPrefix', { message: msg }), 'text-red-400')
}
}
@@ -499,6 +945,8 @@ const handleEvent = (event: {
success?: boolean
error?: string
image_url?: string
audio_url?: string
video_url?: string
mime_type?: string
}) => {
switch (event.type) {
@@ -508,7 +956,21 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
supportsImageTest.value
isGrokAccount.value
? grokTestMode.value === 'video'
? t('admin.accounts.sendingVideoRequest')
: grokTestMode.value === 'image'
? t('admin.accounts.sendingImageRequest')
: grokTestMode.value === 'search'
? t('admin.accounts.grok.sendingSearchRequest')
: grokTestMode.value === 'tts'
? t('admin.accounts.grok.sendingTTSRequest')
: grokTestMode.value === 'stt'
? t('admin.accounts.grok.sendingSTTRequest')
: grokTestMode.value === 'realtime'
? t('admin.accounts.grok.sendingRealtimeRequest')
: t('admin.accounts.sendingTestMessage')
: supportsImageTest.value
? t('admin.accounts.sendingImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
@@ -534,6 +996,26 @@ const handleEvent = (event: {
}
break
case 'audio':
if (event.audio_url) {
generatedAudios.value.push({
url: event.audio_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.audioReceived', { count: generatedAudios.value.length }), 'text-purple-300')
}
break
case 'video':
if (event.video_url) {
generatedVideos.value.push({
url: event.video_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.videoReceived', { count: generatedVideos.value.length }), 'text-purple-300')
}
break
case 'status':
if (event.text) {
addLine(event.text, 'text-cyan-300')
@@ -550,13 +1032,13 @@ const handleEvent = (event: {
status.value = 'success'
} else {
status.value = 'error'
errorMessage.value = event.error || 'Test failed'
errorMessage.value = event.error || t('admin.accounts.testFailed')
}
break
case 'error':
status.value = 'error'
errorMessage.value = event.error || 'Unknown error'
errorMessage.value = event.error || t('common.unknownError')
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
@@ -130,12 +130,18 @@
:show-help="isAnthropic"
:show-proxy-warning="isAnthropic"
:show-cookie-option="isAnthropic"
:show-refresh-token-option="isOpenAI || isAntigravity || isGrok"
:show-sso-option="isGrok"
:show-email-password-option="false"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : isGrok ? 'grok' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
:initial-input-method="grokInitialInputMethod"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@validate-refresh-token="handleGrokValidateRefreshToken"
@import-sso="handleGrokImportSSO"
/>
</div>
@@ -249,6 +255,21 @@ const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
const isGrok = computed(() => props.account?.platform === 'grok')
/**
* Grok reauth default tab (password auth is hidden):
* - refresh_token when RT may still work
* - SSO cookie otherwise
*/
const grokInitialInputMethod = computed<AuthInputMethod>(() => {
if (!isGrok.value) return 'manual'
const creds = (props.account?.credentials || {}) as Record<string, unknown>
const hasRT =
(typeof creds.refresh_token === 'string' && creds.refresh_token.trim() !== '') ||
(typeof creds.has_refresh_token === 'boolean' && creds.has_refresh_token)
if (hasRT) return 'refresh_token'
return 'sso_cookie'
})
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
if (isOpenAILike.value) return openaiOAuth.authUrl.value
@@ -279,10 +300,20 @@ const currentError = computed(() => {
return claudeOAuth.error.value
})
// Computed
// Computed — footer "complete auth" only for code-exchange flows, not SSO/password/RT.
const isManualInputMethod = computed(() => {
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAILike.value || isGemini.value || isAntigravity.value || isGrok.value || oauthFlowRef.value?.inputMethod === 'manual'
const method = oauthFlowRef.value?.inputMethod
if (method === 'sso_cookie' || method === 'email_password' || method === 'refresh_token') {
return false
}
// OpenAI/Gemini/Antigravity/Grok use manual code paste by default (no cookie auth)
return (
isOpenAILike.value ||
isGemini.value ||
isAntigravity.value ||
isGrok.value ||
method === 'manual'
)
})
const canExchangeCode = computed(() => {
@@ -574,4 +605,79 @@ const handleCookieAuth = async (sessionKey: string) => {
claudeOAuth.loading.value = false
}
}
/** Apply Grok Build OAuth tokens onto the existing account (never store password/SSO). */
const applyGrokReauthTokenInfo = async (tokenInfo: {
access_token?: string
refresh_token?: string
email?: string
[key: string]: unknown
}) => {
if (!props.account) return
const credentials = grokOAuth.buildCredentials(tokenInfo as any)
const extra = grokOAuth.buildExtraInfo(tokenInfo as any)
const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, {
type: 'oauth',
credentials,
extra
})
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized', updatedAccount)
handleClose()
}
/** Re-auth with a single SSO cookie → Build OAuth (not batch create). */
const handleGrokImportSSO = async (ssoInput: string) => {
if (!props.account || !isGrok.value) return
const ssoToken = ssoInput
.split('\n')
.map((line) => line.trim())
.filter(Boolean)[0]
if (!ssoToken) return
grokOAuth.loading.value = true
grokOAuth.error.value = ''
try {
const tokenInfo = await grokOAuth.validateSSOToken(ssoToken, props.account.proxy_id)
if (!tokenInfo) return
await applyGrokReauthTokenInfo(tokenInfo)
} catch (error: any) {
grokOAuth.error.value =
error.response?.data?.detail ||
error.message ||
t('admin.accounts.oauth.grok.failedToValidateSSO', 'Failed to validate Grok SSO')
appStore.showError(grokOAuth.error.value)
} finally {
grokOAuth.loading.value = false
}
}
/** Re-auth with a single refresh token. */
const handleGrokValidateRefreshToken = async (refreshTokenInput: string) => {
if (!props.account || !isGrok.value) return
const refreshToken = refreshTokenInput
.split('\n')
.map((line) => line.trim())
.filter(Boolean)[0]
if (!refreshToken) {
grokOAuth.error.value = t('admin.accounts.oauth.grok.pleaseEnterRefreshToken')
return
}
grokOAuth.loading.value = true
grokOAuth.error.value = ''
try {
const tokenInfo = await grokOAuth.validateRefreshToken(refreshToken, props.account.proxy_id)
if (!tokenInfo) return
await applyGrokReauthTokenInfo(tokenInfo)
} catch (error: any) {
grokOAuth.error.value =
error.response?.data?.detail ||
error.message ||
t('admin.accounts.oauth.grok.failedToValidateRT')
appStore.showError(grokOAuth.error.value)
} finally {
grokOAuth.loading.value = false
}
}
</script>
@@ -33,6 +33,9 @@ vi.mock('vue-i18n', async () => {
if (key === 'admin.accounts.imageReceived' && params?.count) {
return `received-${params.count}`
}
if (key === 'admin.accounts.imagePreviewAlt' && params?.index) {
return `test-image-${params.index}`
}
return messages[key] || key
}
})
@@ -179,7 +182,8 @@ describe('AccountTestModal', () => {
const [, request] = (global.fetch as any).mock.calls[0]
expect(JSON.parse(request.body)).toEqual({
model_id: 'grok-4.3',
prompt: ''
prompt: '',
mode: 'text'
})
})
@@ -0,0 +1,41 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
resolve(process.cwd(), 'src/components/admin/account/ReAuthAccountModal.vue'),
'utf8'
)
describe('ReAuthAccountModal Grok re-auth paths', () => {
it('exposes SSO cookie and refresh-token options; password auth stays hidden', () => {
expect(source).toContain(':show-sso-option="isGrok"')
expect(source).toContain(':show-email-password-option="false"')
expect(source).toContain(':show-refresh-token-option="isOpenAI || isAntigravity || isGrok"')
expect(source).not.toContain('@authorize-password=')
})
it('wires SSO and RT reauth without batch account create', () => {
expect(source).toContain('@import-sso="handleGrokImportSSO"')
expect(source).toContain('@validate-refresh-token="handleGrokValidateRefreshToken"')
expect(source).toContain('grokOAuth.validateSSOToken')
expect(source).toContain('grokOAuth.buildCredentials')
// Re-auth updates the existing account; must not call createFromSSO batch create
expect(source).not.toContain('createFromSSO')
expect(source).toContain('applyOAuthCredentials')
})
it('hides footer code-exchange button for SSO/RT input methods', () => {
expect(source).toContain("method === 'sso_cookie'")
expect(source).toContain("method === 'refresh_token'")
})
it('defaults reauth to refresh_token or sso_cookie (not password)', () => {
expect(source).toContain('grokInitialInputMethod')
expect(source).toContain(':initial-input-method="grokInitialInputMethod"')
expect(source).toContain("return 'sso_cookie'")
expect(source).toContain("return 'refresh_token'")
expect(source).not.toContain("return 'email_password'")
expect(source).not.toContain('grokPrefillEmailPassword')
})
})
@@ -156,7 +156,7 @@ const readFileAsText = async (sourceFile: File): Promise<string> => {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.onerror = () => reject(reader.error || new Error(t('common.fileReadFailed')))
reader.readAsText(sourceFile)
})
}
@@ -139,6 +139,11 @@
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
</div>
<div v-if="mode === 'usage'" class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.upstreamModelAudit') }}</label>
<Select v-model="filters.upstream_model_mismatch" :options="upstreamModelMismatchOptions" @change="emitChange" />
</div>
<!-- Error Phase Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('admin.ops.errorLog.type') }}</label>
@@ -307,6 +312,12 @@ const billingModeOptions = ref<SelectOption[]>([
{ value: 'video', label: t('admin.usage.billingModeVideo') }
])
const upstreamModelMismatchOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allUpstreamModelAudit') },
{ value: true, label: t('admin.usage.upstreamModelMismatchOnly') },
{ value: false, label: t('admin.usage.upstreamModelMatchedOnly') }
])
const emitChange = () => emit('change')
const clearPendingUserSearch = () => {
@@ -53,23 +53,41 @@
</template>
<template #cell-model="{ row }">
<div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5 text-xs">
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
class="break-all"
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
<span v-if="i > 0" class="mr-0.5">↳</span>{{ step }}
<div class="space-y-0.5 text-xs">
<div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5">
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
class="break-all"
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
<span v-if="i > 0" class="mr-0.5">↳</span>{{ step }}
</div>
</div>
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }}
</div>
<div class="break-all text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
</div>
</div>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
<div
v-if="row.upstream_model_mismatch === true && row.upstream_response_model"
class="break-all pl-3 text-[11px]"
:class="isLikelyModelVariant(row) ? 'text-amber-600 dark:text-amber-400' : 'text-orange-600 dark:text-orange-400'"
:title="modelAuditTitle(row)"
>
<span class="mr-1">↳ {{ t('usage.upstreamResponseModel') }}:</span>{{ row.upstream_response_model }}
<span
class="ml-1 inline-flex rounded px-1 py-px text-[10px] font-medium ring-1 ring-inset"
:class="isLikelyModelVariant(row)
? 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30'
: 'bg-orange-50 text-orange-700 ring-orange-200 dark:bg-orange-500/10 dark:text-orange-300 dark:ring-orange-500/30'"
>
{{ isLikelyModelVariant(row) ? t('usage.modelVariant') : t('usage.modelMismatch') }}
</span>
</div>
</div>
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }}
</div>
<div class="break-all text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
</div>
</div>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
</template>
<template #cell-reasoning_effort="{ row }">
@@ -548,6 +566,27 @@ const ipGeoBatchLoading = ref(false)
const showIpGeoToolbar = computed(() => props.columns.some((col) => col.key === 'ip_address'))
const sentUpstreamModel = (row: AdminUsageLog): string => row.upstream_model?.trim() || row.model?.trim() || ''
const normalizeModelVariant = (model: string): string => model
.trim()
.toLowerCase()
.replace(/-latest$/, '')
.replace(/-\d{4}-\d{2}-\d{2}$/, '')
.replace(/-\d{8}$/, '')
const isLikelyModelVariant = (row: AdminUsageLog): boolean => {
const sent = sentUpstreamModel(row)
const response = row.upstream_response_model?.trim() || ''
return sent !== '' && response !== '' && normalizeModelVariant(sent) === normalizeModelVariant(response)
}
const modelAuditTitle = (row: AdminUsageLog): string => [
`${t('usage.requestedModel')}: ${row.model || '-'}`,
`${t('usage.sentUpstreamModel')}: ${sentUpstreamModel(row) || '-'}`,
`${t('usage.upstreamResponseModel')}: ${row.upstream_response_model || '-'}`,
].join('\n')
const currentPageIps = computed(() =>
Array.from(new Set(props.data.map((row) => row.ip_address).filter((ip): ip is string => Boolean(ip))))
)
@@ -28,6 +28,10 @@ const messages: Record<string, string> = {
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per Request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.upstreamModelAudit': 'Upstream model audit',
'admin.usage.allUpstreamModelAudit': 'All response model states',
'admin.usage.upstreamModelMismatchOnly': 'Mismatched only',
'admin.usage.upstreamModelMatchedOnly': 'Matched only',
'admin.usage.group': 'Group',
'admin.usage.allGroups': 'All Groups',
'common.refresh': 'Refresh',
@@ -75,6 +79,7 @@ const defaultFilters = () => ({
request_type: null,
billing_type: null,
billing_mode: null,
upstream_model_mismatch: null,
group_id: null,
start_date: '',
end_date: '',
@@ -51,6 +51,11 @@ const messages: Record<string, string> = {
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'usage.requestedModel': 'Requested',
'usage.sentUpstreamModel': 'Sent upstream',
'usage.upstreamResponseModel': 'Upstream response',
'usage.modelVariant': 'Possible version variant',
'usage.modelMismatch': 'Different model',
}
vi.mock('vue-i18n', async () => {
@@ -240,6 +245,48 @@ describe('admin UsageTable tooltip', () => {
expect(text).toContain('claude-sonnet-4-20250514')
})
it.each([
{
name: 'possible version variant',
responseModel: 'gpt-5.5-2026-08-01',
expectedBadge: 'Possible version variant',
},
{
name: 'different upstream model',
responseModel: 'gpt-5.4',
expectedBadge: 'Different model',
},
])('shows a compact upstream response audit marker for $name', ({ responseModel, expectedBadge }) => {
const wrapper = mount(UsageTable, {
props: {
data: [{
request_id: `req-${responseModel}`,
model: 'gpt-5.6-sol',
upstream_model: 'gpt-5.5',
model_mapping_chain: 'gpt-5.6-sol→gpt-5.5',
upstream_response_model: responseModel,
upstream_model_mismatch: true,
}],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
const text = wrapper.text()
expect(text).toContain('gpt-5.6-sol')
expect(text).toContain('gpt-5.5')
expect(text).toContain(responseModel)
expect(text).toContain(expectedBadge)
})
it.each([
{
name: 'defaulted row',
@@ -28,7 +28,7 @@
</div>
<!-- Body -->
<div class="modal-body">
<div ref="modalBodyRef" class="modal-body">
<slot></slot>
</div>
@@ -52,6 +52,7 @@ const dialogId = `modal-title-${++dialogIdCounter}`
// 焦点管理
const dialogRef = ref<HTMLElement | null>(null)
const modalBodyRef = ref<HTMLElement | null>(null)
let previousActiveElement: HTMLElement | null = null
type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
@@ -123,6 +124,9 @@ watch(
// 等待DOM更新后设置焦点到对话框
await nextTick()
if (modalBodyRef.value) {
modalBodyRef.value.scrollTop = 0
}
if (dialogRef.value) {
const firstFocusable = dialogRef.value.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+16 -7
View File
@@ -50,7 +50,7 @@
@change="handleUpload"
/>
<Icon name="upload" size="sm" class="mr-1.5" :stroke-width="2" />
{{ uploadLabel }}
{{ resolvedUploadLabel }}
</label>
<button
v-if="modelValue"
@@ -59,7 +59,7 @@
@click="$emit('update:modelValue', '')"
>
<Icon name="trash" size="sm" class="mr-1.5" :stroke-width="2" />
{{ removeLabel }}
{{ resolvedRemoveLabel }}
</button>
</div>
<p v-if="hint" class="text-xs text-gray-500 dark:text-gray-400">{{ hint }}</p>
@@ -70,9 +70,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
const { t } = useI18n()
const props = withDefaults(defineProps<{
modelValue: string
mode?: 'image' | 'svg'
@@ -84,8 +87,8 @@ const props = withDefaults(defineProps<{
}>(), {
mode: 'image',
size: 'md',
uploadLabel: 'Upload',
removeLabel: 'Remove',
uploadLabel: '',
removeLabel: '',
hint: '',
maxSize: 300 * 1024,
})
@@ -96,6 +99,9 @@ const emit = defineEmits<{
const error = ref('')
const resolvedUploadLabel = computed(() => props.uploadLabel || t('common.upload'))
const resolvedRemoveLabel = computed(() => props.removeLabel || t('common.remove'))
const acceptTypes = computed(() => props.mode === 'svg' ? '.svg' : 'image/*')
const sanitizedValue = computed(() =>
@@ -114,7 +120,10 @@ function handleUpload(event: Event) {
if (!file) return
if (props.maxSize && file.size > props.maxSize) {
error.value = `File too large (${(file.size / 1024).toFixed(1)} KB), max ${(props.maxSize / 1024).toFixed(0)} KB`
error.value = t('common.fileTooLargeKb', {
size: (file.size / 1024).toFixed(1),
max: (props.maxSize / 1024).toFixed(0)
})
input.value = ''
return
}
@@ -128,7 +137,7 @@ function handleUpload(event: Event) {
reader.readAsText(file)
} else {
if (!file.type.startsWith('image/')) {
error.value = 'Please select an image file'
error.value = t('common.selectImageFile')
input.value = ''
return
}
@@ -139,7 +148,7 @@ function handleUpload(event: Event) {
}
reader.onerror = () => {
error.value = 'Failed to read file'
error.value = t('common.fileReadFailed')
}
input.value = ''
}
@@ -139,6 +139,8 @@ const planLabel = computed(() => {
return 'SuperGrok'
case 'supergrokheavy':
return 'SuperGrok Heavy'
case 'heavy':
return 'Heavy'
case 'abnormal':
return t('admin.accounts.subscriptionAbnormal')
default:
@@ -153,9 +155,12 @@ const isGrokFreePlan = computed(() =>
const planIconName = computed<'bolt' | null>(() => {
if (props.platform !== 'grok') return null
// Paid Grok tiers (SuperGrok / Heavy) share the bolt mark; free uses GrokFreeIcon.
if (
normalizedPlanType.value === 'supergrok' ||
normalizedPlanType.value === 'supergrokheavy'
normalizedPlanType.value === 'supergrokheavy' ||
normalizedPlanType.value === 'heavy' ||
normalizedPlanType.value.includes('heavy')
) {
return 'bolt'
}
@@ -198,6 +203,32 @@ const planBadgeClass = computed(() => {
if (normalizedPlanType.value === 'abnormal') {
return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
}
// Free stays muted gray; paid Grok tiers get distinct colors.
if (normalizedPlanType.value === 'free' || normalizedPlanType.value === 'basic') {
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}
if (props.platform === 'grok' && normalizedPlanType.value) {
// Heavy / SuperGrok Heavy → purple
if (normalizedPlanType.value.includes('heavy')) {
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-300'
}
// SuperGrok → cyan
if (normalizedPlanType.value.includes('supergrok')) {
return 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300'
}
// Any other non-free Grok plan (future tiers) → amber so it still stands out
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
}
// OpenAI / other paid plan labels: keep readable distinction from free gray
if (normalizedPlanType.value === 'plus') {
return 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300'
}
if (normalizedPlanType.value === 'team') {
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
}
if (normalizedPlanType.value === 'pro' || normalizedPlanType.value === 'chatgptpro') {
return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300'
}
return typeClass.value
})
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import BaseDialog from '../BaseDialog.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
describe('BaseDialog', () => {
afterEach(() => {
document.body.innerHTML = ''
document.body.classList.remove('modal-open')
})
it('resets body scroll position when reopened', async () => {
const wrapper = mount(BaseDialog, {
attachTo: document.body,
props: { show: false, title: 'Details' },
slots: { default: '<div style="height: 2000px">content</div>' },
global: { stubs: { Icon: true } }
})
await wrapper.setProps({ show: true })
await nextTick()
const body = document.body.querySelector<HTMLElement>('.modal-body')
expect(body).not.toBeNull()
body!.scrollTop = 480
await wrapper.setProps({ show: false })
await wrapper.setProps({ show: true })
await nextTick()
expect(document.body.querySelector<HTMLElement>('.modal-body')?.scrollTop).toBe(0)
wrapper.unmount()
})
})
@@ -46,6 +46,8 @@ describe('PlatformTypeBadge Grok plans', () => {
expect(wrapper.text()).toContain('SuperGrok Heavy')
expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="grok-free-plan-icon"]').exists()).toBe(false)
// Heavy uses purple plan chip
expect(wrapper.html()).toContain('bg-purple-100')
await wrapper.setProps({ platform: 'openai', planType: 'free' })
expect(wrapper.text()).toContain('Free')
@@ -53,6 +55,29 @@ describe('PlatformTypeBadge Grok plans', () => {
expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(false)
})
it('colors free gray, SuperGrok cyan, and Heavy purple', async () => {
const free = mount(PlatformTypeBadge, {
props: { platform: 'grok', type: 'oauth', planType: 'free' },
})
expect(free.html()).toContain('bg-gray-100')
expect(free.html()).not.toContain('bg-purple-100')
expect(free.html()).not.toContain('bg-cyan-100')
const superGrok = mount(PlatformTypeBadge, {
props: { platform: 'grok', type: 'oauth', planType: 'supergrok' },
})
expect(superGrok.text()).toContain('SuperGrok')
expect(superGrok.html()).toContain('bg-cyan-100')
expect(superGrok.find('[data-testid="grok-plan-icon"]').exists()).toBe(true)
const heavy = mount(PlatformTypeBadge, {
props: { platform: 'grok', type: 'oauth', planType: 'Heavy' },
})
expect(heavy.text()).toContain('Heavy')
expect(heavy.html()).toContain('bg-purple-100')
expect(heavy.find('[data-testid="grok-plan-icon"]').exists()).toBe(true)
})
it('uses a dedicated 12px currentColor Grok mark with a Free sparkle', () => {
const wrapper = mount(GrokFreeIcon)
+216 -50
View File
@@ -452,9 +452,14 @@ const platformNote = computed(() => {
? t('keys.useKeyModal.grok.codexNoteWindows')
: t('keys.useKeyModal.grok.codexNote')
}
return activeTab.value === 'windows'
? t('keys.useKeyModal.grok.noteWindows')
: t('keys.useKeyModal.grok.note')
// Grok CLI: shell-specific path guidance (env + ~/.grok/config.toml).
if (activeClientTab.value === 'grok' && (activeTab.value === 'cmd' || activeTab.value === 'powershell')) {
return t('keys.useKeyModal.grok.noteWindows')
}
if (activeClientTab.value === 'grok' && activeTab.value === 'windows') {
return t('keys.useKeyModal.grok.noteWindows')
}
return t('keys.useKeyModal.grok.note')
default:
return t('keys.useKeyModal.note')
}
@@ -754,62 +759,216 @@ http_headers = { "x-openai-actor-authorization" = "local-image-extension" }`
return 'requires_openai_auth = true'
}
function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.grok' : '~/.grok'
const configContent = `[models]
default = "grok"
web_search = "grok"
function joinConfigPath(dir: string, file: string, windows: boolean): string {
if (!windows) return `${dir}/${file}`
return `${dir}\\${file}`
}
[model."grok"]
model = "grok-4.5"
base_url = "${baseUrl}"
name = "Grok 4.5"
api_key = "${apiKey}"
function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] {
// Prefer unix/cmd/powershell when shell tabs are shown; fall back to windows tab.
const shell = activeTab.value
const isWindowsPath = shell === 'windows' || shell === 'cmd' || shell === 'powershell'
const configDir = isWindowsPath ? '%userprofile%\\.grok' : '~/.grok'
let envPath: string
let envContent: string
switch (shell) {
case 'cmd':
envPath = 'Command Prompt'
envContent = `set GROK_MODELS_BASE_URL=${baseUrl}
set XAI_API_KEY=${apiKey}`
break
case 'powershell':
case 'windows':
envPath = 'PowerShell'
envContent = `$env:GROK_MODELS_BASE_URL="${baseUrl}"
$env:XAI_API_KEY="${apiKey}"`
break
default:
envPath = 'Terminal'
envContent = `export GROK_MODELS_BASE_URL="${baseUrl}"
export XAI_API_KEY="${apiKey}"`
}
// Shape follows Grok Build user guide (~/.grok/docs + custom-models) and production-ready Sub2API setups.
// Text models only (Responses). Image/video: Imagine model IDs on media endpoints / feature overrides.
// Credential order: api_key field → env_key → signed-in session → XAI_API_KEY global fallback.
const modelsListUrl = `${baseUrl.replace(/\/+$/, '')}/models`
const configContent = `# Grok Build CLI → Sub2API Grok group (API key auth).
# Docs: ~/.grok/docs/user-guide/05-configuration.md + 11-custom-models.md
# Verify after save: grok inspect
#
# IMPORTANT: api_backend must be "responses" for Sub2API Grok (POST /v1/responses).
# If omitted, Grok Build defaults to chat_completions (/v1/chat/completions).
# Keep api_backend = "responses" on every model entry.
#
# Prefer env_key over hardcoding api_key (never commit secrets).
# Also export GROK_MODELS_BASE_URL + XAI_API_KEY in the shell block above.
# Global inference / catalog endpoints (same role as env GROK_MODELS_BASE_URL).
# When models_base_url is set, Grok uses API-key Bearer auth (no grok login required).
[endpoints]
models_base_url = "${baseUrl}" # inference base; model list defaults to {base}/models
models_list_url = "${modelsListUrl}" # optional override (env: GROK_MODELS_LIST_URL)
xai_api_base_url = "${baseUrl}" # public xAI API base override for gateway routing
cli_chat_proxy_base_url = "${baseUrl}" # CLI chat-proxy base (env: GROK_CLI_CHAT_PROXY_BASE_URL)
# Prefer API key when using a custom gateway (matches Sub2API).
# Requires XAI_API_KEY env or per-model env_key / api_key.
[auth]
preferred_method = "api_key"
[model."grok-4.5"]
model = "grok-4.5" # id sent to the API
name = "Grok 4.5" # shown in /model picker
description = "Grok 4.5 via Sub2API (Responses)"
# base_url inherits from [endpoints].models_base_url; override only if needed:
# base_url = "${baseUrl}"
env_key = "XAI_API_KEY" # or: api_key = "${apiKey}" (not recommended)
api_backend = "responses" # chat_completions | responses | messages
context_window = 500000 # drives auto-compaction timing
# Optional sampling (global defaults can live under [models] instead):
# temperature = 0.7
# top_p = 0.95
# max_completion_tokens = 8192
# Server-side (backend) web_search tools — only if your gateway exposes them:
supports_backend_search = true
[model."grok-build-0.1"]
model = "grok-build-0.1"
name = "Grok Build"
description = "Coding / agent sessions (xAI recommends grok-build* for coding)"
env_key = "XAI_API_KEY"
api_backend = "responses"
context_window = 256000
supports_backend_search = true
# Text multi-agent / client web_search sub-agent (NOT Imagine image/video).
[model."grok-4.20-multi-agent-0309"]
model = "grok-4.20-multi-agent-0309"
name = "Grok 4.20 Multi Agent (text / web_search)"
description = "Text multi-agent; use for web_search sub-agent, not image/video"
env_key = "XAI_API_KEY"
api_backend = "responses"
context_window = 1000000
supports_backend_search = true`
supports_backend_search = true
return [{
path: `${configDir}/config.toml`,
content: configContent,
hint: t('keys.useKeyModal.grok.configTomlHint')
}]
[model."grok-4.3"]
model = "grok-4.3"
name = "Grok 4.3"
env_key = "XAI_API_KEY"
api_backend = "responses"
context_window = 1000000
supports_backend_search = true
# Optional short alias for /model grok:
# [model."grok"]
# model = "grok-4.5"
# name = "Grok"
# env_key = "XAI_API_KEY"
# api_backend = "responses"
# context_window = 1000000
# supports_backend_search = true
[models]
# xAI recommends grok-build* for coding/agent sessions; use grok-4.5 for general chat.
default = "grok-4.5"
web_search = "grok-4.5" # client-side web_search tool model (must exist as [model.*])
image_description = "grok-4.5" # vision/describe-image helper model
# Optional environment-wide sampling defaults (per-model values win):
# temperature = 0.7
# top_p = 0.95
# max_completion_tokens = 8192
# max_retries = 8
[session]
auto_compact_threshold_percent = 80 # auto-compact at this % of context_window (default 85)
# Imagine tools: model IDs go to Sub2API media endpoints (not the text [model.*] catalog).
# Enable only if the Grok group allows image/video generation.
[features]
image_gen = true
video_gen = true
image_gen_model_override = "grok-imagine-image-quality" # or grok-imagine-image
image_edit_model_override = "grok-imagine-edit"
# Optional feature flags (defaults shown in docs):
# telemetry = false
# remote_fetch = true # set false for air-gapped / pure-gateway catalogs
# lsp_tools = false`
return [
{ path: envPath, content: envContent },
{
path: joinConfigPath(configDir, 'config.toml', isWindowsPath),
content: configContent,
hint: t('keys.useKeyModal.grok.configTomlHint')
}
]
}
function generateGrokCodexFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configPath = isWindows
? '%USERPROFILE%\\.codex\\config.toml'
: '~/.codex/config.toml'
const configContent = `model_provider = "sub2api_grok"
model = "grok-4.5"
review_model = "grok-4.5"
model_reasoning_effort = "xhigh"
model_context_window = 1000000
// Codex config reference: wire_api = "responses" only; prefer env_key over experimental_bearer_token.
// Non-OpenAI gateways should set supports_websockets = false (HTTP/SSE).
const shell = activeTab.value
const isWindowsPath = shell === 'windows' || shell === 'cmd' || shell === 'powershell'
const configDir = isWindowsPath ? '%userprofile%\\.codex' : '~/.codex'
[model_providers.sub2api_grok]
let envPath: string
let envContent: string
switch (shell) {
case 'cmd':
envPath = 'Command Prompt'
envContent = `set SUB2API_API_KEY=${apiKey}`
break
case 'powershell':
case 'windows':
envPath = 'PowerShell'
envContent = `$env:SUB2API_API_KEY="${apiKey}"`
break
default:
envPath = 'Terminal'
envContent = `export SUB2API_API_KEY="${apiKey}"`
}
const configContent = `# Codex CLI → Sub2API Grok group
# Docs: Codex config reference (model_providers.*, wire_api = "responses")
#
# Text models only. Image/video: grok-imagine-image / grok-imagine-video on media endpoints.
# Switch model: grok-4.5 | grok-4.3 | grok-build-0.1 | grok-4.20-multi-agent-0309 (text / web_search)
model_provider = "sub2api"
model = "grok-4.5"
# Optional:
# review_model = "grok-4.5"
# model_reasoning_effort = "medium"
# model_context_window = 500000
# disable_response_storage = true
# network_access = "enabled"
# windows_wsl_setup_acknowledged = true
[model_providers.sub2api]
name = "Sub2API Grok"
base_url = "${baseUrl}"
# Prefer env_key (variable NAME). Do not combine with experimental_bearer_token.
env_key = "SUB2API_API_KEY"
# Fallback only if you cannot set env (discouraged — keeps secret on disk):
# experimental_bearer_token = "${apiKey}"
wire_api = "responses"
supports_websockets = true
# API-key providers: do not require ChatGPT OAuth login
requires_openai_auth = false
# Grok/Sub2API path is HTTP/SSE; disable WS (Codex may otherwise try WebSocket first)
supports_websockets = false
[features]
responses_websockets_v2 = true`
const environmentContent = isWindows
? `$env:SUB2API_API_KEY="${apiKey}"`
: `export SUB2API_API_KEY="${apiKey}"`
# Optional:
# [features]
# goals = true`
return [
{ path: envPath, content: envContent },
{
path: configPath,
path: joinConfigPath(configDir, 'config.toml', isWindowsPath),
content: configContent,
hint: t('keys.useKeyModal.grok.codexConfigTomlHint')
},
{
path: isWindows ? 'PowerShell' : 'Terminal',
content: environmentContent
}
]
}
@@ -1318,22 +1477,28 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
}
}
// Align context_window with Grok Build official sample (docs.x.ai/build/settings) where known.
// Image/video: grok-imagine-image / grok-imagine-video on media endpoints — not this list.
const grokModels = {
'grok-4.5': {
name: 'Grok 4.5',
limit: { context: 1000000, output: 128000 }
},
'grok-4.3': {
name: 'Grok 4.3',
limit: { context: 1000000, output: 128000 }
limit: { context: 500000, output: 64000 }
},
'grok-build-0.1': {
name: 'Grok Build 0.1',
limit: { context: 256000, output: 128000 }
limit: { context: 256000, output: 64000 }
},
'grok-4.20-multi-agent-0309': {
name: 'Grok 4.20 Multi Agent (text / web_search)',
limit: { context: 1000000, output: 64000 }
},
'grok-4.3': {
name: 'Grok 4.3',
limit: { context: 1000000, output: 64000 }
},
'grok-composer-2.5-fast': {
name: 'Grok Composer 2.5 Fast',
limit: { context: 500000, output: 128000 }
limit: { context: 500000, output: 64000 }
}
}
@@ -1353,8 +1518,9 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
} else if (platform === 'openai') {
provider[platform].models = openaiModels
} else if (platform === 'grok') {
provider[platform].npm = '@ai-sdk/openai'
provider[platform].name = 'Grok'
// Custom provider pointing at Sub2API OpenAI-compatible Responses/Chat endpoints.
provider[platform].npm = '@ai-sdk/openai-compatible'
provider[platform].name = 'Grok via Sub2API'
provider[platform].models = grokModels
}
@@ -46,14 +46,42 @@ describe('UseKeyModal', () => {
)
expect(grokTab).toBeDefined()
const grokConfig = wrapper.findAll('pre code')
.map((code) => code.text())
.find((content) => content.includes('[model."grok"]'))
expect(grokConfig).toBeDefined()
expect(grokConfig).toContain('model = "grok-4.5"')
expect(grokConfig).toContain('base_url = "https://example.com/v1"')
expect(grokConfig).toContain('api_key = "sk-grok-test"')
expect(grokConfig).toContain('api_backend = "responses"')
const allCode = wrapper.findAll('pre code').map((code) => code.text()).join('\n')
expect(allCode).toContain('GROK_MODELS_BASE_URL')
expect(allCode).toContain('XAI_API_KEY')
expect(allCode).toContain('[model."grok-4.5"]')
expect(allCode).toContain('[model."grok-build-0.1"]')
expect(allCode).toContain('[model."grok-4.20-multi-agent-0309"]')
expect(allCode).toContain('[model."grok-4.3"]')
expect(allCode).toContain('default = "grok-4.5"')
expect(allCode).toContain('models_base_url = "https://example.com/v1"')
expect(allCode).toContain('models_list_url = "https://example.com/v1/models"')
expect(allCode).toContain('xai_api_base_url = "https://example.com/v1"')
expect(allCode).toContain('cli_chat_proxy_base_url = "https://example.com/v1"')
expect(allCode).toContain('preferred_method = "api_key"')
expect(allCode).toContain('image_description = "grok-4.5"')
expect(allCode).toContain('auto_compact_threshold_percent = 80')
expect(allCode).toContain('image_gen = true')
expect(allCode).toContain('video_gen = true')
expect(allCode).toContain('image_gen_model_override = "grok-imagine-image-quality"')
expect(allCode).toContain('image_edit_model_override = "grok-imagine-edit"')
expect(allCode).toContain('env_key = "XAI_API_KEY"')
expect(allCode).toContain('Keep api_backend = "responses" on every model entry.')
expect(allCode).toContain('grok-imagine-image')
expect(allCode).toContain('grok-imagine-edit')
expect(allCode).toMatch(/\[model\."grok-4\.5"\][\s\S]*?context_window = 500000/)
expect(allCode).toMatch(/\[model\."grok-build-0\.1"\][\s\S]*?context_window = 256000/)
// Prefer env_key; hardcode api_key only as commented alternative
expect(allCode).not.toMatch(/^api_key = "sk-grok-test"$/m)
const modelBlocks = allCode
.split(/(?=^\[model\.)/m)
.filter((block) => block.startsWith('[model."'))
expect(modelBlocks.length).toBeGreaterThanOrEqual(4)
for (const block of modelBlocks) {
if (block.includes('# [model.')) continue
expect(block).toContain('api_backend = "responses"')
}
const windowsTab = wrapper.findAll('button').find(
(button) => button.text().trim() === 'Windows'
@@ -61,7 +89,7 @@ describe('UseKeyModal', () => {
expect(windowsTab).toBeDefined()
await windowsTab!.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('%userprofile%\\.grok/config.toml')
expect(wrapper.text().toLowerCase()).toContain('%userprofile%\\.grok\\config.toml')
const opencodeTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.opencode')
@@ -71,13 +99,16 @@ describe('UseKeyModal', () => {
await nextTick()
const parsed = JSON.parse(wrapper.find('pre code').text())
expect(parsed.provider.grok.npm).toBe('@ai-sdk/openai')
expect(parsed.provider.grok.npm).toBe('@ai-sdk/openai-compatible')
expect(parsed.provider.grok.name).toBe('Grok via Sub2API')
expect(parsed.provider.grok.options).toEqual({
baseURL: 'https://example.com/v1',
apiKey: 'sk-grok-test'
})
expect(parsed.provider.grok.models['grok-4.5']).toBeDefined()
expect(parsed.provider.grok.models['grok-4.5'].limit.context).toBe(500000)
expect(parsed.provider.grok.models['grok-build-0.1']).toBeDefined()
expect(parsed.provider.grok.models['grok-4.20-multi-agent-0309']).toBeDefined()
expect(parsed.provider.grok.models['grok-composer-2.5-fast']).toBeDefined()
expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined()
})
@@ -200,23 +231,25 @@ describe('UseKeyModal', () => {
await nextTick()
let codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
const configToml = codeBlocks.find((content) => content.includes('[model_providers.sub2api_grok]'))
const configToml = codeBlocks.find((content) => content.includes('[model_providers.sub2api]'))
expect(configToml).toBeDefined()
expect(configToml).toContain('model_provider = "sub2api_grok"')
expect(configToml).toContain('model_provider = "sub2api"')
expect(configToml).toContain('model = "grok-4.5"')
expect(configToml).toContain('model_context_window = 1000000')
expect(configToml).toContain('base_url = "https://example.com/v1"')
expect(configToml).toContain('env_key = "SUB2API_API_KEY"')
expect(configToml).toContain('wire_api = "responses"')
expect(configToml).toContain('supports_websockets = true')
expect(configToml).not.toContain('requires_openai_auth')
expect(configToml).not.toContain('disable_response_storage')
expect(configToml).not.toContain('network_access')
expect(configToml).not.toContain('windows_wsl_setup_acknowledged')
expect(configToml).toContain('[features]\nresponses_websockets_v2 = true')
expect(configToml).not.toContain('goals = true')
expect(codeBlocks).toContain('export SUB2API_API_KEY="sk-grok-codex-test"')
// API-key provider: Codex must not require a ChatGPT OAuth login.
expect(configToml).toContain('requires_openai_auth = false')
expect(configToml).toContain('supports_websockets = false')
expect(configToml).toContain('grok-4.20-multi-agent-0309 (text / web_search)')
expect(configToml).toContain('grok-imagine-image')
expect(configToml).toContain('grok-imagine-video')
// Hardcoded bearer is only a commented fallback when env cannot be set.
expect(configToml).toMatch(/# experimental_bearer_token = "sk-grok-codex-test"/)
expect(configToml).not.toContain('supports_websockets = true')
expect(configToml).not.toContain('responses_websockets_v2')
expect(wrapper.text()).not.toContain('auth.json')
expect(codeBlocks.join('\n')).toContain('SUB2API_API_KEY')
const windowsTab = wrapper.findAll('button').find(
(button) => button.text().trim() === 'Windows'
@@ -226,8 +259,8 @@ describe('UseKeyModal', () => {
await nextTick()
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
expect(wrapper.text()).toContain('%USERPROFILE%\\.codex\\config.toml')
expect(codeBlocks).toContain('$env:SUB2API_API_KEY="sk-grok-codex-test"')
expect(wrapper.text().toLowerCase()).toContain('%userprofile%\\.codex\\config.toml'.toLowerCase())
expect(codeBlocks.join('\n')).toContain('experimental_bearer_token = "sk-grok-codex-test"')
})
it('keeps legacy OpenAI Codex config as the default', () => {
@@ -59,13 +59,22 @@
<tbody>
<tr
v-for="m in sortedModels"
:key="m.name"
:key="`${m.platform}:${m.name}`"
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
>
<!-- 模型名 + 非 token 计费模式徽章 -->
<td class="border-r border-gray-100 py-2.5 pl-5 pr-4 align-middle dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<span
v-if="platform && m.platform !== platform"
:class="[
'inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium',
platformBadgeLightClass(m.platform)
]"
>
{{ platformLabel(m.platform) }}
</span>
<span
v-if="billingMode(m) !== BILLING_MODE_TOKEN"
class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
@@ -203,7 +212,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatScaled } from '@/utils/pricing'
import { platformAccentColor } from '@/utils/platformColors'
import { platformAccentColor, platformBadgeLightClass, platformLabel } from '@/utils/platformColors'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_IMAGE,
@@ -383,4 +383,25 @@ describe('PlazaModelPricingTable', () => {
// 旧 bug:image_output_price × 0.1 = 0.000003 被当按次价
expect(text).not.toContain('$0.000003')
})
it('Composite 分组中相同模型名按具体平台分别展示徽章', () => {
const anthropic = tokenModel({ name: 'shared-model', platform: 'anthropic' })
const openai = tokenModel({ name: 'shared-model', platform: 'openai' })
const wrapper = mount(PlazaModelPricingTable, {
props: {
models: [anthropic, openai],
platform: 'composite',
rateMultiplier: 1
}
})
const rows = wrapper.findAll('tbody tr')
expect(rows).toHaveLength(2)
expect(rows.map((row) => row.find('td').text())).toEqual([
'shared-modelAnthropic',
'shared-modelOpenAI'
])
expect(wrapper.text()).toContain('Anthropic')
expect(wrapper.text()).toContain('OpenAI')
})
})