mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
Merge remote-tracking branch 'origin/main' into feat/grok-sso-device-oauth
# Conflicts: # frontend/src/api/admin/grok.ts
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user