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