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
+17 -1
View File
@@ -8,7 +8,7 @@ vi.mock('@/api/client', () => ({
apiClient: { post },
}))
import { createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
import { authorizePassword, createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
describe('admin Grok SSO import API', () => {
beforeEach(() => {
@@ -34,4 +34,20 @@ describe('admin Grok SSO import API', () => {
{ timeout: expectedTimeout },
)
})
it('preserves password whitespace and applies the authorization timeout', async () => {
post.mockResolvedValueOnce({ data: { access_token: 'access-token' } })
await authorizePassword(' user@example.com ---- password with spaces ', 7)
expect(post).toHaveBeenCalledWith(
'/admin/grok/oauth/password',
{
email: 'user@example.com',
password: ' password with spaces ',
proxy_id: 7,
},
{ timeout: 120_000 },
)
})
})
@@ -41,7 +41,11 @@ describe('admin system rollback API', () => {
const result = await rollback('0.1.146')
expect(post).toHaveBeenCalledWith('/admin/system/rollback', { version: '0.1.146' })
expect(post).toHaveBeenCalledWith(
'/admin/system/rollback',
{ version: '0.1.146' },
{ timeout: 15 * 60 * 1000 }
)
expect(result.need_restart).toBe(true)
})
@@ -50,6 +54,10 @@ describe('admin system rollback API', () => {
await rollback()
expect(post).toHaveBeenCalledWith('/admin/system/rollback', undefined)
expect(post).toHaveBeenCalledWith(
'/admin/system/rollback',
undefined,
{ timeout: 15 * 60 * 1000 }
)
})
})
+14
View File
@@ -317,6 +317,19 @@ export async function getUsage(id: number, source?: 'passive' | 'active', force?
return data
}
export interface BatchAccountUsageResponse {
usage: Record<string, AccountUsageInfo>
errors: Record<string, string>
}
export async function getBatchUsage(accountIds: number[], force?: boolean): Promise<BatchAccountUsageResponse> {
const { data } = await apiClient.post<BatchAccountUsageResponse>('/admin/accounts/usage/batch', {
account_ids: accountIds,
force: force === true
})
return data
}
/**
* Clear account rate limit status
* @param id - Account ID
@@ -986,6 +999,7 @@ export const accountsAPI = {
getStats,
clearError,
getUsage,
getBatchUsage,
getTodayStats,
getBatchTodayStats,
clearRateLimit,
+3
View File
@@ -56,6 +56,7 @@ export interface TrendParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface TrendResponse {
@@ -87,6 +88,7 @@ export interface ModelStatsParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface ModelStatsResponse {
@@ -115,6 +117,7 @@ export interface GroupStatsParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface GroupStatsResponse {
+56 -1
View File
@@ -19,6 +19,17 @@ export interface GrokAuthUrlRequest {
redirect_uri?: string
}
export interface GrokOAuthCapabilities {
password_auth_enabled: boolean
}
const GROK_AUTHORIZATION_TIMEOUT_MS = 120_000
export async function getCapabilities(): Promise<GrokOAuthCapabilities> {
const { data } = await apiClient.get<GrokOAuthCapabilities>('/admin/grok/oauth/capabilities')
return data
}
export interface GrokExchangeCodeRequest {
session_id: string
state: string
@@ -170,4 +181,48 @@ export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise<Gro
return data
}
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota, createFromSSO }
/** Validate a browser SSO cookie and convert to Build OAuth tokens (no raw SSO stored). */
export async function validateSSOToken(
ssoToken: string,
proxyId?: number | null
): Promise<GrokTokenInfo> {
const payload: Record<string, unknown> = { sso_token: ssoToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/sso-token', payload, {
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
})
return data
}
/**
* Password login → ephemeral SSO → Build OAuth.
* Password is only sent over the wire for this call; never persist it in credentials.
*/
export async function authorizePassword(
emailAndPassword: string,
proxyId?: number | null
): Promise<GrokTokenInfo> {
// Format: email----password (password may contain dashes).
const sep = '----'
const idx = emailAndPassword.indexOf(sep)
const email = (idx >= 0 ? emailAndPassword.slice(0, idx) : emailAndPassword).trim()
const password = idx >= 0 ? emailAndPassword.slice(idx + sep.length) : ''
const payload: Record<string, unknown> = { email, password }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/password', payload, {
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
})
return data
}
export default {
generateAuthUrl,
getCapabilities,
exchangeCode,
refreshGrokToken,
queryQuota,
resetQuota,
createFromSSO,
validateSSOToken,
authorizePassword,
}
+42
View File
@@ -32,6 +32,38 @@ export type DefaultPlatformQuotasMap = Partial<Record<PlatformType, PlatformQuot
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok"]
export type SchedulingThresholdPlatformType =
| "openai"
| "anthropic"
| "grok"
export type AccountSchedulingThresholdsMap = Record<SchedulingThresholdPlatformType, number>
export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [
"openai",
"anthropic",
"grok",
]
export function normalizeAccountSchedulingThresholdsMap(
input?: Partial<Record<SchedulingThresholdPlatformType, number>> | null,
): AccountSchedulingThresholdsMap {
const result = {} as AccountSchedulingThresholdsMap
for (const platform of SCHEDULING_THRESHOLD_PLATFORMS) {
const value = input?.[platform]
result[platform] = typeof value === "number" && Number.isFinite(value)
? Math.min(100, Math.max(1, Math.trunc(value)))
: 100
}
return result
}
export function sanitizeAccountSchedulingThresholdsMap(
input?: Partial<Record<SchedulingThresholdPlatformType, number>> | null,
): AccountSchedulingThresholdsMap {
return normalizeAccountSchedulingThresholdsMap(input)
}
/** 归一化为全 4 平台 × 3 窗口(缺失填 null),供模板非空绑定 */
export function normalizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
const result: DefaultPlatformQuotasMap = {}
@@ -556,6 +588,12 @@ export interface SystemSettings {
fallback_model_openai: string;
fallback_model_gemini: string;
fallback_model_antigravity: string;
grok_default_text_model: string;
grok_cross_client_model_map_enabled: boolean;
grok_default_base_url_mode: string;
// Per-platform account auto-pause thresholds (100 = disabled)
account_scheduling_thresholds: AccountSchedulingThresholdsMap;
// Identity patch configuration (Claude -> Gemini)
enable_identity_patch: boolean;
@@ -874,6 +912,10 @@ export interface UpdateSettingsRequest {
fallback_model_openai?: string;
fallback_model_gemini?: string;
fallback_model_antigravity?: string;
grok_default_text_model?: string;
grok_cross_client_model_map_enabled?: boolean;
grok_default_base_url_mode?: string;
account_scheduling_thresholds?: AccountSchedulingThresholdsMap;
enable_identity_patch?: boolean;
identity_patch_prompt?: string;
ops_monitoring_enabled?: boolean;
+2
View File
@@ -84,6 +84,7 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number
exact_total?: boolean
billing_mode?: string
upstream_model_mismatch?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
@@ -123,6 +124,7 @@ export async function getStats(params: {
model?: string
request_type?: UsageRequestType
stream?: boolean
upstream_model_mismatch?: boolean
period?: string
start_date?: string
end_date?: string
@@ -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')
})
})
@@ -55,7 +55,7 @@ describe('useGrokOAuth.exchangeAuthCode', () => {
})
describe('useGrokOAuth.buildCredentials', () => {
it('persists the Grok CLI subscription proxy for OAuth inference', () => {
it('builds OAuth credentials without forcing base_url or leaking sso/password', () => {
const oauth = useGrokOAuth()
const credentials = oauth.buildCredentials({
@@ -64,9 +64,20 @@ describe('useGrokOAuth.buildCredentials', () => {
expires_at: 1_900_000_000,
client_id: 'client-id',
scope: 'openid grok-cli:access',
email: 'grok@example.com'
})
email: 'grok@example.com',
password: 'super-secret',
sso_token: 'sso-cookie',
sso: 'sso-cookie',
'sso-rw': 'sso-cookie'
} as any)
expect(credentials.base_url).toBe('https://cli-chat-proxy.grok.com/v1')
expect(credentials.access_token).toBe('access-token')
expect(credentials.email).toBe('grok@example.com')
// System/CLI mode chooses the correct host; do not pin public API URL.
expect(credentials.base_url).toBeUndefined()
expect(credentials).not.toHaveProperty('password')
expect(credentials).not.toHaveProperty('sso_token')
expect(credentials).not.toHaveProperty('sso')
expect(credentials).not.toHaveProperty('sso-rw')
})
})
@@ -49,6 +49,7 @@ describe('useModelWhitelist', () => {
expect(models).toContain('grok-4.5')
expect(models).toContain('grok-4.5-latest')
expect(models).toContain('grok-build-latest')
expect(models).toContain('grok-imagine-video-1.5-preview')
})
it('combined 模式支持 Grok 4.5 官方别名映射', () => {
@@ -74,7 +75,7 @@ describe('useModelWhitelist', () => {
const models = getModelsByPlatform('grok')
expect(models).toContain('grok-composer-2.5-fast')
expect(models).toContain('grok-composer')
expect(models).not.toContain('grok-composer')
expect(models).toContain('composer-2.5')
})
+12 -1
View File
@@ -3,7 +3,18 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
export type AddMethod = 'oauth' | 'setup-token'
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'agent_identity' | 'codex_pat' | 'sso_cookie'
export type AuthInputMethod =
| 'manual'
| 'cookie'
| 'refresh_token'
| 'mobile_refresh_token'
| 'session_token'
| 'access_token'
| 'codex_session'
| 'agent_identity'
| 'codex_pat'
| 'sso_cookie'
| 'email_password'
export interface OAuthState {
authUrl: string
+63 -2
View File
@@ -113,6 +113,8 @@ export function useGrokOAuth() {
}
}
// Build account credentials for create/re-auth. Never persist raw SSO cookies
// or passwords: those exist only for the one-shot authorize API call.
const buildCredentials = (tokenInfo: GrokTokenInfo): Record<string, unknown> => {
const credentials: Record<string, unknown> = {
access_token: tokenInfo.access_token,
@@ -125,11 +127,16 @@ export function useGrokOAuth() {
team_id: tokenInfo.team_id,
subscription_tier: tokenInfo.subscription_tier,
entitlement_status: tokenInfo.entitlement_status,
base_url: 'https://cli-chat-proxy.grok.com/v1'
// Leave base_url unset so system/CLI mode can choose the correct host.
}
if (tokenInfo.refresh_token) credentials.refresh_token = tokenInfo.refresh_token
if (tokenInfo.id_token) credentials.id_token = tokenInfo.id_token
return Object.fromEntries(Object.entries(credentials).filter(([, value]) => value !== undefined && value !== ''))
const blocked = new Set(['sso_token', 'password', 'sso', 'sso-rw'])
return Object.fromEntries(
Object.entries(credentials).filter(
([key, value]) => !blocked.has(key) && value !== undefined && value !== ''
)
)
}
const buildExtraInfo = (tokenInfo: GrokTokenInfo): Record<string, unknown> => {
@@ -140,6 +147,58 @@ export function useGrokOAuth() {
return extra
}
const validateSSOToken = async (
ssoToken: string,
proxyId?: number | null
): Promise<GrokTokenInfo | null> => {
if (!ssoToken.trim()) {
error.value = t('admin.accounts.oauth.grok.pleaseEnterSSOToken', 'Please enter an SSO token')
return null
}
loading.value = true
error.value = ''
try {
return await adminAPI.grok.validateSSOToken(ssoToken.trim(), proxyId)
} catch (err: any) {
error.value = extractI18nErrorMessage(
err,
t,
'admin.accounts.oauth.grok.errors',
t('admin.accounts.oauth.grok.failedToValidateSSO', 'Failed to validate SSO token')
)
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
const authorizePassword = async (
emailAndPassword: string,
proxyId?: number | null
): Promise<GrokTokenInfo | null> => {
if (!emailAndPassword.trim()) {
error.value = t('admin.accounts.oauth.grok.pleaseEnterPassword', 'Please enter email----password')
return null
}
loading.value = true
error.value = ''
try {
return await adminAPI.grok.authorizePassword(emailAndPassword, proxyId)
} catch (err: any) {
error.value = extractI18nErrorMessage(
err,
t,
'admin.accounts.oauth.grok.errors',
t('admin.accounts.oauth.grok.failedToAuthorizePassword', 'Password authorization failed')
)
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
return {
authUrl,
sessionId,
@@ -150,6 +209,8 @@ export function useGrokOAuth() {
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
validateSSOToken,
authorizePassword,
buildCredentials,
buildExtraInfo
}
@@ -143,20 +143,20 @@ const xaiModels = [
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309',
'grok',
'grok-4.20-multi-agent',
'grok-4.20-multi-agent-latest',
'grok-4.3-latest',
'grok-latest',
'grok-4.5-latest',
'grok-build',
'grok-build-latest',
'grok-composer',
'composer-2.5',
'grok-4.20-reasoning',
'grok-4.20-non-reasoning',
'grok-imagine',
'grok-imagine-image-quality',
'grok-imagine-image',
'grok-imagine-edit',
'grok-imagine-video',
'grok-imagine-video-1.5-preview',
'grok-imagine-video-1.5'
]
@@ -314,7 +314,7 @@ const grokPresetMappings = [
{ label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' },
{ label: 'Imagine Image', from: 'grok-imagine', to: 'grok-imagine-image-quality', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: 'Imagine Edit', from: 'grok-imagine-edit', to: 'grok-imagine-edit', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Imagine Edit', from: 'grok-imagine-edit', to: 'grok-imagine-image-quality', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Imagine Video', from: 'grok-imagine-video-1.5', to: 'grok-imagine-video-1.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
]
+92 -3
View File
@@ -131,6 +131,10 @@ export default {
rateLimited: 'Rate Limited',
overloaded: 'Overloaded',
tempUnschedulable: 'Temp Unschedulable',
accountSchedulingThresholdOverride: 'Account Auto-Pause Threshold Override',
accountSchedulingThresholdOverrideHint: 'Override the platform auto-pause threshold for this account only. Disable to use platform settings.',
accountSchedulingThresholdOverrideValue: 'Account threshold percent',
accountSchedulingThresholdOverrideDisabledHint: 'Use 1-100. The account becomes temporarily unschedulable after reaching this usage percent; 100 disables it for this account.',
quotaExceeded: 'Quota Exceeded',
unschedulable: 'Unschedulable',
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
@@ -140,7 +144,8 @@ export default {
creditsExhausted: 'Credits Exhausted',
creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}',
overloadedUntil: 'Overloaded until {time}',
viewTempUnschedDetails: 'View temp unschedulable details'
viewTempUnschedDetails: 'View temp unschedulable details',
tempUnschedulableUntil: 'Resumes {time}'
},
columns: {
name: 'Name',
@@ -304,6 +309,10 @@ export default {
notice: 'Rules are evaluated in order and require both error code and keyword match.',
addRule: 'Add Rule',
ruleOrder: 'Rule Order',
multipleErrorTrigger: '{count} matching errors in {minutes} minutes reached the trigger threshold ({threshold}).',
multipleErrorTriggerNoWindow: '{count} matching errors reached the trigger threshold ({threshold}).',
multipleErrorCountInWindow: '{count} matching errors occurred within {minutes} minutes.',
multipleErrorCount: '{count} matching errors contributed to this block.',
ruleIndex: 'Rule #{index}',
errorCode: 'Error Code',
errorCodePlaceholder: 'e.g. 429',
@@ -598,7 +607,55 @@ export default {
},
grok: {
baseUrlHint: 'Grok OAuth accounts forward to the official xAI API base URL.',
apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.'
apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.',
// Account connectivity test modal
testMode: 'Test mode',
testModeHint:
'Text / image / video use the selected model. Web search, TTS, STT and Realtime hit standalone endpoints (not free-form chat tools).',
testModeText: 'Text (Responses)',
testModeImage: 'Image (/images/generations)',
testModeVideo: 'Video (/videos/generations)',
testModeSearch: 'Web search (/web_search)',
testModeTTS: 'TTS (/tts)',
testModeSTT: 'STT (/stt)',
testModeRealtime: 'Realtime (WS /realtime)',
textTestMode: 'Mode: Text (Responses)',
searchTestMode: 'Mode: Web search (/web_search)',
ttsTestMode: 'Mode: TTS (/tts)',
sttTestMode: 'Mode: STT (/stt)',
realtimeTestMode: 'Mode: Realtime (WS /realtime)',
searchQueryLabel: 'Search query',
searchQueryPlaceholder: 'Example: xAI Grok',
searchQueryDefault: 'xAI Grok',
searchTestHint:
'Standalone web_search probe (same as gateway /v1/web_search). Not a free-form chat with tools.',
ttsTextLabel: 'TTS text',
ttsTextPlaceholder: 'Example: Hello from Sub2API connectivity test.',
ttsTextDefault: 'Hello from Sub2API account connectivity test.',
ttsTestHint: 'Standalone /v1/tts with language=en; success reports audio byte size.',
sttTestHint: 'Standalone /v1/stt with a synthetic silent WAV; success means the endpoint is reachable.',
realtimeTestHint:
'Standalone WebSocket dial to /v1/realtime (model=grok-voice-latest). Handshake success = connectivity OK; may also show the first server event.',
sendingSearchRequest: 'Sending standalone web_search request...',
sendingTTSRequest: 'Sending standalone /tts request...',
sendingSTTRequest: 'Sending standalone /stt request...',
sendingRealtimeRequest: 'Dialing standalone /realtime WebSocket...',
selectedTestMode: 'Test mode: {mode}',
imageUploadLabel: 'Source image (optional, for edits)',
videoFirstFrameLabel: 'First-frame / reference image (optional)',
imageUploadHint:
'PNG/JPEG recommended, both sides ≥ 8 px, under ~4 MB for edits. Uploading a source image switches to /images/edits (image-to-image). Leave empty for text-to-image /images/generations.',
videoFirstFrameHint:
'Optional first-frame / reference image for image-to-video. PNG/JPEG recommended, both sides ≥ 8 px.',
audioUploadLabel: 'Audio file (optional for STT)',
audioUploadHint:
'Upload a real audio clip to transcribe. Without a file, a silent WAV is used for connectivity only.',
mediaTooLarge: 'File is too large (max ~6 MB for admin test uploads).',
chooseImageFile: 'Choose image',
chooseAudioFile: 'Choose audio',
uploadPreviewAlt: 'Upload preview',
fileReadFailed: 'Failed to read the selected file',
noResponseBody: 'No response body from server'
},
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
@@ -1009,6 +1066,17 @@ export default {
ssoCookieLabel: 'Grok Web SSO Key',
ssoCookiePlaceholder: 'One SSO key per line\nSupports multiple, one per line',
ssoCookieHint: 'One SSO key per line. Multiple keys are imported with 3-way concurrency; expect about 90 seconds per batch. Use a matching-region proxy if needed.',
emailPasswordAuth: 'Email + password',
emailPasswordDesc:
'Sign in with a Grok web email and password. The server uses the password only to obtain an ephemeral SSO cookie, then converts it to Build OAuth credentials. Neither the password nor raw SSO is stored on the account.',
emailPasswordInputLabel: 'email----password',
emailPasswordPlaceholder: "user{'@'}example.com----your-password\nMultiple lines supported",
emailPasswordHint:
'Format: email----password (password may contain -). Requires YesCaptcha keys; use a matching-region proxy when needed.',
pleaseEnterPassword: 'Please enter email----password (one per line)',
pleaseEnterSSOToken: 'Please enter an SSO token',
failedToValidateSSO: 'Failed to validate Grok SSO',
failedToAuthorizePassword: 'Grok password authorization failed',
convertingSSO: 'Converting...',
convertSSOAndCreate: 'Convert & Create Account',
validating: 'Validating...',
@@ -1272,6 +1340,9 @@ export default {
reAuthorizedSuccess: 'Account re-authorized successfully',
// Test Modal
testAccountConnection: 'Test Account Connection',
errorPrefix: 'Error: {message}',
imagePreviewAlt: 'Test image {index}',
imageLightboxAlt: 'Image preview',
account: 'Account',
readyToTest: 'Ready to test. Click "Start Test" to begin...',
connectingToApi: 'Connecting to API...',
@@ -1295,10 +1366,22 @@ export default {
imagePromptLabel: 'Image prompt',
imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.',
imageTestHint:
'Calls standalone /v1/images/generations and shows the returned image below.',
imageTestMode: 'Mode: Image generation test',
videoPromptLabel: 'Video prompt',
videoPromptPlaceholder: 'Example: A red ball bouncing once on a white floor, short simple motion.',
videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.',
videoTestHint:
'Calls standalone /v1/videos/generations, polls until done, then downloads the finished video for on-page preview.',
videoTestMode: 'Mode: Video generation test',
sendingVideoRequest: 'Sending video generation request...',
imagePreview: 'Generated images:',
imageReceived: 'Received test image #{count}',
audioPreview: 'Generated audio:',
audioReceived: 'Received test audio #{count}',
videoPreview: 'Generated video:',
videoReceived: 'Received test video #{count}',
// Stats Modal
viewStats: 'View Stats',
usageStatistics: 'Usage Statistics',
@@ -1346,6 +1429,12 @@ export default {
grokTokens: 'Tok',
grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window ({limit} limit)',
grokWeeklyUsage: 'Weekly {percent}%',
grokUsed: 'Used $',
grokBalance: 'Bal $',
grokPrepaid: 'Prepaid balance',
grokMonthlyLimit: 'Monthly used / limit (USD)',
grokOverage: 'Overage onDemandUsed/onDemandCap',
grokOverageShort: 'OD $',
grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.',
grokRetryAfter: 'Retry after {time}',
grokProbe: 'Probe',
@@ -985,6 +985,8 @@ export default {
title: 'Video Generation Pricing',
description:
'Configure Grok video generation prices in USD per second of output video. Leave empty to use the default per-second rates (grok-imagine-video: $0.05/s 480p, $0.07/s 720p; video-1.5: $0.08/s 480p, $0.14/s 720p, $0.25/s 1080p).',
modelOverridesTitle: 'Per-model video price overrides',
modelOverridesDescription: 'Each populated cell overrides the flat resolution price for that model family. Preview and legacy aliases for video-1.5 use the same family; empty cells fall back to the flat resolution price.',
independentMultiplier: 'Use independent video multiplier',
videoMultiplier: 'Video multiplier',
modeHint:
@@ -992,6 +994,20 @@ export default {
finalPricePreview: 'Final per-second price preview',
notConfigured: 'Not configured'
},
explicitPricing: {
title: 'Grok Search & Voice Pricing',
description: 'Optional per-group prices for web_search (per 1k calls) and Voice realtime / TTS / STT (USD). Leave empty if unused.',
searchPricePer1k: 'Search price per 1k calls (USD)',
pricePlaceholder: 'optional'
},
voicePricing: {
title: 'Grok Voice Pricing',
description: 'Optional per-group prices for Voice realtime / TTS / STT (USD). Leave empty to leave unpriced.',
audioRealtimePerMin: 'Realtime price per minute (USD)',
audioTtsPerMillionChars: 'TTS price per million chars (USD)',
audioSttPerHour: 'STT price per hour (USD)',
pricePlaceholder: 'optional'
},
webSearchPricing: {
title: 'Codex Web Search Pricing',
pricePerCall: 'Price per search call (USD)',
@@ -544,6 +544,10 @@ export default {
billingModeImage: 'Image',
billingModeVideo: 'Video',
allBillingModes: 'All Billing Modes',
upstreamModelAudit: 'Upstream model audit',
allUpstreamModelAudit: 'All response model states',
upstreamModelMismatchOnly: 'Mismatched only',
upstreamModelMatchedOnly: 'Matched only',
ipAddress: 'IP',
clickToViewBalance: 'Click to view balance history',
failedToLoadUser: 'Failed to load user info',
+17 -1
View File
@@ -418,7 +418,12 @@ export default {
title: 'Gateway Scheduling Settings',
description: 'Control API Key scheduling behavior',
allowUngroupedKey: 'Allow Ungrouped Key Scheduling',
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.'
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.',
accountSchedulingThresholdsTitle: 'Platform Account Auto-Pause Thresholds',
accountSchedulingThresholdsDescription: 'When an account\'s current native usage window (OpenAI Codex/Anthropic session, or Grok request/token utilization) reaches this percent, Sub2API temporarily removes it from scheduling until the window resets. Use 100 to disable.',
accountSchedulingThresholdsGlobalHint: 'System-wide default for every account on that platform. Individual accounts can still override this in the account editor.',
accountSchedulingThresholdsDisabledHint: '100 disables platform auto-pause. Values 1–99 pause scheduling once utilization reaches that percent.',
accountSchedulingThresholdsRangeHint: 'Integer 1–100 (percent). OpenAI/Anthropic/Grok only.'
},
upstreamBillingProbe: {
title: 'Upstream Rate Auto Detection',
@@ -445,6 +450,17 @@ export default {
gatewayForwarding: {
title: 'Request Forwarding',
description: 'Control how requests are forwarded to upstream OAuth accounts',
grokDefaultTextModel: 'Default Grok text model',
grokDefaultTextModelHint: 'Used for empty model values and, only when the switch is enabled, requests from other client model namespaces. Custom Grok model IDs are accepted.',
grokCrossClientMap: 'Map other clients to Grok',
grokCrossClientMapHint: 'Disabled by default. When enabled, GPT, Codex, o-series, and Claude model IDs are routed to the default Grok text model above.',
grokDefaultBaseURLMode: 'Default Grok upstream',
grokDefaultBaseURLModeHint: 'Used only when a Grok account has no explicit base URL. Media and voice endpoints continue to use their official API hosts.',
grokBaseURLModeCLI: 'CLI chat proxy',
grokBaseURLModeAPI: 'Public API',
grokBaseURLModeUSEast1: 'Regional API (us-east-1)',
grokBaseURLModeUSWest2: 'Regional API (us-west-2)',
grokBaseURLModeEUWest1: 'Regional API (eu-west-1)',
fingerprintUnification: 'Fingerprint Unification',
fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.',
metadataPassthrough: 'Metadata Passthrough',
+7
View File
@@ -81,6 +81,13 @@ export default {
view: 'View',
settings: 'Settings',
chooseFile: 'Choose File',
upload: 'Upload',
remove: 'Remove',
noFileSelected: 'No file selected',
selectedFile: 'Selected: {name}',
fileReadFailed: 'Failed to read file',
selectImageFile: 'Please select an image file',
fileTooLargeKb: 'File too large ({size} KB), max {max} KB',
copy: 'Copy',
notAvailable: 'N/A',
now: 'Now',
+21 -8
View File
@@ -169,16 +169,24 @@ export default {
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
},
grok: {
description: 'Configure Grok Build, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group.',
description:
'Configure Grok CLI, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group. Text models use Responses; image/video use Imagine model IDs on media endpoints.',
claudeDescription: 'Configure Claude Code to send Messages API traffic through your Sub2API Grok group.',
codexDescription: 'Configure Codex to send Responses API traffic through your Sub2API Grok group.',
configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.',
codexConfigTomlHint: 'Back up an existing config.toml before merging this provider configuration.',
note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select grok from /model.',
noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select grok from /model.',
claudeNote: 'Choose one method: run the terminal commands for the current session, or save settings.json for user-level persistent configuration.',
codexNote: 'Save config.toml under ~/.codex and set SUB2API_API_KEY before starting Codex.',
codexNoteWindows: 'Save config.toml under %USERPROFILE%\\.codex and set SUB2API_API_KEY in PowerShell before starting Codex.',
configTomlHint:
'Official path: ~/.grok/config.toml (or $GROK_HOME). Fill [endpoints] (models_base_url / models_list_url / xai_api_base_url / cli_chat_proxy_base_url), [auth] preferred_method=api_key, [models], [session], and [features] image/video overrides. Prefer env_key over api_key; every text model needs api_backend=responses. Back up before merge, then run grok inspect.',
codexConfigTomlHint:
'Official Codex: wire_api = "responses" only; prefer env_key over experimental_bearer_token; supports_websockets = false for non-OpenAI gateways (Sub2API can still accept client WS and bridge to HTTP/SSE). Back up ~/.codex/config.toml before merge.',
note:
'Export GROK_MODELS_BASE_URL and XAI_API_KEY, save the full config.toml (endpoints/auth/models/session/features) as ~/.grok/config.toml, run grok inspect, then /model grok-4.5 (or grok-build-0.1 for coding).',
noteWindows:
'Set GROK_MODELS_BASE_URL and XAI_API_KEY, save the full config.toml as %USERPROFILE%\\.grok\\config.toml, run grok inspect, then /model grok-4.5 (or grok-build-0.1 for coding).',
claudeNote:
'Choose one method: terminal env for this session, or ~/.claude/settings.json for persistence. Do not commit files that contain your API key.',
codexNote:
'Export SUB2API_API_KEY, save config.toml under ~/.codex (mkdir -p ~/.codex). Prefer env_key auth; do not commit secrets.',
codexNoteWindows:
'Set $env:SUB2API_API_KEY, save config.toml under %USERPROFILE%\\.codex. Prefer env_key auth; do not commit secrets.',
},
opencode: {
title: 'OpenCode Example',
@@ -295,6 +303,11 @@ export default {
model: 'Model',
requestedModel: 'Requested',
upstreamModel: 'Upstream',
sentUpstreamModel: 'Sent upstream',
upstreamResponseModel: 'Upstream response',
upstreamModelMismatch: 'Response model mismatch',
modelVariant: 'Possible version variant',
modelMismatch: 'Different model',
reasoningEffort: 'Reasoning Effort',
endpoint: 'Endpoint',
endpointDistribution: 'Endpoint Distribution',
+90 -3
View File
@@ -334,6 +334,10 @@ export default {
rateLimited: '限流中',
overloaded: '过载中',
tempUnschedulable: '临时不可调度',
accountSchedulingThresholdOverride: '账号自动停调阈值覆盖',
accountSchedulingThresholdOverrideHint: '仅对当前账号覆盖平台级自动停调阈值;关闭后使用平台设置。',
accountSchedulingThresholdOverrideValue: '账号阈值百分比',
accountSchedulingThresholdOverrideDisabledHint: '1-100,达到该用量百分比后临时不可调度;100 表示禁用当前账号自动停调。',
quotaExceeded: '配额超限',
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
@@ -343,7 +347,8 @@ export default {
creditsExhausted: '积分已用尽',
creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复',
overloadedUntil: '负载过重,重置时间:{time}',
viewTempUnschedDetails: '查看临时不可调度详情'
viewTempUnschedDetails: '查看临时不可调度详情',
tempUnschedulableUntil: '预计 {time} 恢复'
},
tempUnschedulable: {
title: '临时不可调度',
@@ -352,6 +357,10 @@ export default {
notice: '规则按顺序匹配,需同时满足错误码与关键词。',
addRule: '添加规则',
ruleOrder: '规则序号',
multipleErrorTrigger: '{minutes} 分钟内累计 {count} 次匹配错误,达到触发阈值({threshold})。',
multipleErrorTriggerNoWindow: '累计 {count} 次匹配错误,达到触发阈值({threshold})。',
multipleErrorCountInWindow: '{minutes} 分钟内累计发生 {count} 次匹配错误。',
multipleErrorCount: '本次不可调度由累计 {count} 次匹配错误触发。',
ruleIndex: '规则 #{index}',
errorCode: '错误码',
errorCodePlaceholder: '例如 429',
@@ -401,6 +410,12 @@ export default {
grokTokens: 'Token',
grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 {limit})',
grokWeeklyUsage: '周额度已用 {percent}%',
grokUsed: '已用 $',
grokBalance: '余额 $',
grokPrepaid: '预付余额',
grokMonthlyLimit: '月度已用/上限(USD)',
grokOverage: '超额 onDemandUsed/onDemandCap',
grokOverageShort: '超额 $',
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
grokRetryAfter: '{time} 后重试',
grokProbe: '探测',
@@ -662,7 +677,54 @@ export default {
},
grok: {
baseUrlHint: 'Grok OAuth 账号会转发到官方 xAI API Base URL。',
apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。'
apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。',
// 账号连通性测试弹窗
testMode: '测试模式',
testModeHint:
'文本 / 图片 / 视频使用所选模型。网页搜索、TTS、STT、Realtime 走独立接口探测(不是对话里的 tools)。',
testModeText: '文本(Responses)',
testModeImage: '图片(/images/generations)',
testModeVideo: '视频(/videos/generations)',
testModeSearch: '网页搜索(/web_search)',
testModeTTS: '语音合成 TTS(/tts)',
testModeSTT: '语音识别 STT(/stt)',
testModeRealtime: '实时语音 Realtime(WS /realtime)',
textTestMode: '模式:文本(Responses)',
searchTestMode: '模式:网页搜索(/web_search)',
ttsTestMode: '模式:TTS(/tts)',
sttTestMode: '模式:STT(/stt)',
realtimeTestMode: '模式:Realtime(WS /realtime)',
searchQueryLabel: '搜索关键词',
searchQueryPlaceholder: '例如:xAI Grok',
searchQueryDefault: 'xAI Grok',
searchTestHint:
'独立网页搜索探测(与网关 /v1/web_search 语义一致),不是带 tools 的自由对话。',
ttsTextLabel: 'TTS 文本',
ttsTextPlaceholder: '例如:Hello from Sub2API connectivity test.',
ttsTextDefault: 'Hello from Sub2API account connectivity test.',
ttsTestHint: '独立调用 /v1/tts(language=en);成功时显示音频字节数。',
sttTestHint: '独立调用 /v1/stt,使用合成静音 WAV;成功表示接口可达。',
realtimeTestHint:
'独立 WebSocket 拨号 /v1/realtime(model=grok-voice-latest)。握手成功即连通;若有首包服务端事件会一并显示。',
sendingSearchRequest: '正在发送独立 web_search 请求...',
sendingTTSRequest: '正在发送独立 /tts 请求...',
sendingSTTRequest: '正在发送独立 /stt 请求...',
sendingRealtimeRequest: '正在拨号独立 /realtime WebSocket...',
selectedTestMode: '测试模式:{mode}',
imageUploadLabel: '源图片(可选,图生图/编辑)',
videoFirstFrameLabel: '首帧 / 参考图(可选)',
imageUploadHint:
'建议 PNG/JPEG,宽高均 ≥ 8 像素,编辑建议小于约 4MB。上传源图会走 /images/edits(图生图);不上传则走 /images/generations 文生图。',
videoFirstFrameHint:
'可选首帧/参考图用于图生视频。建议 PNG/JPEG,宽高均 ≥ 8 像素。',
audioUploadLabel: '音频文件(STT 可选)',
audioUploadHint: '上传真实音频做转写;不上传则用静音 WAV 仅测连通。',
mediaTooLarge: '文件过大(管理端测试上传上限约 6MB)。',
chooseImageFile: '选择图片',
chooseAudioFile: '选择音频',
uploadPreviewAlt: '上传预览',
fileReadFailed: '读取所选文件失败',
noResponseBody: '服务器未返回响应体'
},
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
@@ -1062,6 +1124,16 @@ export default {
ssoCookieLabel: 'Grok Web SSO Key',
ssoCookiePlaceholder: '每行一个 SSO key\n支持多个,每行一个',
ssoCookieHint: '每行一个 SSO key;多个 key 会 3 路并发导入,耗时约 90 秒 × 批次数,建议使用对应地区代理。',
emailPasswordAuth: '邮箱密码登录',
emailPasswordDesc:
'使用 Grok 网页邮箱与密码登录。服务端仅用密码换取临时 SSO 再转 Build OAuth;密码与 raw SSO 均不会写入账号凭据。',
emailPasswordInputLabel: '邮箱----密码',
emailPasswordPlaceholder: "user{'@'}example.com----your-password\n支持多个,每行一组",
emailPasswordHint: '格式:email----password(密码可含 -)。需要配置 YesCaptcha 密钥;建议搭配代理。',
pleaseEnterPassword: '请输入 email----password(每行一组)',
pleaseEnterSSOToken: '请输入 SSO Token',
failedToValidateSSO: '校验 Grok SSO 失败',
failedToAuthorizePassword: 'Grok 密码授权失败',
convertingSSO: '转换中...',
convertSSOAndCreate: '转换并创建账号',
validating: '验证中...',
@@ -1320,6 +1392,9 @@ export default {
reAuthorizedSuccess: '账号重新授权成功',
// Test Modal
testAccountConnection: '测试账号连接',
errorPrefix: '错误:{message}',
imagePreviewAlt: '测试图片 {index}',
imageLightboxAlt: '图片预览',
account: '账号',
readyToTest: '准备测试。点击"开始测试"按钮开始...',
connectingToApi: '连接 API 中...',
@@ -1341,10 +1416,22 @@ export default {
imagePromptLabel: '生图提示词',
imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
imageTestHint:
'调用独立 /v1/images/generations 生图,并在下方预览返回图片。',
imageTestMode: '模式:生图测试',
videoPromptLabel: '视频提示词',
videoPromptPlaceholder: '例如:一只红球在白地板上弹跳一次,动作简短。',
videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.',
videoTestHint:
'调用独立 /v1/videos/generations,轮询至完成后下载成品视频并在页面上预览。',
videoTestMode: '模式:视频生成测试',
sendingVideoRequest: '正在发送视频生成测试请求...',
imagePreview: '生成结果:',
imageReceived: '已收到第 {count} 张测试图片',
audioPreview: '生成音频:',
audioReceived: '已收到第 {count} 段测试音频',
videoPreview: '生成视频:',
videoReceived: '已收到第 {count} 段测试视频',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',
@@ -982,6 +982,8 @@ export default {
title: '视频生成计费',
description:
'配置 Grok 视频生成的每秒单价(USD/秒),留空则使用默认每秒价(grok-imagine-video:480p $0.05/s、720p $0.07/s;video-1.5:480p $0.08/s、720p $0.14/s、1080p $0.25/s)',
modelOverridesTitle: '按模型覆盖视频价格',
modelOverridesDescription: '已填写的单元格会覆盖该模型族的平面分辨率价格。video-1.5 的 preview 与 legacy 别名共用同一模型族;留空则回退到平面分辨率价格。',
independentMultiplier: '视频倍率独立',
videoMultiplier: '视频独立倍率',
modeHint:
@@ -989,6 +991,20 @@ export default {
finalPricePreview: '最终每秒价格预览',
notConfigured: '未配置'
},
explicitPricing: {
title: 'Grok 搜索与 Voice 定价',
description: '分组级 web_search(每千次)与 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。',
searchPricePer1k: '搜索每千次价格(USD)',
pricePlaceholder: '可选'
},
voicePricing: {
title: 'Grok Voice 定价',
description: '分组级 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。',
audioRealtimePerMin: 'Realtime 每分钟价格(USD)',
audioTtsPerMillionChars: 'TTS 每百万字符价格(USD)',
audioSttPerHour: 'STT 每小时价格(USD)',
pricePlaceholder: '可选'
},
webSearchPricing: {
title: 'Codex 网页搜索计费',
pricePerCall: '搜索单次价格(USD/次)',
@@ -541,6 +541,10 @@ export default {
billingModeImage: '按次(图片)',
billingModeVideo: '按次(视频)',
allBillingModes: '全部计费模式',
upstreamModelAudit: '上游模型审计',
allUpstreamModelAudit: '全部响应模型状态',
upstreamModelMismatchOnly: '仅不一致',
upstreamModelMatchedOnly: '仅一致',
ipAddress: 'IP',
clickToViewBalance: '点击查看充值记录',
failedToLoadUser: '加载用户信息失败',
+17 -1
View File
@@ -411,7 +411,12 @@ export default {
title: '网关调度设置',
description: '控制 API Key 的调度行为',
allowUngroupedKey: '允许未分组 Key 调度',
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。'
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。',
accountSchedulingThresholdsTitle: '平台账号自动停调阈值',
accountSchedulingThresholdsDescription: '当账号当前原生用量窗口(OpenAI Codex/Anthropic 会话,或 Grok 请求/Token 利用率)达到该百分比时,Sub2API 会临时将其移出调度,直到窗口重置。填 100 表示禁用。',
accountSchedulingThresholdsGlobalHint: '系统级默认值,作用于该平台全部账号。可在账号编辑页对单个账号覆盖。',
accountSchedulingThresholdsDisabledHint: '100 表示禁用该平台自动停调;1–99 表示达到该利用率后暂停调度。',
accountSchedulingThresholdsRangeHint: '整数 1–100(百分比)。仅 OpenAI / Anthropic / Grok。'
},
upstreamBillingProbe: {
title: '上游倍率自动探测',
@@ -438,6 +443,17 @@ export default {
gatewayForwarding: {
title: '请求转发行为',
description: '控制请求转发到上游 OAuth 账号时的行为',
grokDefaultTextModel: '默认 Grok 文本模型',
grokDefaultTextModelHint: '用于空模型值;仅在右侧开关开启时也用于其他客户端模型命名空间。允许填写自定义 Grok 模型 ID。',
grokCrossClientMap: '映射其他客户端模型到 Grok',
grokCrossClientMapHint: '默认关闭。开启后,GPT、Codex、o 系列和 Claude 模型 ID 会路由到左侧默认 Grok 文本模型。',
grokDefaultBaseURLMode: '默认 Grok 上游',
grokDefaultBaseURLModeHint: '仅用于 Grok 账号未配置显式 base URL 的文本请求;媒体和语音仍使用官方 API 主机。',
grokBaseURLModeCLI: 'CLI 聊天代理',
grokBaseURLModeAPI: '公共 API',
grokBaseURLModeUSEast1: '区域 API(us-east-1)',
grokBaseURLModeUSWest2: '区域 API(us-west-2)',
grokBaseURLModeEUWest1: '区域 API(eu-west-1)',
fingerprintUnification: '指纹统一化',
fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。',
metadataPassthrough: 'Metadata 透传',
+7
View File
@@ -81,6 +81,13 @@ export default {
view: '查看',
settings: '设置',
chooseFile: '选择文件',
upload: '上传',
remove: '移除',
noFileSelected: '未选择文件',
selectedFile: '已选:{name}',
fileReadFailed: '读取文件失败',
selectImageFile: '请选择图片文件',
fileTooLargeKb: '文件过大({size} KB),上限 {max} KB',
copy: '复制',
notAvailable: '不可用',
now: '现在',
+21 -8
View File
@@ -173,16 +173,24 @@ export default {
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
},
grok: {
description: '配置 Grok Build、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。',
description:
'配置 Grok CLI、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。文本模型走 Responses;图片/视频使用 Imagine 模型 ID 与媒体端点。',
claudeDescription: '配置 Claude Code,让 Messages API 请求通过当前 Sub2API Grok 分组发送。',
codexDescription: '配置 Codex,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。',
codexConfigTomlHint: '如已有 config.toml,请先备份再合并此服务商配置。',
note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 grok。',
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 grok。',
claudeNote: '二选一即可:终端命令仅在当前会话生效;保存 settings.json 可作为用户级持久配置。',
codexNote: '将 config.toml 保存到 ~/.codex,并在启动 Codex 前设置 SUB2API_API_KEY。',
codexNoteWindows: '将 config.toml 保存到 %USERPROFILE%\\.codex,并在 PowerShell 中设置 SUB2API_API_KEY 后启动 Codex。'
configTomlHint:
'官方路径:~/.grok/config.toml(或 $GROK_HOME)。请填写 [endpoints](models_base_url / models_list_url / xai_api_base_url / cli_chat_proxy_base_url)、[auth] preferred_method=api_key、[models]、[session]、[features] 图片/视频覆盖。优先 env_key,勿硬编码 api_key;文本模型必须 api_backend=responses。合并前备份,保存后运行 grok inspect。',
codexConfigTomlHint:
'Codex 官方:wire_api 仅支持 "responses";优先 env_key,勿与 experimental_bearer_token 混用;非 OpenAI 网关默认 supports_websockets = false(Sub2API 仍可接客户端 WS 并桥接到 HTTP/SSE)。合并前备份 ~/.codex/config.toml。',
note:
'导出 GROK_MODELS_BASE_URL 与 XAI_API_KEY,将完整 config.toml(endpoints/auth/models/session/features)保存为 ~/.grok/config.toml,运行 grok inspect,再用 /model 选择 grok-4.5(编程场景可用 grok-build-0.1)。',
noteWindows:
'设置 GROK_MODELS_BASE_URL 与 XAI_API_KEY,将完整 config.toml 保存为 %USERPROFILE%\\.grok\\config.toml,运行 grok inspect,再用 /model 选择 grok-4.5(编程场景可用 grok-build-0.1)。',
claudeNote:
'二选一:终端环境变量仅当前会话;~/.claude/settings.json 可持久化。请勿把含 API Key 的文件提交到仓库。',
codexNote:
'导出 SUB2API_API_KEY,将 config.toml 保存到 ~/.codex(可用 mkdir -p ~/.codex)。优先 env_key,勿提交密钥。',
codexNoteWindows:
'设置 $env:SUB2API_API_KEY,将 config.toml 保存到 %USERPROFILE%\\.codex。优先 env_key,勿提交密钥。'
},
opencode: {
title: 'OpenCode 配置示例',
@@ -300,6 +308,11 @@ export default {
model: '模型',
requestedModel: '请求',
upstreamModel: '上游',
sentUpstreamModel: '发往上游',
upstreamResponseModel: '上游响应',
upstreamModelMismatch: '上游响应模型不一致',
modelVariant: '疑似版本变体',
modelMismatch: '模型不一致',
reasoningEffort: '推理强度',
endpoint: '端点',
endpointDistribution: '端点分布',
+33
View File
@@ -526,6 +526,8 @@ export interface PaginationConfig {
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'composite'
export type VideoModelPrices = Record<string, Record<string, number>>
export type SubscriptionType = 'standard' | 'subscription'
export interface OpenAIMessagesDispatchModelConfig {
@@ -570,8 +572,15 @@ export interface Group {
video_price_480p: number | null
video_price_720p: number | null
video_price_1080p: number | null
// Optional model-family x resolution overrides for Grok video pricing.
video_model_prices?: VideoModelPrices
// Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
web_search_price_per_call: number | null
// Grok Voice 显式定价(分组级)
search_price_per_1k: number | null
audio_realtime_price_per_min: number | null
audio_tts_price_per_million_chars: number | null
audio_stt_price_per_hour: number | null
// 高峰时段倍率配置
peak_rate_enabled: boolean
peak_start: string
@@ -770,7 +779,12 @@ export interface CreateGroupRequest {
video_price_480p?: number | null
video_price_720p?: number | null
video_price_1080p?: number | null
video_model_prices?: VideoModelPrices
web_search_price_per_call?: number | null
search_price_per_1k?: number | null
audio_realtime_price_per_min?: number | null
audio_tts_price_per_million_chars?: number | null
audio_stt_price_per_hour?: number | null
peak_rate_enabled?: boolean
peak_start?: string
peak_end?: string
@@ -825,7 +839,12 @@ export interface UpdateGroupRequest {
video_price_480p?: number | null
video_price_720p?: number | null
video_price_1080p?: number | null
video_model_prices?: VideoModelPrices
web_search_price_per_call?: number | null
search_price_per_1k?: number | null
audio_realtime_price_per_min?: number | null
audio_tts_price_per_million_chars?: number | null
audio_stt_price_per_hour?: number | null
peak_rate_enabled?: boolean
peak_start?: string
peak_end?: string
@@ -976,6 +995,9 @@ export interface TempUnschedulableState {
matched_keyword: string
rule_index: number
error_message: string
trigger_count?: number
trigger_threshold?: number
trigger_window_minutes?: number
}
export interface TempUnschedulableStatus {
@@ -1264,6 +1286,14 @@ export interface GrokBillingSummary {
billing_period_start?: string
billing_period_end?: string
used_percent?: number | null
/** Absolute USD money from billing probes */
prepaid_balance?: number | null
monthly_limit?: number | null
monthly_used?: number | null
on_demand_cap?: number | null
on_demand_used?: number | null
top_up_method?: string
is_unified_billing_user?: boolean
plan?: string
status_code?: number
source?: string
@@ -1282,6 +1312,7 @@ export interface AccountUsageInfo {
seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null
seven_day_fable?: UsageProgress | null
thirty_day?: UsageProgress | null
gemini_shared_daily?: UsageProgress | null
gemini_pro_daily?: UsageProgress | null
gemini_flash_daily?: UsageProgress | null
@@ -1653,6 +1684,8 @@ export interface UsageLogAccountSummary {
export interface AdminUsageLog extends UsageLog {
upstream_model?: string | null
upstream_response_model?: string | null
upstream_model_mismatch?: boolean | null
model_mapping_chain?: string | null
// 账号计费倍率(仅管理员可见)
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
BILLING_MODE_IMAGE,
BILLING_MODE_TOKEN,
BILLING_MODE_VIDEO,
getDisplayBillingMode,
isImageUsage
} from '../billingMode'
describe('billingMode helpers', () => {
it('prefers explicit video mode over image_count', () => {
expect(
getDisplayBillingMode({ image_count: 1, billing_mode: BILLING_MODE_VIDEO })
).toBe(BILLING_MODE_VIDEO)
expect(isImageUsage({ image_count: 1, billing_mode: BILLING_MODE_VIDEO })).toBe(false)
})
it('infers image when image_count set and mode missing', () => {
expect(getDisplayBillingMode({ image_count: 2, billing_mode: null })).toBe(BILLING_MODE_IMAGE)
})
it('keeps token mode even with image_count', () => {
expect(
getDisplayBillingMode({ image_count: 1, billing_mode: BILLING_MODE_TOKEN })
).toBe(BILLING_MODE_TOKEN)
})
})
+4
View File
@@ -32,6 +32,10 @@ export function isImageUsage(row: Pick<ImageBillingRow, 'image_count' | 'billing
}
export function getDisplayBillingMode(row: Pick<ImageBillingRow, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
// Explicit video/token modes always win over image_count heuristics.
if (row?.billing_mode === BILLING_MODE_VIDEO || row?.billing_mode === BILLING_MODE_TOKEN) {
return row.billing_mode
}
if ((row?.image_count ?? 0) > 0 && !row?.billing_mode) {
return BILLING_MODE_IMAGE
}
+199 -1
View File
@@ -318,7 +318,12 @@
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
:today-stats-loading="todayStatsLoading"
:manual-refresh-token="usageManualRefreshToken"
:batched-usage="usageBatchByAccountId[String(row.id)] ?? null"
:batched-usage-error="usageBatchErrorByAccountId[String(row.id)] ?? null"
:batched-usage-loading="usageBatchLoadingByAccountId[String(row.id)] === true"
:request-batched-usage="isDesktopViewport ? queueBatchedUsage : null"
@account-updated="handleAccountUpdated"
@usage-loaded="handleAccountUsageLoaded(row.id, $event)"
/>
</template>
<template #cell-proxy="{ row }">
@@ -527,7 +532,7 @@ import { extractApiErrorMessage } from '@/utils/apiError'
import { sanitizeUrl } from '@/utils/url'
import { getFloatingPanelPosition } from '@/utils/floatingPanel'
import { formatMultiplier } from '@/utils/formatters'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, AccountUsageInfo, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
@@ -692,6 +697,24 @@ const todayStatsReqSeq = ref(0)
const pendingTodayStatsRefresh = ref(false)
const usageManualRefreshToken = ref(0)
const desktopViewportQuery = '(min-width: 768px)'
const isDesktopViewport = ref(
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
)
let desktopViewportMediaQuery: MediaQueryList | null = null
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
const usageBatchByAccountId = ref<Record<string, AccountUsageInfo | null>>({})
const usageBatchErrorByAccountId = ref<Record<string, string | null>>({})
const usageBatchLoadingByAccountId = ref<Record<string, boolean>>({})
const usageBatchRequestTokenByAccountId = ref<Record<string, number>>({})
const usageBatchCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
const USAGE_BATCH_CACHE_TTL = 5 * 60 * 1000
const pendingUsageBatchIds = new Set<number>()
let usageBatchFlushTimer: ReturnType<typeof setTimeout> | null = null
let queuedUsageBatchForce = false
let usageBatchRequestToken = 0
const buildDefaultTodayStats = (): WindowStats => ({
requests: 0,
tokens: 0,
@@ -700,6 +723,138 @@ const buildDefaultTodayStats = (): WindowStats => ({
user_cost: 0
})
const accountSupportsBatchUsage = (account: Account) => {
if (account.platform === 'anthropic') {
return account.type === 'oauth' || account.type === 'setup-token'
}
if (account.platform === 'gemini') return true
if (account.platform === 'antigravity') return account.type === 'oauth'
if (account.platform === 'openai') return account.type === 'oauth'
if (account.platform === 'grok') return account.type === 'oauth'
return false
}
const setUsageBatchLoading = (accountID: number, loadingState: boolean) => {
usageBatchLoadingByAccountId.value = {
...usageBatchLoadingByAccountId.value,
[String(accountID)]: loadingState
}
}
const setUsageBatchState = (accountID: number, usage: AccountUsageInfo | null, error: string | null) => {
const key = String(accountID)
usageBatchByAccountId.value = {
...usageBatchByAccountId.value,
[key]: usage
}
usageBatchErrorByAccountId.value = {
...usageBatchErrorByAccountId.value,
[key]: error
}
}
const handleAccountUsageLoaded = (accountID: number, usage: AccountUsageInfo) => {
if (usageBatchByAccountId.value[String(accountID)] === usage) return
setUsageBatchState(accountID, usage, null)
}
const flushQueuedUsageBatch = async () => {
usageBatchFlushTimer = null
const accountIDs = Array.from(pendingUsageBatchIds)
const force = queuedUsageBatchForce
pendingUsageBatchIds.clear()
queuedUsageBatchForce = false
if (accountIDs.length === 0) return
const requestTokensByAccount = accountIDs.reduce<Record<string, number>>((acc, accountID) => {
acc[String(accountID)] = usageBatchRequestTokenByAccountId.value[String(accountID)] ?? 0
return acc
}, {})
try {
const result = await adminAPI.accounts.getBatchUsage(accountIDs, force)
const usageMap = result.usage ?? {}
const errorMap = result.errors ?? {}
const now = Date.now()
const nextUsage = { ...usageBatchByAccountId.value }
const nextErrors = { ...usageBatchErrorByAccountId.value }
const nextLoading = { ...usageBatchLoadingByAccountId.value }
for (const accountID of accountIDs) {
const key = String(accountID)
if ((usageBatchRequestTokenByAccountId.value[key] ?? 0) !== requestTokensByAccount[key]) {
continue
}
const usage = usageMap[key] ?? null
nextUsage[key] = usage
nextErrors[key] = errorMap[key] ?? null
nextLoading[key] = false
if (usage) {
usageBatchCache.set(accountID, { data: usage, ts: now })
} else {
usageBatchCache.delete(accountID)
}
}
usageBatchByAccountId.value = nextUsage
usageBatchErrorByAccountId.value = nextErrors
usageBatchLoadingByAccountId.value = nextLoading
} catch (error) {
const nextErrors = { ...usageBatchErrorByAccountId.value }
const nextLoading = { ...usageBatchLoadingByAccountId.value }
for (const accountID of accountIDs) {
const key = String(accountID)
if ((usageBatchRequestTokenByAccountId.value[key] ?? 0) !== requestTokensByAccount[key]) {
continue
}
nextErrors[key] = 'Failed'
nextLoading[key] = false
}
usageBatchErrorByAccountId.value = nextErrors
usageBatchLoadingByAccountId.value = nextLoading
console.error('Failed to load account usage batch:', error)
}
}
const queueBatchedUsage = (account: Account, options?: { force?: boolean }) => {
if (!isDesktopViewport.value) return
if (!accountSupportsBatchUsage(account)) return
const force = options?.force === true
const cacheKey = account.id
const key = String(cacheKey)
if (force) {
usageBatchCache.delete(cacheKey)
} else {
const cached = usageBatchCache.get(cacheKey)
if (cached && Date.now() - cached.ts < USAGE_BATCH_CACHE_TTL) {
setUsageBatchState(cacheKey, cached.data, null)
setUsageBatchLoading(cacheKey, false)
return
}
}
usageBatchErrorByAccountId.value = {
...usageBatchErrorByAccountId.value,
[key]: null
}
usageBatchRequestTokenByAccountId.value = {
...usageBatchRequestTokenByAccountId.value,
[key]: ++usageBatchRequestToken
}
setUsageBatchLoading(cacheKey, true)
pendingUsageBatchIds.add(cacheKey)
queuedUsageBatchForce = queuedUsageBatchForce || force
if (usageBatchFlushTimer !== null) return
usageBatchFlushTimer = setTimeout(() => {
void flushQueuedUsageBatch()
}, 0)
}
const refreshTodayStatsBatch = async () => {
// Why this checks both columns:
// - today_stats column shows dedicated today's metrics.
@@ -1084,6 +1239,22 @@ watch(loading, (isLoading, wasLoading) => {
}
})
watch(accounts, (rows) => {
const visibleIDs = new Set(rows.map((row) => String(row.id)))
usageBatchByAccountId.value = Object.fromEntries(
Object.entries(usageBatchByAccountId.value).filter(([key]) => visibleIDs.has(key))
)
usageBatchErrorByAccountId.value = Object.fromEntries(
Object.entries(usageBatchErrorByAccountId.value).filter(([key]) => visibleIDs.has(key))
)
usageBatchLoadingByAccountId.value = Object.fromEntries(
Object.entries(usageBatchLoadingByAccountId.value).filter(([key]) => visibleIDs.has(key))
)
usageBatchRequestTokenByAccountId.value = Object.fromEntries(
Object.entries(usageBatchRequestTokenByAccountId.value).filter(([key]) => visibleIDs.has(key))
)
})
watch(upstreamBillingNow, () => {
if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
if (typeof document !== 'undefined' && document.hidden) return
@@ -2183,6 +2354,19 @@ const handleClickOutside = (event: MouseEvent) => {
}
onMounted(async () => {
if (typeof window !== 'undefined') {
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
isDesktopViewport.value = desktopViewportMediaQuery.matches
desktopViewportListener = (event: MediaQueryListEvent) => {
isDesktopViewport.value = event.matches
}
if (typeof desktopViewportMediaQuery.addEventListener === 'function') {
desktopViewportMediaQuery.addEventListener('change', desktopViewportListener)
} else {
desktopViewportMediaQuery.addListener(desktopViewportListener)
}
}
load()
loadUpstreamBillingProbeGlobalState()
try {
@@ -2205,9 +2389,23 @@ onMounted(async () => {
})
onUnmounted(() => {
if (usageBatchFlushTimer !== null) {
clearTimeout(usageBatchFlushTimer)
usageBatchFlushTimer = null
}
pendingUsageBatchIds.clear()
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', handleViewportResize)
document.removeEventListener('click', handleClickOutside)
if (desktopViewportMediaQuery && desktopViewportListener) {
if (typeof desktopViewportMediaQuery.removeEventListener === 'function') {
desktopViewportMediaQuery.removeEventListener('change', desktopViewportListener)
} else {
desktopViewportMediaQuery.removeListener(desktopViewportListener)
}
}
desktopViewportListener = null
desktopViewportMediaQuery = null
})
</script>
+275 -1
View File
@@ -1087,6 +1087,45 @@
/>
</div>
</div>
<div
class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700"
data-testid="create-grok-video-model-prices"
>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.videoPricing.modelOverridesTitle") }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.videoPricing.modelOverridesDescription") }}
</p>
<div class="mt-3 space-y-3">
<div
v-for="family in videoModelPriceFamilyRows(createForm.video_model_prices)"
:key="family.key"
class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,minmax(0,7rem))] sm:items-end"
>
<div class="min-w-0 pb-1 font-mono text-xs text-gray-700 dark:text-gray-300">
{{ family.label }}
</div>
<label
v-for="resolution in grokVideoPriceResolutions"
:key="resolution.key"
class="block"
>
<span class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
{{ resolution.label }} ($/s)
</span>
<input
v-model.number="createForm.video_model_prices[family.key][resolution.key]"
type="number"
step="0.001"
min="0"
class="input"
:data-testid="`create-grok-video-price-${family.key}-${resolution.key}`"
/>
</label>
</div>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t(videoPricingI18nKey("modeHint")) }}
</p>
@@ -1444,6 +1483,69 @@
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="createForm.platform === 'grok'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t("admin.groups.explicitPricing.title") }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.explicitPricing.description") }}
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label class="input-label">{{ t("admin.groups.explicitPricing.searchPricePer1k") }}</label>
<input
v-model.number="createForm.search_price_per_1k"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.explicitPricing.pricePlaceholder')"
data-testid="create-search-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
<input
v-model.number="createForm.audio_realtime_price_per_min"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-realtime-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
<input
v-model.number="createForm.audio_tts_price_per_million_chars"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-tts-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
<input
v-model.number="createForm.audio_stt_price_per_hour"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-stt-price"
/>
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<div
v-if="createForm.platform === 'openai'"
@@ -2692,6 +2794,45 @@
/>
</div>
</div>
<div
class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700"
data-testid="edit-grok-video-model-prices"
>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.videoPricing.modelOverridesTitle") }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.videoPricing.modelOverridesDescription") }}
</p>
<div class="mt-3 space-y-3">
<div
v-for="family in videoModelPriceFamilyRows(editForm.video_model_prices)"
:key="family.key"
class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,minmax(0,7rem))] sm:items-end"
>
<div class="min-w-0 pb-1 font-mono text-xs text-gray-700 dark:text-gray-300">
{{ family.label }}
</div>
<label
v-for="resolution in grokVideoPriceResolutions"
:key="resolution.key"
class="block"
>
<span class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
{{ resolution.label }} ($/s)
</span>
<input
v-model.number="editForm.video_model_prices[family.key][resolution.key]"
type="number"
step="0.001"
min="0"
class="input"
:data-testid="`edit-grok-video-price-${family.key}-${resolution.key}`"
/>
</label>
</div>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t(videoPricingI18nKey("modeHint")) }}
</p>
@@ -3045,6 +3186,69 @@
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="editForm.platform === 'grok'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t("admin.groups.explicitPricing.title") }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.explicitPricing.description") }}
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label class="input-label">{{ t("admin.groups.explicitPricing.searchPricePer1k") }}</label>
<input
v-model.number="editForm.search_price_per_1k"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.explicitPricing.pricePlaceholder')"
data-testid="edit-search-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
<input
v-model.number="editForm.audio_realtime_price_per_min"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-realtime-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
<input
v-model.number="editForm.audio_tts_price_per_million_chars"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-tts-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
<input
v-model.number="editForm.audio_stt_price_per_hour"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-stt-price"
/>
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<div
v-if="editForm.platform === 'openai'"
@@ -4219,6 +4423,12 @@ import {
supportsVideoPricingPlatform,
videoPricingI18nKey,
} from "./groupsImagePricing";
import {
createVideoModelPricesForm,
grokVideoPriceResolutions,
serializeVideoModelPrices,
videoModelPriceFamilyRows,
} from "./groupsVideoModelPricing";
const { t } = useI18n();
const appStore = useAppStore();
@@ -4719,8 +4929,13 @@ const createForm = reactive({
video_price_480p: null as number | null,
video_price_720p: null as number | null,
video_price_1080p: null as number | null,
video_model_prices: createVideoModelPricesForm(),
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
search_price_per_1k: null as number | null,
audio_realtime_price_per_min: null as number | null,
audio_tts_price_per_million_chars: null as number | null,
audio_stt_price_per_hour: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
@@ -5073,8 +5288,13 @@ const editForm = reactive({
video_price_480p: null as number | null,
video_price_720p: null as number | null,
video_price_1080p: null as number | null,
video_model_prices: createVideoModelPricesForm(),
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
search_price_per_1k: null as number | null,
audio_realtime_price_per_min: null as number | null,
audio_tts_price_per_million_chars: null as number | null,
audio_stt_price_per_hour: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
@@ -5523,7 +5743,12 @@ const closeCreateModal = () => {
createForm.video_price_480p = null;
createForm.video_price_720p = null;
createForm.video_price_1080p = null;
createForm.video_model_prices = createVideoModelPricesForm();
createForm.web_search_price_per_call = null;
createForm.search_price_per_1k = null;
createForm.audio_realtime_price_per_min = null;
createForm.audio_tts_price_per_million_chars = null;
createForm.audio_stt_price_per_hour = null;
createForm.peak_rate_enabled = false;
createForm.peak_start = "";
createForm.peak_end = "";
@@ -5608,9 +5833,16 @@ const handleCreateGroup = async () => {
}
submitting.value = true;
try {
const {
video_model_prices: _createFormVideoModelPrices,
...createGroupForm
} = createForm;
const videoModelPrices = serializeVideoModelPrices(
createForm.video_model_prices,
);
// 构建请求数据,包含模型路由配置
const requestData = {
...createForm,
...createGroupForm,
daily_limit_usd: normalizeOptionalLimit(
createForm.daily_limit_usd as number | string | null,
),
@@ -5620,6 +5852,9 @@ const handleCreateGroup = async () => {
monthly_limit_usd: normalizeOptionalLimit(
createForm.monthly_limit_usd as number | string | null,
),
...(Object.keys(videoModelPrices).length > 0
? { video_model_prices: videoModelPrices }
: {}),
model_routing: convertRoutingRulesToApiFormat(
createModelRoutingRules.value,
),
@@ -5678,6 +5913,18 @@ const handleCreateGroup = async () => {
requestData.video_price_480p = emptyToNull(requestData.video_price_480p);
requestData.video_price_720p = emptyToNull(requestData.video_price_720p);
requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p);
requestData.search_price_per_1k = emptyToNull(
requestData.search_price_per_1k,
);
requestData.audio_realtime_price_per_min = emptyToNull(
requestData.audio_realtime_price_per_min,
);
requestData.audio_tts_price_per_million_chars = emptyToNull(
requestData.audio_tts_price_per_million_chars,
);
requestData.audio_stt_price_per_hour = emptyToNull(
requestData.audio_stt_price_per_hour,
);
requestData.web_search_price_per_call = emptyToNull(
requestData.web_search_price_per_call,
);
@@ -5734,7 +5981,14 @@ const handleEdit = async (group: AdminGroup) => {
editForm.video_price_480p = group.video_price_480p;
editForm.video_price_720p = group.video_price_720p;
editForm.video_price_1080p = group.video_price_1080p;
editForm.video_model_prices = createVideoModelPricesForm(
group.video_model_prices,
);
editForm.web_search_price_per_call = group.web_search_price_per_call ?? null;
editForm.search_price_per_1k = group.search_price_per_1k ?? null;
editForm.audio_realtime_price_per_min = group.audio_realtime_price_per_min ?? null;
editForm.audio_tts_price_per_million_chars = group.audio_tts_price_per_million_chars ?? null;
editForm.audio_stt_price_per_hour = group.audio_stt_price_per_hour ?? null;
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
editForm.peak_start = group.peak_start ?? "";
editForm.peak_end = group.peak_end ?? "";
@@ -5814,7 +6068,12 @@ const closeEditModal = () => {
editForm.video_price_480p = null;
editForm.video_price_720p = null;
editForm.video_price_1080p = null;
editForm.video_model_prices = createVideoModelPricesForm();
editForm.web_search_price_per_call = null;
editForm.search_price_per_1k = null;
editForm.audio_realtime_price_per_min = null;
editForm.audio_tts_price_per_million_chars = null;
editForm.audio_stt_price_per_hour = null;
resetMessagesDispatchFormState(editForm);
editForm.allow_live = false;
resetModelsListState(editModelsListState);
@@ -5851,6 +6110,9 @@ const handleUpdateGroup = async () => {
monthly_limit_usd: normalizeOptionalLimit(
editForm.monthly_limit_usd as number | string | null,
),
video_model_prices: serializeVideoModelPrices(
editForm.video_model_prices,
),
fallback_group_id:
editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request:
@@ -5917,6 +6179,18 @@ const handleUpdateGroup = async () => {
payload.video_price_480p = emptyPriceToClear(payload.video_price_480p);
payload.video_price_720p = emptyPriceToClear(payload.video_price_720p);
payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p);
payload.search_price_per_1k = emptyPriceToClear(
payload.search_price_per_1k,
);
payload.audio_realtime_price_per_min = emptyPriceToClear(
payload.audio_realtime_price_per_min,
);
payload.audio_tts_price_per_million_chars = emptyPriceToClear(
payload.audio_tts_price_per_million_chars,
);
payload.audio_stt_price_per_hour = emptyPriceToClear(
payload.audio_stt_price_per_hour,
);
payload.web_search_price_per_call = emptyPriceToClear(
payload.web_search_price_per_call,
);
+161
View File
@@ -4871,6 +4871,78 @@
<Toggle v-model="form.allow_ungrouped_key_scheduling" />
</div>
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<div class="mb-3">
<label class="font-medium text-gray-900 dark:text-white">
{{
t(
"admin.settings.scheduling.accountSchedulingThresholdsTitle",
)
}}
</label>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.scheduling.accountSchedulingThresholdsDescription",
)
}}
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.scheduling.accountSchedulingThresholdsGlobalHint",
)
}}
</p>
<p class="mt-0.5 text-xs text-amber-600 dark:text-amber-400">
{{
t(
"admin.settings.scheduling.accountSchedulingThresholdsDisabledHint",
)
}}
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="platform in schedulingThresholdPlatforms"
:key="platform"
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
>
<div class="flex items-start justify-between gap-3">
<div>
<label
class="font-mono text-sm font-medium text-gray-900 dark:text-white"
>
{{ platform }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.scheduling.accountSchedulingThresholdsRangeHint",
)
}}
</p>
</div>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
%
</span>
</div>
<input
v-model.number="form.account_scheduling_thresholds[platform]"
type="number"
min="1"
max="100"
step="1"
class="input mt-3"
:data-testid="`account-scheduling-threshold-${platform}`"
placeholder="100"
/>
</div>
</div>
</div>
<div
v-if="!form.openai_advanced_scheduler_enabled"
class="flex items-center justify-between border-t border-gray-100 pt-5 dark:border-dark-700"
@@ -5066,6 +5138,71 @@
</p>
</div>
<div class="space-y-5 p-6">
<div class="grid gap-5 border-b border-gray-100 pb-5 dark:border-dark-700 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<div>
<label
for="grok-default-text-model"
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.gatewayForwarding.grokDefaultTextModel") }}
</label>
<input
id="grok-default-text-model"
v-model.trim="form.grok_default_text_model"
type="text"
class="input mt-2 w-full"
list="grok-default-text-model-options"
data-testid="grok-default-text-model"
placeholder="grok-4.5"
/>
<datalist id="grok-default-text-model-options">
<option value="grok-4.5" />
<option value="grok-4.1-fast" />
<option value="grok-4" />
</datalist>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.grokDefaultTextModelHint") }}
</p>
</div>
<div class="flex items-center justify-between gap-5 md:min-w-72">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.gatewayForwarding.grokCrossClientMap") }}
</label>
<p class="mt-0.5 max-w-sm text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.grokCrossClientMapHint") }}
</p>
</div>
<Toggle
v-model="form.grok_cross_client_model_map_enabled"
data-testid="grok-cross-client-model-map-toggle"
/>
</div>
</div>
<div class="md:col-span-2">
<label
for="grok-default-base-url-mode"
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.gatewayForwarding.grokDefaultBaseURLMode") }}
</label>
<select
id="grok-default-base-url-mode"
v-model="form.grok_default_base_url_mode"
class="input mt-2 w-full"
data-testid="grok-default-base-url-mode"
>
<option value="cli">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeCLI") }}</option>
<option value="api">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeAPI") }}</option>
<option value="us-east-1">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeUSEast1") }}</option>
<option value="us-west-2">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeUSWest2") }}</option>
<option value="eu-west-1">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeEUWest1") }}</option>
</select>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.grokDefaultBaseURLModeHint") }}
</p>
</div>
<!-- Fingerprint Unification -->
<div class="flex items-center justify-between">
<div>
@@ -8505,8 +8642,11 @@ import { adminAPI } from "@/api";
import {
appendAuthSourceDefaultsToUpdateRequest,
buildAuthSourceDefaultsState,
normalizeAccountSchedulingThresholdsMap,
normalizePlatformQuotasMap,
sanitizeAccountSchedulingThresholdsMap,
sanitizePlatformQuotasMap,
SCHEDULING_THRESHOLD_PLATFORMS,
defaultWeChatConnectScopesForMode,
deriveWeChatConnectStoredMode,
normalizeDefaultSubscriptionSettings,
@@ -9252,8 +9392,11 @@ type SettingsForm = Omit<
openai_advanced_scheduler_weight_session_sticky: string;
// 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量)
default_platform_quotas: DefaultPlatformQuotasMap;
account_scheduling_thresholds: ReturnType<typeof normalizeAccountSchedulingThresholdsMap>;
};
const schedulingThresholdPlatforms = SCHEDULING_THRESHOLD_PLATFORMS;
const form = reactive<SettingsForm>({
registration_enabled: true,
email_verify_enabled: false,
@@ -9276,6 +9419,7 @@ const form = reactive<SettingsForm>({
login_agreement_documents: defaultLoginAgreementDocuments(),
default_balance: 0,
default_platform_quotas: normalizePlatformQuotasMap() as DefaultPlatformQuotasMap,
account_scheduling_thresholds: normalizeAccountSchedulingThresholdsMap(),
affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
affiliate_rebate_duration_days: 0,
@@ -9455,6 +9599,9 @@ const form = reactive<SettingsForm>({
fallback_model_openai: "gpt-4o",
fallback_model_gemini: "gemini-2.5-pro",
fallback_model_antigravity: "gemini-2.5-pro",
grok_default_text_model: "grok-4.5",
grok_cross_client_model_map_enabled: false,
grok_default_base_url_mode: "cli",
// Identity patch (Claude -> Gemini)
enable_identity_patch: true,
identity_patch_prompt: "",
@@ -10533,6 +10680,9 @@ async function loadSettings() {
: defaultLoginAgreementDocuments();
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas);
form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap(
settings.account_scheduling_thresholds,
);
form.backend_mode_enabled = settings.backend_mode_enabled;
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
settings.default_subscriptions,
@@ -11037,6 +11187,11 @@ async function saveSettings() {
fallback_model_openai: form.fallback_model_openai,
fallback_model_gemini: form.fallback_model_gemini,
fallback_model_antigravity: form.fallback_model_antigravity,
grok_default_text_model:
form.grok_default_text_model.trim() || "grok-4.5",
grok_cross_client_model_map_enabled:
form.grok_cross_client_model_map_enabled,
grok_default_base_url_mode: form.grok_default_base_url_mode,
enable_identity_patch: form.enable_identity_patch,
identity_patch_prompt: form.identity_patch_prompt,
min_claude_code_version: form.min_claude_code_version,
@@ -11205,6 +11360,9 @@ async function saveSettings() {
}
payload.default_platform_quotas = sanitizePlatformQuotasMap(form.default_platform_quotas);
payload.account_scheduling_thresholds = sanitizeAccountSchedulingThresholdsMap(
form.account_scheduling_thresholds,
);
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
const updated = await settingsStepUp.run(() =>
@@ -11218,6 +11376,9 @@ async function saveSettings() {
}
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
form.default_platform_quotas = normalizePlatformQuotasMap(updated.default_platform_quotas);
form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap(
updated.account_scheduling_thresholds,
);
registrationEmailSuffixWhitelistTags.value =
normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist,
+4 -2
View File
@@ -450,6 +450,7 @@ const loadModelStats = async (source: ModelDistributionSource, force = false) =>
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
upstream_model_mismatch: filters.value.upstream_model_mismatch,
}
const response = await adminAPI.dashboard.getModelStats({ ...baseParams, model_source: source })
@@ -499,6 +500,7 @@ const loadChartData = async () => {
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
upstream_model_mismatch: filters.value.upstream_model_mismatch,
include_stats: false,
include_trend: true,
include_model_stats: false,
@@ -573,7 +575,7 @@ const exportToExcel = async () => {
const XLSX = await import('xlsx')
const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('usage.upstreamModel'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('admin.usage.account'), t('usage.requestedModel'), t('usage.sentUpstreamModel'), t('usage.upstreamResponseModel'), t('usage.upstreamModelMismatch'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
@@ -593,7 +595,7 @@ const exportToExcel = async () => {
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
log.upstream_model || '', formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.upstream_model || log.model, log.upstream_response_model || '', log.upstream_model_mismatch == null ? '' : t(log.upstream_model_mismatch ? 'common.yes' : 'common.no'), formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log),
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
@@ -10,6 +10,7 @@ const {
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
listAccounts,
showError,
showSuccess,
@@ -21,6 +22,7 @@ const {
getModelsListCandidates: vi.fn(),
getUsageSummary: vi.fn(),
getCapacitySummary: vi.fn(),
getLiveCapability: vi.fn(),
listAccounts: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
@@ -51,6 +53,7 @@ vi.mock('@/api/admin', () => ({
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
@@ -244,6 +247,7 @@ describe('admin GroupsView column settings', () => {
getModelsListCandidates.mockResolvedValue([])
getUsageSummary.mockResolvedValue([])
getCapacitySummary.mockResolvedValue([])
getLiveCapability.mockResolvedValue({ supported: false })
listAccounts.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
isCurrentStep.mockReturnValue(false)
})
@@ -11,6 +11,7 @@ const {
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
showSuccess,
showError
} = vi.hoisted(() => ({
@@ -19,6 +20,7 @@ const {
getModelsListCandidates: vi.fn(),
getUsageSummary: vi.fn(),
getCapacitySummary: vi.fn(),
getLiveCapability: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
@@ -31,6 +33,7 @@ vi.mock('@/api/admin', () => ({
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -166,6 +169,7 @@ describe('GroupsView duplicate action', () => {
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
showSuccess,
showError
]) {
@@ -188,6 +192,7 @@ describe('GroupsView duplicate action', () => {
getModelsListCandidates.mockResolvedValue([])
getUsageSummary.mockResolvedValue([])
getCapacitySummary.mockResolvedValue([])
getLiveCapability.mockResolvedValue({ supported: false })
})
afterEach(() => {
@@ -444,6 +444,8 @@ const baseSettingsResponse = {
fallback_model_openai: "",
fallback_model_gemini: "",
fallback_model_antigravity: "",
grok_default_text_model: "grok-4.5",
grok_cross_client_model_map_enabled: false,
enable_identity_patch: false,
identity_patch_prompt: "",
ops_monitoring_enabled: false,
@@ -1265,6 +1267,34 @@ describe("admin SettingsView payment visible method controls", () => {
expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存");
});
it("loads and saves configurable Grok cross-client model mapping", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
grok_default_text_model: "grok-4.1-fast",
grok_cross_client_model_map_enabled: true,
});
const wrapper = mountView();
await flushPromises();
await openGatewayTab(wrapper);
const modelInput = wrapper.get('[data-testid="grok-default-text-model"]');
const mappingToggle = wrapper.get(
'[data-testid="grok-cross-client-model-map-toggle"]',
);
expect((modelInput.element as HTMLInputElement).value).toBe("grok-4.1-fast");
expect((mappingToggle.element as HTMLInputElement).checked).toBe(true);
await modelInput.setValue("grok-custom-text");
await mappingToggle.setValue(false);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
const payload = updateSettings.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(payload.grok_default_text_model).toBe("grok-custom-text");
expect(payload.grok_cross_client_model_map_enabled).toBe(false);
});
it("loads fail-safe-off Ollama Cloud usage refresh settings and saves an explicit opt-in", async () => {
const wrapper = mountView();
@@ -4,7 +4,7 @@ import { defineComponent, ref } from 'vue'
import UsageView from '../UsageView.vue'
const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery } = vi.hoisted(() => {
const { list, exportList, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery, aoaToSheet, sheetAddAoa, saveAs, xlsxWrite } = vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
@@ -13,12 +13,17 @@ const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, ro
return {
list: vi.fn(),
exportList: vi.fn(),
getStats: vi.fn(),
getSnapshotV2: vi.fn(),
getById: vi.fn(),
getModelStats: vi.fn(),
listErrorLogs: vi.fn(),
routeQuery: {} as Record<string, string>,
aoaToSheet: vi.fn(() => ({})),
sheetAddAoa: vi.fn(),
saveAs: vi.fn(),
xlsxWrite: vi.fn(() => new Uint8Array([1, 2, 3])),
}
})
@@ -27,6 +32,12 @@ const messages: Record<string, string> = {
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.usage.failedToLoadUser': 'Failed to load user',
'usage.requestedModel': 'Requested model',
'usage.sentUpstreamModel': 'Sent upstream model',
'usage.upstreamResponseModel': 'Upstream response model',
'usage.upstreamModelMismatch': 'Upstream model mismatch',
'common.yes': 'Yes',
'common.no': 'No',
}
const formatLocalDate = (date: Date): string => {
@@ -54,10 +65,22 @@ vi.mock('@/api/admin', () => ({
vi.mock('@/api/admin/usage', () => ({
adminUsageAPI: {
list: vi.fn(),
list: exportList,
},
}))
vi.mock('file-saver', () => ({ saveAs }))
vi.mock('xlsx', () => ({
utils: {
aoa_to_sheet: aoaToSheet,
sheet_add_aoa: sheetAddAoa,
book_new: vi.fn(() => ({})),
book_append_sheet: vi.fn(),
},
write: xlsxWrite,
}))
vi.mock('@/api/admin/ops', () => ({
listErrorLogs,
}))
@@ -539,3 +562,62 @@ describe('admin UsageView ranking tab', () => {
expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 5 }), expect.anything())
})
})
describe('admin UsageView model audit export', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset().mockResolvedValue({ items: [], total: 0, pages: 0 })
exportList.mockReset().mockResolvedValue({
items: [{
id: 1,
created_at: '2026-08-04T00:00:00Z',
model: 'gpt-5.6-sol',
upstream_model: 'gpt-5.5',
upstream_response_model: 'gpt-5.4',
upstream_model_mismatch: true,
request_type: 'sync',
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_creation_tokens: 0,
duration_ms: 10,
}],
total: 1,
pages: 1,
})
getStats.mockReset().mockResolvedValue({
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
})
getSnapshotV2.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] })
getModelStats.mockReset().mockResolvedValue({ models: [] })
aoaToSheet.mockClear()
sheetAddAoa.mockClear()
saveAs.mockClear()
xlsxWrite.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('exports requested, sent, response, and mismatch as separate admin columns', async () => {
const wrapper = mountRouteFilteredUsageView()
vi.advanceTimersByTime(120)
await flushPromises()
await (wrapper.vm as any).exportToExcel()
await flushPromises()
const headers = aoaToSheet.mock.calls[0][0][0]
expect(headers.slice(4, 8)).toEqual([
'Requested model',
'Sent upstream model',
'Upstream response model',
'Upstream model mismatch',
])
const row = sheetAddAoa.mock.calls[0][1][0]
expect(row.slice(4, 8)).toEqual(['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4', 'Yes'])
expect(saveAs).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
createVideoModelPricesForm,
serializeVideoModelPrices,
videoModelPriceFamilyRows
} from '../groupsVideoModelPricing'
describe('Grok video model pricing form', () => {
it('provides editable rows for both canonical Grok video families', () => {
const form = createVideoModelPricesForm()
expect(videoModelPriceFamilyRows(form).map(({ key }) => key)).toEqual([
'grok-imagine-video',
'grok-imagine-video-1.5'
])
expect(form['grok-imagine-video']['480p']).toBeNull()
expect(form['grok-imagine-video-1.5']['1080p']).toBeNull()
})
it('serializes only finite non-negative prices and preserves future families', () => {
const form = createVideoModelPricesForm({
'grok-imagine-video-2': { '1080p': 0.4 }
})
form['grok-imagine-video']['480p'] = 0.05
form['grok-imagine-video']['720p'] = ''
form['grok-imagine-video-1.5']['1080p'] = -1
expect(serializeVideoModelPrices(form)).toEqual({
'grok-imagine-video': { '480p': 0.05 },
'grok-imagine-video-2': { '1080p': 0.4 }
})
})
it('round-trips unknown model families so editing does not discard them', () => {
const form = createVideoModelPricesForm({
'grok-imagine-video-2': { '480p': 0.2 }
})
expect(videoModelPriceFamilyRows(form).map(({ key }) => key)).toContain(
'grok-imagine-video-2'
)
expect(serializeVideoModelPrices(form)).toMatchObject({
'grok-imagine-video-2': { '480p': 0.2 }
})
})
})
@@ -0,0 +1,77 @@
export const grokVideoPriceResolutions = [
{ key: '480p', label: '480p' },
{ key: '720p', label: '720p' },
{ key: '1080p', label: '1080p' }
] as const
export const grokVideoPriceFamilies = [
{ key: 'grok-imagine-video', label: 'grok-imagine-video' },
{ key: 'grok-imagine-video-1.5', label: 'grok-imagine-video-1.5' }
] as const
export type VideoModelPrices = Record<string, Record<string, number>>
export type VideoModelPricesForm = Record<string, Record<string, number | string | null>>
function normalizeFamily(value: string): string {
return value.trim().toLowerCase()
}
function normalizePrice(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const price = Number(value)
return Number.isFinite(price) && price >= 0 ? price : null
}
function emptyTiers(): Record<string, number | string | null> {
return Object.fromEntries(grokVideoPriceResolutions.map(({ key }) => [key, null]))
}
// Keep unknown families from an existing group so a future backend catalog is
// not silently discarded when an operator edits another group setting.
export function createVideoModelPricesForm(
prices?: VideoModelPrices | null
): VideoModelPricesForm {
const form: VideoModelPricesForm = {}
for (const [rawFamily, rawTiers] of Object.entries(prices ?? {})) {
const family = normalizeFamily(rawFamily)
if (!family || !rawTiers || typeof rawTiers !== 'object') continue
form[family] = emptyTiers()
for (const [rawResolution, rawPrice] of Object.entries(rawTiers)) {
const price = normalizePrice(rawPrice)
if (price !== null) form[family][rawResolution.trim().toLowerCase()] = price
}
}
for (const { key } of grokVideoPriceFamilies) {
form[key] ??= emptyTiers()
}
return form
}
export function serializeVideoModelPrices(form: VideoModelPricesForm): VideoModelPrices {
const result: VideoModelPrices = {}
for (const [rawFamily, tiers] of Object.entries(form)) {
const family = normalizeFamily(rawFamily)
if (!family || !tiers || typeof tiers !== 'object') continue
const normalizedTiers: Record<string, number> = {}
for (const [rawResolution, rawPrice] of Object.entries(tiers)) {
const resolution = rawResolution.trim().toLowerCase()
const price = normalizePrice(rawPrice)
if (resolution && price !== null) normalizedTiers[resolution] = price
}
if (Object.keys(normalizedTiers).length > 0) result[family] = normalizedTiers
}
return result
}
export function videoModelPriceFamilyRows(form: VideoModelPricesForm) {
const known = new Set<string>(grokVideoPriceFamilies.map(({ key }) => key))
const extra = Object.keys(form)
.map(normalizeFamily)
.filter((family) => family && !known.has(family))
.sort()
.map((key) => ({ key, label: key }))
return [...grokVideoPriceFamilies, ...extra]
}
@@ -114,6 +114,8 @@
<OpsErrorDetailsModal
:show="showErrorDetails"
:time-range="timeRange"
:custom-start-time="customStartTime"
:custom-end-time="customEndTime"
:platform="platform"
:group-id="groupId"
:error-type="errorDetailsType"
@@ -5,10 +5,13 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import OpsErrorLogTable from './OpsErrorLogTable.vue'
import { opsAPI, type OpsErrorLog } from '@/api/admin/ops'
import { buildOpsErrorTimeParams } from '../utils/opsErrorParams'
interface Props {
show: boolean
timeRange: string
customStartTime?: string | null
customEndTime?: string | null
platform?: string
groupId?: number | null
errorType: 'request' | 'upstream'
@@ -103,11 +106,11 @@ async function fetchErrorLogs() {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize.value,
time_range: props.timeRange,
view: viewMode.value,
sort_by: sortBy.value,
sort_order: sortOrder.value
}
Object.assign(params, buildOpsErrorTimeParams(props.timeRange, props.customStartTime, props.customEndTime))
const platform = String(props.platform || '').trim()
if (platform) params.platform = platform
@@ -160,7 +163,7 @@ watch(
)
watch(
() => [props.timeRange, props.platform, props.groupId] as const,
() => [props.timeRange, props.customStartTime, props.customEndTime, props.platform, props.groupId] as const,
() => {
if (!props.show) return
page.value = 1
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { buildOpsErrorTimeParams } from '../opsErrorParams'
describe('buildOpsErrorTimeParams', () => {
it('uses explicit timestamps for a complete custom range', () => {
expect(buildOpsErrorTimeParams('custom', '2026-08-01T00:00:00Z', '2026-08-02T00:00:00Z')).toEqual({
start_time: '2026-08-01T00:00:00Z',
end_time: '2026-08-02T00:00:00Z'
})
})
it('preserves predefined ranges and falls back for incomplete custom ranges', () => {
expect(buildOpsErrorTimeParams('24h')).toEqual({ time_range: '24h' })
expect(buildOpsErrorTimeParams('custom', null, null)).toEqual({ time_range: '1h' })
})
})
@@ -0,0 +1,11 @@
export function buildOpsErrorTimeParams(
timeRange: string,
customStartTime?: string | null,
customEndTime?: string | null
): Record<string, string> {
if (timeRange === 'custom' && customStartTime && customEndTime) {
return { start_time: customStartTime, end_time: customEndTime }
}
return { time_range: timeRange === 'custom' ? '1h' : timeRange }
}
+2 -5
View File
@@ -231,7 +231,7 @@ import Icon from '@/components/icons/Icon.vue'
import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { formatReasoningEffort } from '@/utils/format'
import { BILLING_MODE_IMAGE, getBillingModeLabel } from '@/utils/billingMode'
import { getBillingModeLabel, getDisplayBillingMode as resolveDisplayBillingMode } from '@/utils/billingMode'
import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType'
import type {
ApiKey,
@@ -605,10 +605,7 @@ const getRequestTypeExportText = (log: UsageLog): string => {
const getDisplayBillingMode = (
row: Pick<UsageLog, 'billing_mode' | 'image_count'> | null | undefined
): string | null | undefined => {
if ((row?.image_count ?? 0) > 0) return BILLING_MODE_IMAGE
return row?.billing_mode
}
): string | null | undefined => resolveDisplayBillingMode(row)
const escapeCSVValue = (value: unknown): string => {
if (value == null) return ''