Merge remote-tracking branch 'origin/main' into feat/grok-sso-device-oauth

# Conflicts:
#	frontend/src/api/admin/grok.ts
This commit is contained in:
shaw
2026-07-14 10:19:16 +08:00
147 changed files with 8735 additions and 597 deletions
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
ADMIN_UI_REQUEST_HEADER,
shouldMarkAdminUIRequest,
} from '@/api/adminUIRequest'
describe('Admin UI request marker', () => {
it('uses the stable request header name', () => {
expect(ADMIN_UI_REQUEST_HEADER).toBe('X-Admin-UI-Request')
})
it.each([
'/admin',
'/admin/users',
'/api/v1/admin',
'/api/v1/admin/accounts?status=active',
'https://api.example.test/api/v1/admin/dashboard',
])('marks Admin API request %s before page navigation', (requestURL) => {
expect(shouldMarkAdminUIRequest(requestURL, '/login')).toBe(true)
})
it.each(['/keys', '/groups/available', '/auth/me', '/announcements'])(
'marks shared request %s while an Admin page is active',
(requestURL) => {
expect(shouldMarkAdminUIRequest(requestURL, '/admin/dashboard')).toBe(true)
}
)
it.each([
['/keys', '/dashboard'],
['/api/v1/administer', '/dashboard'],
['/keys', '/administrator'],
['', '/'],
])('does not mark request %s on page %s', (requestURL, pagePath) => {
expect(shouldMarkAdminUIRequest(requestURL, pagePath)).toBe(false)
})
})
+50
View File
@@ -12,6 +12,7 @@ describe('API Client', () => {
beforeEach(async () => {
localStorage.clear()
window.history.replaceState({}, '', '/')
// 每次测试重新导入以获取干净的模块状态
vi.resetModules()
const mod = await import('@/api/client')
@@ -120,6 +121,55 @@ describe('API Client', () => {
const config = adapter.mock.calls[0][0]
expect(config.withCredentials).toBe(true)
})
it('Admin API 在进入管理页面前也带 Admin UI 标记', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/admin/users')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
})
it('管理页面调用共享 API 时带 Admin UI 标记', async () => {
window.history.replaceState({}, '', '/admin/dashboard')
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/groups/available')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
})
it('普通用户页面调用共享 API 时不带 Admin UI 标记', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/groups/available')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
})
})
// --- 响应拦截器 ---
+10 -9
View File
@@ -4,6 +4,9 @@
*/
import { apiClient } from '../client'
import type { GrokBillingSummary, GrokQuotaWindow, WindowStats } from '@/types'
export type { GrokBillingSummary, GrokQuotaWindow } from '@/types'
export interface GrokAuthUrlResponse {
auth_url: string
@@ -79,13 +82,6 @@ export function getGrokSSOImportTimeout(keyCount: number): number {
return batches * GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS + GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS
}
export interface GrokQuotaWindow {
limit?: number | null
remaining?: number | null
reset_unix?: number | null
reset_at?: string | null
}
export interface GrokQuotaSnapshot {
requests?: GrokQuotaWindow | null
tokens?: GrokQuotaWindow | null
@@ -102,13 +98,18 @@ export interface GrokQuotaSnapshot {
}
export interface GrokQuotaProbeResult {
source: 'active_probe'
model: string
source: 'active_probe' | 'billing_probe' | 'hybrid_probe'
model?: string
billing?: GrokBillingSummary | null
snapshot?: GrokQuotaSnapshot | null
local_usage_7d?: WindowStats | null
local_usage_monthly?: WindowStats | null
status_code?: number
headers_observed: boolean
reset_supported: boolean
fetched_at: number
persisted?: boolean
probe_error?: string
}
export interface GrokQuotaResetResult {
+3
View File
@@ -828,6 +828,7 @@ export interface OpsRuntimeLogConfig {
export interface OpsSystemLog {
id: number
created_at: string
host: string
level: string
component: string
message: string
@@ -849,6 +850,7 @@ export interface OpsSystemLogQuery {
time_range?: '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d'
start_time?: string
end_time?: string
host?: string
level?: string
component?: string
request_id?: string
@@ -864,6 +866,7 @@ export interface OpsSystemLogQuery {
export interface OpsSystemLogCleanupRequest {
start_time?: string
end_time?: string
host?: string
level?: string
component?: string
request_id?: string
+27
View File
@@ -0,0 +1,27 @@
export const ADMIN_UI_REQUEST_HEADER = 'X-Admin-UI-Request'
function isAdminPath(path: string): boolean {
return (
path === '/admin' ||
path.startsWith('/admin/') ||
path === '/api/v1/admin' ||
path.startsWith('/api/v1/admin/')
)
}
function requestPath(rawURL: string): string {
const value = rawURL.trim()
if (!value) return ''
try {
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
return new URL(value, origin).pathname
} catch {
return value.split(/[?#]/, 1)[0]
}
}
export function shouldMarkAdminUIRequest(requestURL: string, pagePath?: string): boolean {
const currentPath =
pagePath ?? (typeof window !== 'undefined' ? window.location.pathname : '')
return isAdminPath(requestPath(requestURL)) || isAdminPath(currentPath)
}
+5
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiResponse } from '@/types'
import { getLocale } from '@/i18n'
import { ADMIN_UI_REQUEST_HEADER, shouldMarkAdminUIRequest } from './adminUIRequest'
import { getAPIBaseURL } from './url'
export { buildApiUrl, buildGatewayUrl } from './url'
@@ -74,6 +75,10 @@ apiClient.interceptors.request.use(
config.params.timezone = getUserTimezone()
}
if (config.headers && shouldMarkAdminUIRequest(String(config.url || ''))) {
config.headers[ADMIN_UI_REQUEST_HEADER] = '1'
}
return config
},
(error) => {
@@ -382,7 +382,15 @@
</div>
</div>
<UsageProgressBar
v-if="grokRequestQuotaBar"
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"
@@ -390,13 +398,20 @@
color="indigo"
/>
<UsageProgressBar
v-if="grokTokenQuotaBar"
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="2M"
: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>
@@ -409,7 +424,7 @@
<div v-if="grokQuotaStatusLine" class="text-[10px] text-gray-500 dark:text-gray-400">
{{ grokQuotaStatusLine }}
</div>
<GrokQuotaProbeCell :account="account" />
<GrokQuotaProbeCell :account="account" @probed="handleGrokProbed" />
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
@@ -602,6 +617,7 @@
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'
@@ -614,6 +630,8 @@ import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
// xAI Free billing exposes a window without usage_percent, so estimate it from local tokens.
const GROK_FREE_TOKEN_LIMIT = 2_000_000
const props = withDefaults(
defineProps<{
@@ -1047,9 +1065,62 @@ const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number |
const grokRequestQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_request_quota))
const grokTokenQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_token_quota))
const grokLocalUsage = computed(() =>
props.todayStats ||
usageInfo.value?.grok_local_usage ||
usageInfo.value?.grok_local_usage_7d ||
usageInfo.value?.grok_local_usage_monthly ||
null
)
const grokFreeQuotaUsage = computed(() =>
usageInfo.value?.grok_local_usage_7d ||
props.todayStats ||
usageInfo.value?.grok_local_usage ||
null
)
const grokBilling = computed(() => usageInfo.value?.grok_billing || null)
const grokWeeklyBillingBar = computed((): GrokQuotaBarInfo | null => {
const billing = grokBilling.value
if (billing?.period_type?.toLowerCase() !== 'weekly' || billing.usage_percent == null) {
return null
}
return {
utilization: Math.min(100, Math.max(0, billing.usage_percent)),
resetsAt: billing.period_end || null
}
})
const grokPlanLabelIsFree = (value: string) => value.includes('free') || value.includes('basic')
const grokPlanLabelIsPaid = (value: string) => {
return value !== '' && !grokPlanLabelIsFree(value) && !value.includes('unknown')
}
const grokIsFree = computed(() => {
if (props.account.platform !== 'grok' || props.account.type !== 'oauth') return false
const billing = grokBilling.value
if (
billing?.usage_percent != null ||
billing?.used_percent != null ||
(billing?.monthly_limit_cents != null && billing.monthly_limit_cents > 0)
) return false
const plan = (billing?.plan || '').trim().toLowerCase()
const tier = (usageInfo.value?.subscription_tier || '').trim().toLowerCase()
const entitlement = (usageInfo.value?.grok_entitlement_status || '').toLowerCase()
if (grokPlanLabelIsPaid(plan) || grokPlanLabelIsPaid(tier)) return false
if (
grokPlanLabelIsFree(plan) ||
grokPlanLabelIsFree(tier) ||
grokPlanLabelIsFree(entitlement)
) return true
return billing != null
})
const grokFreeTokenBar = computed(() => {
if (!grokIsFree.value || !grokFreeQuotaUsage.value) return null
const used = Math.max(0, grokFreeQuotaUsage.value.tokens || 0)
return { utilization: Math.min(100, (used / GROK_FREE_TOKEN_LIMIT) * 100) }
})
const grokQuotaUnknown = computed(() => {
if (props.account.platform !== 'grok') return false
if (grokRequestQuotaBar.value || grokTokenQuotaBar.value) return false
if (grokBilling.value || grokFreeTokenBar.value || grokRequestQuotaBar.value || grokTokenQuotaBar.value) return false
return usageInfo.value?.grok_quota_snapshot_state !== 'observed'
})
const grokQuotaUnknownLabel = computed(() => {
@@ -1080,7 +1151,6 @@ const grokQuotaStatusLine = computed(() => {
}
return parts.length > 0 ? parts.join(' | ') : null
})
const grokLocalUsage = computed(() => usageInfo.value?.grok_local_usage || props.todayStats || null)
const grokEntitlementLabel = computed(() => {
const status = (usageInfo.value?.grok_entitlement_status || '').trim()
return status || null
@@ -1283,6 +1353,34 @@ const loadActiveUsage = async () => {
}
}
const handleGrokProbed = (result: GrokQuotaProbeResult) => {
const current = usageInfo.value
if (!current) return
const snapshot = result.snapshot
const merged: AccountUsageInfo = {
...current,
grok_billing: result.billing ?? current.grok_billing,
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: snapshot?.entitlement_status || current.grok_entitlement_status,
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,
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() })
}
// ===== API Key quota progress bars =====
interface QuotaBarInfo {
@@ -2824,6 +2824,38 @@
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between gap-4">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.longContextBilling') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.longContextBillingDesc') }}
</p>
</div>
<button
type="button"
data-testid="openai-long-context-billing-toggle"
role="switch"
:aria-checked="openAILongContextBillingEnabled"
@click="toggleOpenAILongContextBilling"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openAILongContextBillingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openAILongContextBillingEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
@@ -3681,6 +3713,8 @@ const fillHeaderOverrideTemplate = () => {
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
const openAILongContextBillingTouched = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
@@ -3693,6 +3727,11 @@ const anthropicPassthroughEnabled = ref(false)
const anthropicAPIKeyAuthScheme = ref<AnthropicAPIKeyAuthScheme>('x_api_key')
const webSearchEmulationMode = ref('default')
const webSearchGlobalEnabled = ref(false)
const toggleOpenAILongContextBilling = () => {
openAILongContextBillingEnabled.value = !openAILongContextBillingEnabled.value
openAILongContextBillingTouched.value = true
}
const {
globalEnabled: quotaNotifyGlobalEnabled,
state: quotaNotifyState,
@@ -4537,6 +4576,8 @@ const resetForm = () => {
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
openAILongContextBillingEnabled.value = false
openAILongContextBillingTouched.value = false
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
@@ -4619,6 +4660,7 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
delete extra.openai_passthrough
delete extra.openai_oauth_passthrough
}
extra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
if (accountCategory.value === 'oauth-based' && codexCLIOnlyEnabled.value) {
extra.codex_cli_only = true
@@ -4654,6 +4696,17 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
return Object.keys(extra).length > 0 ? extra : undefined
}
const buildOpenAICodexImportExtra = (): Record<string, unknown> | undefined => {
const extra = buildOpenAIExtra()
if (!extra) {
return undefined
}
if (!openAILongContextBillingTouched.value) {
delete extra.openai_long_context_billing_enabled
}
return Object.keys(extra).length > 0 ? extra : undefined
}
const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unknown> | undefined => {
if (form.platform !== 'anthropic' || accountCategory.value !== 'apikey') {
return base
@@ -5409,7 +5462,7 @@ const handleOpenAIImportCodexSession = async (content: string) => {
oauthClient.error.value = ''
try {
const extra = buildOpenAIExtra()
const extra = buildOpenAICodexImportExtra()
const result = await adminAPI.accounts.importCodexSession({
content: trimmed,
name: form.name,
@@ -5487,7 +5540,7 @@ const handleOpenAIImportCodexPAT = async (accessToken: string) => {
oauthClient.error.value = ''
try {
const extra = buildOpenAIExtra()
const extra = buildOpenAICodexImportExtra()
await adminAPI.accounts.createOpenAICodexPAT({
access_token: trimmed,
name: form.name,
@@ -1786,7 +1786,39 @@
/>
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<!-- OpenAI API 长上下文计费开关 -->
<div
v-if="account?.platform === 'openai' && !isSparkShadow && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between gap-4">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.longContextBilling') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.longContextBillingDesc') }}
</p>
</div>
<button
type="button"
data-testid="openai-long-context-billing-toggle"
role="switch"
:aria-checked="openAILongContextBillingEnabled"
@click="openAILongContextBillingEnabled = !openAILongContextBillingEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openAILongContextBillingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openAILongContextBillingEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
@@ -2783,6 +2815,7 @@ const customBaseUrl = ref('')
// OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
const editPlanType = ref<string>('')
const openAICompactMode = ref<OpenAICompactMode>('auto')
@@ -3216,6 +3249,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
openaiPassthroughEnabled.value = false
openAILongContextBillingEnabled.value = false
editPlanType.value = ''
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
@@ -3231,6 +3265,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
const longContextBillingValue = extra?.openai_long_context_billing_enabled
openAILongContextBillingEnabled.value = longContextBillingValue === true
// plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填
editPlanType.value = newAccount.type === 'oauth'
? readPlanType(newAccount.credentials as Record<string, unknown> | undefined)
@@ -4401,6 +4437,11 @@ const handleSubmit = async () => {
delete newExtra.openai_passthrough
delete newExtra.openai_oauth_passthrough
}
if (isSparkShadow.value) {
delete newExtra.openai_long_context_billing_enabled
} else {
newExtra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
}
if (openAICompactMode.value === 'auto') {
delete newExtra.openai_compact_mode
} else {
@@ -55,6 +55,8 @@ const props = defineProps<{
account: Account
}>()
const emit = defineEmits<{ probed: [result: GrokQuotaProbeResult] }>()
const { t } = useI18n()
const visible = computed(() => props.account.platform === 'grok' && props.account.type === 'oauth')
@@ -92,18 +94,27 @@ const retryAfterLabel = computed(() => {
const summary = computed(() => {
const snapshot = data.value?.snapshot
if (!data.value) return ''
if (!snapshot) return t('admin.accounts.usageWindow.grokNoHeaders')
const parts = [
formatWindow(t('admin.accounts.usageWindow.grokRequests'), snapshot.requests),
formatWindow(t('admin.accounts.usageWindow.grokTokens'), snapshot.tokens)
].filter(Boolean)
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', {
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) {
if (snapshot?.entitlement_status) {
parts.push(snapshot.entitlement_status)
}
return parts.length > 0 ? parts.join(' | ') : t('admin.accounts.usageWindow.grokNoHeaders')
const visibleParts = parts.filter((part): part is string => Boolean(part))
return visibleParts.length > 0 ? visibleParts.join(' | ') : t('admin.accounts.usageWindow.grokNoHeaders')
})
const truncatedError = computed(() => {
@@ -117,6 +128,8 @@ const handleProbe = async () => {
error.value = null
try {
data.value = await adminAPI.grok.queryQuota(props.account.id)
error.value = data.value.probe_error || null
emit('probed', data.value)
} catch (e) {
error.value = extractErrorMessage(e)
} finally {
@@ -660,6 +660,339 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25|true')
})
it('Grok OAuth uses the official weekly billing percentage when available', async () => {
getUsage.mockResolvedValue({
grok_billing: {
period_type: 'weekly',
usage_percent: 37,
period_end: '2026-07-16T03:25:00Z',
plan: 'SuperGrok'
},
grok_local_usage: {
requests: 5,
tokens: 2_200_000,
cost: 4.42,
standard_cost: 4.42,
user_cost: 0.44
},
grok_request_quota: { limit: 100, remaining: 100 },
grok_token_quota: { limit: 2_000_000, remaining: 2_000_000 }
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4201, platform: 'grok', type: 'oauth', extra: {} })
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'remainingCapacity'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}|{{ remainingCapacity }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('7d|37|2026-07-16T03:25:00Z')
expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokRequests|')
expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokTokens|')
expect(wrapper.text()).not.toContain('2M|')
})
it.each([
{ tokens: 0, expected: 0, compact: '0' },
{ tokens: 1_000_000, expected: 50, compact: '1.0M' },
{ tokens: 2_000_000, expected: 100, compact: '2.0M' },
{ tokens: 2_200_000, expected: 100, compact: '2.2M' }
])('Grok Free derives its 2M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected, compact }) => {
getUsage.mockResolvedValue({
grok_billing: {
period_type: 'weekly',
usage_percent: null,
plan: ''
},
grok_local_usage: {
requests: 5,
tokens,
cost: 0,
standard_cost: 0,
user_cost: 0
},
grok_request_quota: { limit: 100, remaining: 100 },
grok_token_quota: { limit: 2_000_000, remaining: 2_000_000 }
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4300 + expected, 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(`2M|${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|')
})
it('Grok Free uses the weekly billing window instead of today-only usage', async () => {
getUsage.mockResolvedValue({
grok_billing: { period_type: 'weekly', usage_percent: null, plan: '' },
grok_local_usage: {
requests: 2,
tokens: 200_000,
cost: 0,
standard_cost: 0
},
grok_local_usage_7d: {
requests: 12,
tokens: 1_500_000,
cost: 0,
standard_cost: 0
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4398, 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('2M|75')
expect(wrapper.text()).toContain('200.0K')
})
it('Grok Free falls back to refreshed today stats when weekly usage is unavailable', async () => {
getUsage.mockResolvedValue({
grok_billing: { period_type: 'weekly', usage_percent: null, plan: '' },
grok_local_usage: {
requests: 1,
tokens: 250_000,
cost: 0,
standard_cost: 0,
user_cost: 0
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4399, platform: 'grok', type: 'oauth', extra: {} }),
todayStats: {
requests: 4,
tokens: 1_000_000,
cost: 0,
standard_cost: 0,
user_cost: 0
}
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('2M|50')
expect(wrapper.text()).toContain('1.0M')
expect(wrapper.text()).not.toContain('250K')
})
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 2M fallback when billing is unavailable', async () => {
getUsage.mockResolvedValue({
subscription_tier: 'FREE',
grok_local_usage: {
requests: 3,
tokens: 1_000_000,
cost: 0,
standard_cost: 0
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({ id: 4403, 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('2M|50')
})
it('Grok manual probes merge billing, quota headers, and local usage', 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_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()).toContain('ACTIVE')
expect(wrapper.text()).not.toContain('stale error')
})
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
@@ -0,0 +1,213 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createAccountMock,
importCodexSessionMock,
createOpenAICodexPATMock,
} = vi.hoisted(() => ({
createAccountMock: vi.fn(),
importCodexSessionMock: vi.fn(),
createOpenAICodexPATMock: vi.fn(),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showWarning: vi.fn(),
}),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ isSimpleMode: true }),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
create: createAccountMock,
checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }),
importCodexSession: importCodexSessionMock,
createOpenAICodexPAT: createOpenAICodexPATMock,
},
settings: {
getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }),
getSettings: vi.fn().mockResolvedValue({}),
},
tlsFingerprintProfiles: {
list: vi.fn().mockResolvedValue([]),
},
},
}))
vi.mock('@/api/admin/accounts', () => ({
getAntigravityDefaultModelMapping: vi.fn().mockResolvedValue([]),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
import CreateAccountModal from '../CreateAccountModal.vue'
const BaseDialogStub = defineComponent({
name: 'BaseDialog',
props: { show: { type: Boolean, default: false } },
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
})
const OAuthAuthorizationFlowStub = defineComponent({
name: 'OAuthAuthorizationFlow',
emits: ['import-codex-session', 'import-codex-pat'],
template: `
<div>
<button data-testid="import-codex-session" @click="$emit('import-codex-session', 'session-json')">session</button>
<button data-testid="import-codex-pat" @click="$emit('import-codex-pat', 'pat-token')">pat</button>
</div>
`,
})
function mountModal() {
return mount(CreateAccountModal, {
props: { show: true, proxies: [], groups: [] },
global: {
stubs: {
BaseDialog: BaseDialogStub,
OAuthAuthorizationFlow: OAuthAuthorizationFlowStub,
ConfirmDialog: true,
Select: true,
Icon: true,
PlatformIcon: true,
ProxySelector: true,
ProxyAdBanner: true,
GroupSelector: true,
ModelWhitelistSelector: true,
QuotaLimitCard: true,
},
},
})
}
async function selectButtonByText(wrapper: ReturnType<typeof mountModal>, text: string) {
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes(text))
expect(button).toBeDefined()
await button?.trigger('click')
}
async function submitApiKeyAccount(platform: 'openai' | 'anthropic', enableLongContextBilling = false) {
const wrapper = mountModal()
await selectButtonByText(wrapper, platform === 'openai' ? 'OpenAI' : 'admin.accounts.claudeConsole')
if (platform === 'openai') {
await selectButtonByText(wrapper, 'API Key')
}
await wrapper.get('form#create-account-form input[type="text"]').setValue(`${platform} account`)
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
if (enableLongContextBilling) {
await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click')
}
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
}
async function openCodexImportStep(toggleClicks = 0) {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
for (let click = 0; click < toggleClicks; click += 1) {
await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click')
}
await wrapper.get('form#create-account-form input[type="text"]').setValue('Codex import')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
return wrapper
}
describe('CreateAccountModal OpenAI long-context billing', () => {
beforeEach(() => {
createAccountMock.mockReset().mockResolvedValue({})
importCodexSessionMock.mockReset().mockResolvedValue({
created: 1,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
warnings: [],
})
createOpenAICodexPATMock.mockReset().mockResolvedValue({})
})
it('sends false explicitly for normal OpenAI account creation by default', async () => {
await submitApiKeyAccount('openai')
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('sends true explicitly when OpenAI long-context billing is enabled', async () => {
await submitApiKeyAccount('openai', true)
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
})
it('omits the OpenAI setting for non-OpenAI account creation', async () => {
await submitApiKeyAccount('anthropic')
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
})
it('leaves Codex session import billing ownership to the backend', async () => {
const wrapper = await openCodexImportStep()
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
await flushPromises()
expect(importCodexSessionMock).toHaveBeenCalledTimes(1)
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
})
it('leaves Codex PAT import billing ownership to the backend', async () => {
const wrapper = await openCodexImportStep()
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
await flushPromises()
expect(createOpenAICodexPATMock).toHaveBeenCalledTimes(1)
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
})
it('sends explicit true for Codex session import after the toggle is enabled', async () => {
const wrapper = await openCodexImportStep(1)
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
await flushPromises()
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
})
it('sends explicit false for Codex session import after the toggle is changed back', async () => {
const wrapper = await openCodexImportStep(2)
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
await flushPromises()
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('sends explicit true for Codex PAT import after the toggle is enabled', async () => {
const wrapper = await openCodexImportStep(1)
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
await flushPromises()
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
})
it('sends explicit false for Codex PAT import after the toggle is changed back', async () => {
const wrapper = await openCodexImportStep(2)
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
await flushPromises()
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
})
@@ -395,6 +395,105 @@ describe('EditAccountModal', () => {
})
})
it('loads and submits the per-account OpenAI long-context billing toggle', async () => {
const account = buildAccount()
account.extra = {
openai_long_context_billing_enabled: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-long-context-billing-toggle"]')
expect(toggle.attributes('aria-checked')).toBe('true')
await toggle.trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('defaults legacy OpenAI accounts to long-context billing disabled', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-long-context-billing-toggle"]')
expect(toggle.attributes('aria-checked')).toBe('false')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('does not render or submit the long-context billing toggle for Spark shadow accounts', async () => {
const account = buildOpenAISparkShadowAccount()
account.extra = {
openai_long_context_billing_enabled: false
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.find('[data-testid="openai-long-context-billing-toggle"]').exists()).toBe(false)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty(
'openai_long_context_billing_enabled'
)
})
it('preserves an explicit OpenAI long-context billing opt-out', async () => {
const account = buildAccount()
account.extra = {
openai_long_context_billing_enabled: false
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-long-context-billing-toggle"]')
expect(toggle.attributes('aria-checked')).toBe('false')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('fails closed for malformed OpenAI long-context billing values', async () => {
const account = buildAccount()
account.extra = {
openai_long_context_billing_enabled: 'false'
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.get('[data-testid="openai-long-context-billing-toggle"]').attributes('aria-checked')).toBe('false')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('loads and submits Grok OAuth model mapping edits', async () => {
const account = buildGrokOAuthAccount()
updateAccountMock.mockReset()
@@ -0,0 +1,54 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import GrokQuotaProbeCell from '../GrokQuotaProbeCell.vue'
import type { Account } from '@/types'
const { queryQuota } = vi.hoisted(() => ({
queryQuota: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
grok: { queryQuota }
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params?.percent == null ? key : `${key}:${params.percent}`
})
}))
const account = {
id: 99,
platform: 'grok',
type: 'oauth'
} as Account
describe('GrokQuotaProbeCell', () => {
beforeEach(() => {
queryQuota.mockReset()
})
it('keeps billing data while exposing a failed Free quota fallback', async () => {
queryQuota.mockResolvedValue({
source: 'hybrid_probe',
billing: { period_type: 'weekly', usage_percent: null },
headers_observed: false,
reset_supported: false,
fetched_at: 1,
probe_error: 'upstream returned 402 for probe model "grok-4.5"'
})
const wrapper = mount(GrokQuotaProbeCell, { props: { account } })
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('upstream returned 402 for probe model "grok-4.5"')
expect(wrapper.emitted('probed')?.[0]?.[0]).toMatchObject({
billing: { period_type: 'weekly', usage_percent: null },
probe_error: 'upstream returned 402 for probe model "grok-4.5"'
})
})
})
@@ -250,6 +250,7 @@ import TextArea from '@/components/common/TextArea.vue'
import { Icon } from '@/components/icons'
import { useClipboard } from '@/composables/useClipboard'
import { buildApiUrl } from '@/api/client'
import { ADMIN_UI_REQUEST_HEADER } from '@/api/adminUIRequest'
import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types'
@@ -438,7 +439,8 @@ const startTest = async () => {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
[ADMIN_UI_REQUEST_HEADER]: '1'
},
body: JSON.stringify(requestBody),
signal: abortController.signal
@@ -168,6 +168,11 @@
<div class="text-sm">
<div class="flex items-center gap-1.5">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<span
v-if="row.long_context_billing_applied"
data-testid="long-context-billing-marker"
class="inline-flex items-center rounded px-1 py-px text-[10px] font-semibold leading-tight bg-amber-100 text-amber-700 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:ring-amber-500/30"
>x2</span>
<!-- Cost Detail Tooltip -->
<div
class="group relative"
@@ -120,6 +120,38 @@ describe('admin UsageTable tooltip', () => {
} as DOMRect)
})
it('marks only usage rows that actually applied long-context billing', () => {
const wrapper = mount(UsageTable, {
props: {
data: [
{
...baseImageRow,
request_id: 'req-long-context-enabled',
long_context_billing_applied: true,
},
{
...baseImageRow,
request_id: 'req-long-context-disabled',
long_context_billing_applied: false,
},
],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
expect(wrapper.findAll('[data-testid="long-context-billing-marker"]')).toHaveLength(1)
expect(wrapper.get('[data-testid="long-context-billing-marker"]').text()).toBe('x2')
})
it('shows service tier and billing breakdown in cost tooltip', async () => {
const row = {
request_id: 'req-admin-1',
@@ -402,6 +402,9 @@ export default {
oauthPassthrough: 'Auto passthrough (auth only)',
oauthPassthroughDesc:
'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.',
longContextBilling: 'API long-context pricing',
longContextBillingDesc:
'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.',
responsesWebsocketsV2: 'Responses WebSocket v2',
responsesWebsocketsV2Desc:
'Disabled by default. Enable to allow responses_websockets_v2 capability (still gated by global and account-type switches).',
@@ -1210,6 +1213,7 @@ export default {
claude: 'Claude',
grokRequests: 'Req',
grokTokens: 'Tok',
grokWeeklyUsage: 'Weekly {percent}%',
grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.',
grokRetryAfter: 'Retry after {time}',
grokProbe: 'Probe',
@@ -50,6 +50,7 @@ export default {
timeRange: 'Time range',
startTime: 'Start time (optional)',
endTime: 'End time (optional)',
host: 'Host',
component: 'Component',
componentPlaceholder: 'e.g. http.access',
keyId: 'KEY ID',
@@ -317,6 +317,7 @@ export default {
claude: 'Claude',
grokRequests: '请求',
grokTokens: 'Token',
grokWeeklyUsage: '周额度已用 {percent}%',
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
grokRetryAfter: '{time} 后重试',
grokProbe: '探测',
@@ -505,6 +506,8 @@ export default {
oauthPassthrough: '自动透传(仅替换认证)',
oauthPassthroughDesc:
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
longContextBilling: 'API 长上下文计费',
longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时开启。',
responsesWebsocketsV2: 'Responses WebSocket v2',
responsesWebsocketsV2Desc:
'默认关闭。开启后可启用 responses_websockets_v2 协议能力(受网关全局开关与账号类型开关约束)。',
@@ -50,6 +50,7 @@ export default {
timeRange: '时间范围',
startTime: '开始时间(可选)',
endTime: '结束时间(可选)',
host: 'Host',
component: '组件',
componentPlaceholder: '例如 http.access',
keyId: 'KEY ID',
+38 -4
View File
@@ -1020,10 +1020,38 @@ export interface AntigravityModelQuota {
}
export interface GrokQuotaWindow {
limit?: number
remaining?: number
reset_unix?: number
reset_at?: string
limit?: number | null
remaining?: number | null
reset_unix?: number | null
reset_at?: string | null
}
export interface GrokBillingProductUsage {
product: string
usage_percent?: number | null
}
export interface GrokBillingSummary {
period_type?: string
usage_percent?: number | null
period_start?: string
period_end?: string
product_usage?: GrokBillingProductUsage[]
monthly_limit_cents?: number | null
used_cents?: number | null
included_used_cents?: number | null
billing_period_start?: string
billing_period_end?: string
used_percent?: number | null
plan?: string
status_code?: number
source?: string
fetched_at?: string
updated_at?: string
weekly_updated_at?: string
monthly_updated_at?: string
partial?: boolean
failed_windows?: string[]
}
export interface AccountUsageInfo {
@@ -1049,6 +1077,11 @@ export interface AccountUsageInfo {
grok_last_headers_seen_at?: string
grok_last_status_code?: number
grok_local_usage?: WindowStats | null
grok_local_usage_7d?: WindowStats | null
grok_local_usage_monthly?: WindowStats | null
grok_billing?: GrokBillingSummary | null
subscription_tier?: string
subscription_tier_raw?: string
ai_credits?: Array<{
credit_type?: string
amount?: number
@@ -1348,6 +1381,7 @@ export interface UsageLog {
total_cost: number
actual_cost: number
rate_multiplier: number
long_context_billing_applied: boolean
billing_type: number
request_type?: UsageRequestType
@@ -48,6 +48,7 @@ const filters = reactive({
time_range: '1h' as '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d',
start_time: '',
end_time: '',
host: '',
level: '',
component: '',
request_id: '',
@@ -175,6 +176,7 @@ const buildQuery = () => {
}
if (filters.start_time) query.start_time = toRFC3339(filters.start_time)
if (filters.end_time) query.end_time = toRFC3339(filters.end_time)
if (filters.host.trim()) query.host = filters.host.trim()
if (filters.level.trim()) query.level = filters.level.trim()
if (filters.component.trim()) query.component = filters.component.trim()
if (filters.request_id.trim()) query.request_id = filters.request_id.trim()
@@ -288,6 +290,7 @@ const cleanupCurrentFilter = async () => {
const payload = {
start_time: toRFC3339(filters.start_time),
end_time: toRFC3339(filters.end_time),
host: filters.host.trim() || undefined,
level: filters.level.trim() || undefined,
component: filters.component.trim() || undefined,
request_id: filters.request_id.trim() || undefined,
@@ -313,6 +316,7 @@ const resetFilters = () => {
filters.time_range = '1h'
filters.start_time = ''
filters.end_time = ''
filters.host = ''
filters.level = ''
filters.component = ''
filters.request_id = ''
@@ -454,6 +458,10 @@ onMounted(async () => {
{{ t('admin.ops.systemLogs.component') }}
<input v-model="filters.component" type="text" class="input mt-1" :placeholder="t('admin.ops.systemLogs.componentPlaceholder')" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
{{ t('admin.ops.systemLogs.host') }}
<input v-model="filters.host" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
request_id
<input v-model="filters.request_id" type="text" class="input mt-1" />
@@ -503,6 +511,7 @@ onMounted(async () => {
<thead class="bg-gray-50 dark:bg-dark-900">
<tr>
<th class="w-[170px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.time') }}</th>
<th class="w-[160px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.host') }}</th>
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.level') }}</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.logDetails') }}</th>
</tr>
@@ -510,6 +519,9 @@ onMounted(async () => {
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
<tr v-for="row in logs" :key="row.id" class="align-top">
<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{{ formatTime(row.created_at) }}</td>
<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">
<span class="block truncate" :title="row.host || '-'">{{ row.host || '-' }}</span>
</td>
<td class="px-3 py-2 text-xs">
<span class="inline-flex rounded-full px-2 py-0.5 font-semibold" :class="levelBadgeClass(row.level)">
{{ row.level }}
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import OpsSystemLogTable from '../OpsSystemLogTable.vue'
import enLocale from '@/i18n/locales/en'
import zhLocale from '@/i18n/locales/zh'
const mockListSystemLogs = vi.fn()
const mockCleanupSystemLogs = vi.fn()
const mockGetSystemLogSinkHealth = vi.fn()
const mockGetRuntimeLogConfig = vi.fn()
vi.mock('@/api/admin/ops', () => ({
opsAPI: {
listSystemLogs: (...args: any[]) => mockListSystemLogs(...args),
cleanupSystemLogs: (...args: any[]) => mockCleanupSystemLogs(...args),
getSystemLogSinkHealth: (...args: any[]) => mockGetSystemLogSinkHealth(...args),
getRuntimeLogConfig: (...args: any[]) => mockGetRuntimeLogConfig(...args),
},
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}),
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
const SelectStub = defineComponent({
name: 'SelectControlStub',
props: {
modelValue: {
type: [String, Number],
default: '',
},
},
emits: ['update:modelValue'],
template: '<div class="select-stub" />',
})
const PaginationStub = defineComponent({
name: 'PaginationStub',
template: '<div class="pagination-stub" />',
})
const runtimeConfig = {
level: 'info',
enable_sampling: false,
sampling_initial: 100,
sampling_thereafter: 100,
caller: true,
stacktrace_level: 'error',
retention_days: 30,
}
const sinkHealth = {
queue_depth: 0,
queue_capacity: 5000,
dropped_count: 0,
write_failed_count: 0,
written_count: 1,
avg_write_delay_ms: 0,
}
describe('OpsSystemLogTable host support', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(window, 'confirm').mockReturnValue(true)
mockListSystemLogs.mockResolvedValue({
items: [
{
id: 1,
created_at: '2026-07-14T00:10:01Z',
host: 'api-node-1',
level: 'warn',
component: 'app',
message: 'request failed',
},
],
total: 1,
page: 1,
page_size: 20,
})
mockCleanupSystemLogs.mockResolvedValue({ deleted: 1 })
mockGetSystemLogSinkHealth.mockResolvedValue(sinkHealth)
mockGetRuntimeLogConfig.mockResolvedValue(runtimeConfig)
})
it('renders the host and sends it with list and cleanup filters', async () => {
const wrapper = mount(OpsSystemLogTable, {
global: {
stubs: {
Select: SelectStub,
Pagination: PaginationStub,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('api-node-1')
const hostLabel = wrapper.findAll('label').find((label) => label.text().includes('admin.ops.systemLogs.host'))
expect(hostLabel).toBeDefined()
await hostLabel!.find('input').setValue(' api-node-2 ')
const searchButton = wrapper.findAll('button').find((button) => button.text() === 'admin.ops.systemLogs.search')
expect(searchButton).toBeDefined()
await searchButton!.trigger('click')
await flushPromises()
expect(mockListSystemLogs).toHaveBeenLastCalledWith(expect.objectContaining({ host: 'api-node-2' }))
const cleanupButton = wrapper.findAll('button').find((button) => button.text() === 'admin.ops.systemLogs.cleanCurrentFilters')
expect(cleanupButton).toBeDefined()
await cleanupButton!.trigger('click')
await flushPromises()
expect(mockCleanupSystemLogs).toHaveBeenCalledWith(expect.objectContaining({ host: 'api-node-2' }))
})
it.each([
['zh', zhLocale],
['en', enLocale],
])('defines the Host translation for %s', (_name, locale) => {
expect(locale.admin.ops.systemLogs.host).toBe('Host')
})
})