mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 00:21:34 +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:
@@ -8,7 +8,7 @@ vi.mock('@/api/client', () => ({
|
||||
apiClient: { post },
|
||||
}))
|
||||
|
||||
import { createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
|
||||
import { authorizePassword, createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
|
||||
|
||||
describe('admin Grok SSO import API', () => {
|
||||
beforeEach(() => {
|
||||
@@ -34,4 +34,20 @@ describe('admin Grok SSO import API', () => {
|
||||
{ timeout: expectedTimeout },
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves password whitespace and applies the authorization timeout', async () => {
|
||||
post.mockResolvedValueOnce({ data: { access_token: 'access-token' } })
|
||||
|
||||
await authorizePassword(' user@example.com ---- password with spaces ', 7)
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/admin/grok/oauth/password',
|
||||
{
|
||||
email: 'user@example.com',
|
||||
password: ' password with spaces ',
|
||||
proxy_id: 7,
|
||||
},
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,11 @@ describe('admin system rollback API', () => {
|
||||
|
||||
const result = await rollback('0.1.146')
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/system/rollback', { version: '0.1.146' })
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/admin/system/rollback',
|
||||
{ version: '0.1.146' },
|
||||
{ timeout: 15 * 60 * 1000 }
|
||||
)
|
||||
expect(result.need_restart).toBe(true)
|
||||
})
|
||||
|
||||
@@ -50,6 +54,10 @@ describe('admin system rollback API', () => {
|
||||
|
||||
await rollback()
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/system/rollback', undefined)
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/admin/system/rollback',
|
||||
undefined,
|
||||
{ timeout: 15 * 60 * 1000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -317,6 +317,19 @@ export async function getUsage(id: number, source?: 'passive' | 'active', force?
|
||||
return data
|
||||
}
|
||||
|
||||
export interface BatchAccountUsageResponse {
|
||||
usage: Record<string, AccountUsageInfo>
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
export async function getBatchUsage(accountIds: number[], force?: boolean): Promise<BatchAccountUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchAccountUsageResponse>('/admin/accounts/usage/batch', {
|
||||
account_ids: accountIds,
|
||||
force: force === true
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear account rate limit status
|
||||
* @param id - Account ID
|
||||
@@ -986,6 +999,7 @@ export const accountsAPI = {
|
||||
getStats,
|
||||
clearError,
|
||||
getUsage,
|
||||
getBatchUsage,
|
||||
getTodayStats,
|
||||
getBatchTodayStats,
|
||||
clearRateLimit,
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface TrendParams {
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
upstream_model_mismatch?: boolean
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
@@ -87,6 +88,7 @@ export interface ModelStatsParams {
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
upstream_model_mismatch?: boolean
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
@@ -115,6 +117,7 @@ export interface GroupStatsParams {
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
upstream_model_mismatch?: boolean
|
||||
}
|
||||
|
||||
export interface GroupStatsResponse {
|
||||
|
||||
@@ -19,6 +19,17 @@ export interface GrokAuthUrlRequest {
|
||||
redirect_uri?: string
|
||||
}
|
||||
|
||||
export interface GrokOAuthCapabilities {
|
||||
password_auth_enabled: boolean
|
||||
}
|
||||
|
||||
const GROK_AUTHORIZATION_TIMEOUT_MS = 120_000
|
||||
|
||||
export async function getCapabilities(): Promise<GrokOAuthCapabilities> {
|
||||
const { data } = await apiClient.get<GrokOAuthCapabilities>('/admin/grok/oauth/capabilities')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface GrokExchangeCodeRequest {
|
||||
session_id: string
|
||||
state: string
|
||||
@@ -170,4 +181,48 @@ export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise<Gro
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota, createFromSSO }
|
||||
/** Validate a browser SSO cookie and convert to Build OAuth tokens (no raw SSO stored). */
|
||||
export async function validateSSOToken(
|
||||
ssoToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo> {
|
||||
const payload: Record<string, unknown> = { sso_token: ssoToken }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/sso-token', payload, {
|
||||
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Password login → ephemeral SSO → Build OAuth.
|
||||
* Password is only sent over the wire for this call; never persist it in credentials.
|
||||
*/
|
||||
export async function authorizePassword(
|
||||
emailAndPassword: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo> {
|
||||
// Format: email----password (password may contain dashes).
|
||||
const sep = '----'
|
||||
const idx = emailAndPassword.indexOf(sep)
|
||||
const email = (idx >= 0 ? emailAndPassword.slice(0, idx) : emailAndPassword).trim()
|
||||
const password = idx >= 0 ? emailAndPassword.slice(idx + sep.length) : ''
|
||||
const payload: Record<string, unknown> = { email, password }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/password', payload, {
|
||||
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export default {
|
||||
generateAuthUrl,
|
||||
getCapabilities,
|
||||
exchangeCode,
|
||||
refreshGrokToken,
|
||||
queryQuota,
|
||||
resetQuota,
|
||||
createFromSSO,
|
||||
validateSSOToken,
|
||||
authorizePassword,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,38 @@ export type DefaultPlatformQuotasMap = Partial<Record<PlatformType, PlatformQuot
|
||||
|
||||
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok"]
|
||||
|
||||
export type SchedulingThresholdPlatformType =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "grok"
|
||||
|
||||
export type AccountSchedulingThresholdsMap = Record<SchedulingThresholdPlatformType, number>
|
||||
|
||||
export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"grok",
|
||||
]
|
||||
|
||||
export function normalizeAccountSchedulingThresholdsMap(
|
||||
input?: Partial<Record<SchedulingThresholdPlatformType, number>> | null,
|
||||
): AccountSchedulingThresholdsMap {
|
||||
const result = {} as AccountSchedulingThresholdsMap
|
||||
for (const platform of SCHEDULING_THRESHOLD_PLATFORMS) {
|
||||
const value = input?.[platform]
|
||||
result[platform] = typeof value === "number" && Number.isFinite(value)
|
||||
? Math.min(100, Math.max(1, Math.trunc(value)))
|
||||
: 100
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function sanitizeAccountSchedulingThresholdsMap(
|
||||
input?: Partial<Record<SchedulingThresholdPlatformType, number>> | null,
|
||||
): AccountSchedulingThresholdsMap {
|
||||
return normalizeAccountSchedulingThresholdsMap(input)
|
||||
}
|
||||
|
||||
/** 归一化为全 4 平台 × 3 窗口(缺失填 null),供模板非空绑定 */
|
||||
export function normalizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
|
||||
const result: DefaultPlatformQuotasMap = {}
|
||||
@@ -556,6 +588,12 @@ export interface SystemSettings {
|
||||
fallback_model_openai: string;
|
||||
fallback_model_gemini: string;
|
||||
fallback_model_antigravity: string;
|
||||
grok_default_text_model: string;
|
||||
grok_cross_client_model_map_enabled: boolean;
|
||||
grok_default_base_url_mode: string;
|
||||
|
||||
// Per-platform account auto-pause thresholds (100 = disabled)
|
||||
account_scheduling_thresholds: AccountSchedulingThresholdsMap;
|
||||
|
||||
// Identity patch configuration (Claude -> Gemini)
|
||||
enable_identity_patch: boolean;
|
||||
@@ -874,6 +912,10 @@ export interface UpdateSettingsRequest {
|
||||
fallback_model_openai?: string;
|
||||
fallback_model_gemini?: string;
|
||||
fallback_model_antigravity?: string;
|
||||
grok_default_text_model?: string;
|
||||
grok_cross_client_model_map_enabled?: boolean;
|
||||
grok_default_base_url_mode?: string;
|
||||
account_scheduling_thresholds?: AccountSchedulingThresholdsMap;
|
||||
enable_identity_patch?: boolean;
|
||||
identity_patch_prompt?: string;
|
||||
ops_monitoring_enabled?: boolean;
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
user_id?: number
|
||||
exact_total?: boolean
|
||||
billing_mode?: string
|
||||
upstream_model_mismatch?: boolean
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
|
||||
@@ -123,6 +124,7 @@ export async function getStats(params: {
|
||||
model?: string
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
upstream_model_mismatch?: boolean
|
||||
period?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
|
||||
@@ -14,15 +14,19 @@
|
||||
|
||||
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<div v-if="isTempUnschedulable" class="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<span class="max-w-[180px] text-center text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{{ tempUnschedRecoveryText }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
@@ -302,6 +306,13 @@ const overloadCountdown = computed(() => {
|
||||
return formatCountdownWithSuffix(props.account.overload_until)
|
||||
})
|
||||
|
||||
const tempUnschedRecoveryText = computed(() => {
|
||||
if (!isTempUnschedulable.value || !props.account.temp_unschedulable_until) return ''
|
||||
return t('admin.accounts.status.tempUnschedulableUntil', {
|
||||
time: formatDateTime(props.account.temp_unschedulable_until)
|
||||
})
|
||||
})
|
||||
|
||||
// Computed: status badge class
|
||||
const statusClass = computed(() => {
|
||||
if (hasError.value) {
|
||||
|
||||
@@ -113,8 +113,6 @@
|
||||
<!-- No data yet -->
|
||||
<div v-else class="space-y-1">
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
<!-- Always allow on-demand upstream quota probe, even before passive headers exist. -->
|
||||
<GrokQuotaProbeCell :account="account" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -356,82 +354,72 @@
|
||||
</div>
|
||||
<div v-else-if="isForbidden" class="space-y-1">
|
||||
<span class="inline-block rounded px-1.5 py-0.5 text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||
{{ grokEntitlementLabel || t('admin.accounts.forbidden') }}
|
||||
{{ usageInfo?.grok_entitlement_status || t('admin.accounts.forbidden') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="usageInfo" class="space-y-1">
|
||||
<div v-if="grokEntitlementLabel" class="mb-0.5">
|
||||
<span class="inline-block rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
{{ grokEntitlementLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="grokLocalUsage" class="mb-0.5 flex items-center">
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatWindowRequests(grokLocalUsage) }} req
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatWindowTokens(grokLocalUsage) }}
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
|
||||
A ${{ formatWindowCost(grokLocalUsage) }}
|
||||
</span>
|
||||
<!-- Free: only rolling 24h soft-gate bar. Paid: 7d + 30d + prepaid money. -->
|
||||
<template v-if="grokIsFree">
|
||||
<UsageProgressBar
|
||||
v-if="grokFreeTokenBar"
|
||||
label="24h"
|
||||
:title="t('admin.accounts.usageWindow.grokFreeQuota24hHint', { limit: formatCompactNumber(grokFreeTokenBar.limit) })"
|
||||
:utilization="grokFreeTokenBar.utilization"
|
||||
:show-now-when-idle="true"
|
||||
color="emerald"
|
||||
/>
|
||||
<div v-else-if="grokQuotaUnknown" class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{{ grokQuotaUnknownLabel }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UsageProgressBar
|
||||
v-if="grokWeeklyBillingBar"
|
||||
label="7d"
|
||||
:utilization="grokWeeklyBillingBar.utilization"
|
||||
:resets-at="grokWeeklyBillingBar.resetsAt"
|
||||
:show-now-when-idle="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
v-if="grokMonthlyBillingBar"
|
||||
label="30d"
|
||||
:utilization="grokMonthlyBillingBar.utilization"
|
||||
:resets-at="grokMonthlyBillingBar.resetsAt"
|
||||
:show-now-when-idle="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<div
|
||||
v-if="grokPrepaidMoneyLine"
|
||||
class="flex flex-wrap items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span
|
||||
v-if="grokLocalUsage.user_cost != null"
|
||||
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
|
||||
:title="t('usage.userBilled')"
|
||||
class="rounded bg-emerald-50 px-1 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
:title="t('admin.accounts.usageWindow.grokPrepaid')"
|
||||
>
|
||||
U ${{ formatWindowUserCost(grokLocalUsage) }}
|
||||
{{ t('admin.accounts.usageWindow.grokPrepaid') }} ${{ grokPrepaidMoneyLine.prepaid }}
|
||||
</span>
|
||||
<span :title="t('admin.accounts.usageWindow.grokMonthlyLimit')">
|
||||
{{ t('admin.accounts.usageWindow.grokUsed') }}
|
||||
{{ grokPrepaidMoneyLine.used }}/{{ grokPrepaidMoneyLine.limit }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="grokQuotaUnknown" class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{{ grokQuotaUnknownLabel }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="usageInfo.error" class="truncate text-xs text-amber-600 dark:text-amber-400 max-w-[200px]" :title="usageInfo.error">
|
||||
{{ usageErrorLabel }}
|
||||
</div>
|
||||
<UsageProgressBar
|
||||
v-if="grokWeeklyBillingBar"
|
||||
label="7d"
|
||||
:utilization="grokWeeklyBillingBar.utilization"
|
||||
:resets-at="grokWeeklyBillingBar.resetsAt"
|
||||
:show-now-when-idle="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
v-if="!grokWeeklyBillingBar && !grokIsFree && grokRequestQuotaBar"
|
||||
:label="t('admin.accounts.usageWindow.grokRequests')"
|
||||
:utilization="grokRequestQuotaBar.utilization"
|
||||
:resets-at="grokRequestQuotaBar.resetsAt"
|
||||
:remaining-capacity="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
v-if="!grokWeeklyBillingBar && !grokIsFree && grokTokenQuotaBar"
|
||||
:label="t('admin.accounts.usageWindow.grokTokens')"
|
||||
:utilization="grokTokenQuotaBar.utilization"
|
||||
:resets-at="grokTokenQuotaBar.resetsAt"
|
||||
:remaining-capacity="true"
|
||||
color="emerald"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
v-if="grokFreeTokenBar"
|
||||
label="24h"
|
||||
:title="t('admin.accounts.usageWindow.grokFreeQuota24hHint', { limit: formatCompactNumber(grokFreeTokenBar.limit) })"
|
||||
:utilization="grokFreeTokenBar.utilization"
|
||||
:show-now-when-idle="true"
|
||||
color="emerald"
|
||||
/>
|
||||
<div v-if="grokRetryAfterLabel" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.usageWindow.grokRetryAfter', { time: grokRetryAfterLabel }) }}
|
||||
</div>
|
||||
<div v-if="grokQuotaUnknown" class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{{ grokQuotaUnknownLabel }}
|
||||
</div>
|
||||
<div v-else-if="usageInfo.error" class="truncate text-xs text-amber-600 dark:text-amber-400 max-w-[200px]" :title="usageInfo.error">
|
||||
{{ usageErrorLabel }}
|
||||
</div>
|
||||
<div v-if="grokQuotaStatusLine" class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{{ grokQuotaStatusLine }}
|
||||
</div>
|
||||
<GrokQuotaProbeCell :account="account" @probed="handleGrokProbed" />
|
||||
<GrokQuotaProbeCell :account="account" compact @probed="handleGrokProbed" />
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
<GrokQuotaProbeCell :account="account" compact @probed="handleGrokProbed" />
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<!-- Gemini platform: show quota + local usage window -->
|
||||
@@ -629,11 +617,10 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { GrokQuotaProbeResult } from '@/api/admin/grok'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
|
||||
import { formatCompactNumber, formatRelativeTime } from '@/utils/format'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
|
||||
@@ -652,16 +639,25 @@ const props = withDefaults(
|
||||
todayStats?: WindowStats | null
|
||||
todayStatsLoading?: boolean
|
||||
manualRefreshToken?: number
|
||||
batchedUsage?: AccountUsageInfo | null
|
||||
batchedUsageError?: string | null
|
||||
batchedUsageLoading?: boolean
|
||||
requestBatchedUsage?: ((account: Account, options?: { force?: boolean }) => void) | null
|
||||
}>(),
|
||||
{
|
||||
todayStats: null,
|
||||
todayStatsLoading: false,
|
||||
manualRefreshToken: 0
|
||||
manualRefreshToken: 0,
|
||||
batchedUsage: null,
|
||||
batchedUsageError: null,
|
||||
batchedUsageLoading: false,
|
||||
requestBatchedUsage: null
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'account-updated': [account: Account]
|
||||
'usage-loaded': [usage: AccountUsageInfo]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -674,6 +670,9 @@ const loading = ref(false)
|
||||
const activeQueryLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
watch(usageInfo, (usage) => {
|
||||
if (usage) emit('usage-loaded', usage)
|
||||
})
|
||||
const suppressOpenAIUsageRefreshUntil = ref(0)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isDesktopViewport = ref(
|
||||
@@ -713,6 +712,8 @@ const shouldFetchUsage = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function')
|
||||
|
||||
const showGeminiTodayStats = computed(() => {
|
||||
return props.account.platform === 'gemini' && props.account.type === 'service_account'
|
||||
})
|
||||
@@ -1072,17 +1073,6 @@ interface GrokQuotaBarInfo {
|
||||
resetsAt: string | null
|
||||
}
|
||||
|
||||
const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number | null; reset_at?: string | null } | null): GrokQuotaBarInfo | null => {
|
||||
if (!quota || quota.limit == null || quota.remaining == null || quota.limit <= 0) return null
|
||||
const remaining = Math.min(quota.limit, Math.max(0, quota.remaining))
|
||||
return {
|
||||
utilization: (remaining / quota.limit) * 100,
|
||||
resetsAt: quota.reset_at || null
|
||||
}
|
||||
}
|
||||
|
||||
const grokRequestQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_request_quota))
|
||||
const grokTokenQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_token_quota))
|
||||
const grokBilling = computed(() => usageInfo.value?.grok_billing || null)
|
||||
const grokWeeklyBillingBar = computed((): GrokQuotaBarInfo | null => {
|
||||
const billing = grokBilling.value
|
||||
@@ -1094,6 +1084,63 @@ const grokWeeklyBillingBar = computed((): GrokQuotaBarInfo | null => {
|
||||
resetsAt: billing.period_end || null
|
||||
}
|
||||
})
|
||||
// Monthly used/limit % from billing probe (used_percent or derived from cents).
|
||||
const grokMonthlyBillingBar = computed((): GrokQuotaBarInfo | null => {
|
||||
const billing = grokBilling.value
|
||||
if (!billing) return null
|
||||
let utilization: number | null = null
|
||||
if (billing.used_percent != null && Number.isFinite(billing.used_percent)) {
|
||||
utilization = billing.used_percent
|
||||
} else if (
|
||||
billing.monthly_limit_cents != null &&
|
||||
billing.monthly_limit_cents > 0 &&
|
||||
billing.used_cents != null
|
||||
) {
|
||||
utilization = (billing.used_cents / billing.monthly_limit_cents) * 100
|
||||
}
|
||||
if (utilization == null) return null
|
||||
// Avoid duplicating the weekly bar when period_type is weekly-only without monthly.
|
||||
if (billing.period_type?.toLowerCase() === 'weekly' && billing.monthly_limit_cents == null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
utilization: Math.min(100, Math.max(0, utilization)),
|
||||
resetsAt: billing.billing_period_end || billing.period_end || null
|
||||
}
|
||||
})
|
||||
const formatGrokMoney = (value?: number | null) => {
|
||||
if (value == null || Number.isNaN(value)) return '0'
|
||||
if (value >= 1000) return formatCompactNumber(value)
|
||||
if (value >= 100) return value.toFixed(0)
|
||||
if (value >= 10) return value.toFixed(1)
|
||||
return value.toFixed(2)
|
||||
}
|
||||
// Prepaid money line for paid Grok: show when prepaid_balance is present.
|
||||
// Monthly used/limit numbers are optional context; primary progress is the 30d bar.
|
||||
const grokPrepaidMoneyLine = computed(() => {
|
||||
const billing = grokBilling.value
|
||||
if (!billing) return null
|
||||
const prepaid = billing.prepaid_balance
|
||||
// "只针对预付": only render when prepaid field exists (including $0.00).
|
||||
if (prepaid == null || !Number.isFinite(prepaid)) return null
|
||||
const used =
|
||||
billing.monthly_used != null
|
||||
? billing.monthly_used
|
||||
: billing.used_cents != null
|
||||
? billing.used_cents / 100
|
||||
: 0
|
||||
const limit =
|
||||
billing.monthly_limit != null
|
||||
? billing.monthly_limit
|
||||
: billing.monthly_limit_cents != null
|
||||
? billing.monthly_limit_cents / 100
|
||||
: 0
|
||||
return {
|
||||
prepaid: formatGrokMoney(prepaid),
|
||||
used: formatGrokMoney(used),
|
||||
limit: formatGrokMoney(limit)
|
||||
}
|
||||
})
|
||||
const grokPlanLabelIsFree = (value: string) => value.includes('free') || value.includes('basic')
|
||||
const grokPlanLabelIsPaid = (value: string) => {
|
||||
return value !== '' && !grokPlanLabelIsFree(value) && !value.includes('unknown')
|
||||
@@ -1119,14 +1166,6 @@ const grokIsFree = computed(() => {
|
||||
return billing != null
|
||||
})
|
||||
const grokFreeQuotaUsage = computed(() => usageInfo.value?.grok_local_usage_24h || null)
|
||||
const grokLocalUsage = computed(() => {
|
||||
if (grokIsFree.value) return grokFreeQuotaUsage.value
|
||||
return props.todayStats ||
|
||||
usageInfo.value?.grok_local_usage ||
|
||||
usageInfo.value?.grok_local_usage_7d ||
|
||||
usageInfo.value?.grok_local_usage_monthly ||
|
||||
null
|
||||
})
|
||||
const grokFreeTokenBar = computed(() => {
|
||||
if (!grokIsFree.value || !grokFreeQuotaUsage.value) return null
|
||||
const limit = usageInfo.value?.grok_free_token_limit
|
||||
@@ -1136,7 +1175,12 @@ const grokFreeTokenBar = computed(() => {
|
||||
})
|
||||
const grokQuotaUnknown = computed(() => {
|
||||
if (props.account.platform !== 'grok') return false
|
||||
if (grokBilling.value || grokFreeTokenBar.value || grokRequestQuotaBar.value || grokTokenQuotaBar.value) return false
|
||||
if (grokIsFree.value) {
|
||||
return !grokFreeTokenBar.value
|
||||
}
|
||||
if (grokWeeklyBillingBar.value || grokMonthlyBillingBar.value || grokPrepaidMoneyLine.value) {
|
||||
return false
|
||||
}
|
||||
return usageInfo.value?.grok_quota_snapshot_state !== 'observed'
|
||||
})
|
||||
const grokQuotaUnknownLabel = computed(() => {
|
||||
@@ -1144,33 +1188,6 @@ const grokQuotaUnknownLabel = computed(() => {
|
||||
? t('admin.accounts.usageWindow.grokNoHeaders')
|
||||
: t('admin.accounts.usageWindow.grokUnknown')
|
||||
})
|
||||
const grokQuotaStatusLine = computed(() => {
|
||||
if (props.account.platform !== 'grok') return null
|
||||
const parts: string[] = []
|
||||
const status = usageInfo.value?.grok_last_status_code
|
||||
if (status) {
|
||||
parts.push(t('admin.accounts.usageWindow.grokLastStatus', { status }))
|
||||
}
|
||||
if (usageInfo.value?.grok_last_quota_probe_at) {
|
||||
parts.push(
|
||||
t('admin.accounts.usageWindow.grokLastProbe', {
|
||||
time: formatRelativeTime(usageInfo.value.grok_last_quota_probe_at)
|
||||
})
|
||||
)
|
||||
}
|
||||
if (usageInfo.value?.grok_last_headers_seen_at) {
|
||||
parts.push(
|
||||
t('admin.accounts.usageWindow.grokLastHeadersSeen', {
|
||||
time: formatRelativeTime(usageInfo.value.grok_last_headers_seen_at)
|
||||
})
|
||||
)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' | ') : null
|
||||
})
|
||||
const grokEntitlementLabel = computed(() => {
|
||||
const status = (usageInfo.value?.grok_entitlement_status || '').trim()
|
||||
return status || null
|
||||
})
|
||||
const grokRetryAfterLabel = computed(() => {
|
||||
const seconds = usageInfo.value?.grok_retry_after_seconds
|
||||
if (seconds == null || seconds <= 0) return null
|
||||
@@ -1179,11 +1196,6 @@ const grokRetryAfterLabel = computed(() => {
|
||||
return `${minutes}m`
|
||||
})
|
||||
|
||||
const formatWindowRequests = (stats: WindowStats) => formatCompactNumber(stats.requests, { allowBillions: false })
|
||||
const formatWindowTokens = (stats: WindowStats) => formatCompactNumber(stats.tokens)
|
||||
const formatWindowCost = (stats: WindowStats) => stats.cost.toFixed(2)
|
||||
const formatWindowUserCost = (stats: WindowStats) => (stats.user_cost ?? 0).toFixed(2)
|
||||
|
||||
// 账户类型显示标签
|
||||
const antigravityTierLabel = computed(() => {
|
||||
switch (antigravityTier.value) {
|
||||
@@ -1273,8 +1285,24 @@ const isAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')
|
||||
})
|
||||
|
||||
const requestParentBatchUsage = (options?: { force?: boolean }) => {
|
||||
if (!isBatchManaged.value || !shouldFetchUsage.value) return
|
||||
props.requestBatchedUsage?.(props.account, options)
|
||||
}
|
||||
|
||||
const syncManagedUsageState = () => {
|
||||
if (!isBatchManaged.value) return
|
||||
usageInfo.value = props.batchedUsage ?? null
|
||||
error.value = props.batchedUsageError ?? null
|
||||
loading.value = props.batchedUsageLoading === true
|
||||
}
|
||||
|
||||
const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?: boolean }) => {
|
||||
if (!shouldFetchUsage.value) return
|
||||
if (isBatchManaged.value) {
|
||||
requestParentBatchUsage({ force: options?.bypassCache === true })
|
||||
return
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (!options?.bypassCache) {
|
||||
@@ -1290,9 +1318,9 @@ const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const fetchFn = () => options?.source
|
||||
? adminAPI.accounts.getUsage(props.account.id, options.source)
|
||||
: adminAPI.accounts.getUsage(props.account.id)
|
||||
const fetchFn = () => options?.source
|
||||
? adminAPI.accounts.getUsage(props.account.id, options.source, options.bypassCache === true)
|
||||
: adminAPI.accounts.getUsage(props.account.id)
|
||||
const result = await enqueueUsageRequest(props.account, fetchFn)
|
||||
if (!unmounted.value) {
|
||||
usageInfo.value = result
|
||||
@@ -1369,54 +1397,10 @@ const loadActiveUsage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGrokProbed = (result: GrokQuotaProbeResult) => {
|
||||
const current = usageInfo.value
|
||||
if (!current) return
|
||||
const snapshot = result.snapshot
|
||||
const statusCode = snapshot?.status_code ?? result.status_code
|
||||
const hasActiveProbeSnapshot = snapshot != null && (
|
||||
result.source === 'active_probe' ||
|
||||
result.source === 'hybrid_probe' ||
|
||||
snapshot.observation_source === 'active_probe'
|
||||
)
|
||||
const probeSucceeded = hasActiveProbeSnapshot &&
|
||||
statusCode != null && statusCode >= 200 && statusCode < 300
|
||||
const snapshotEntitlement = snapshot?.entitlement_status?.trim()
|
||||
const currentEntitlement = current.grok_entitlement_status?.trim()
|
||||
const entitlementStatus = snapshotEntitlement || (
|
||||
probeSucceeded && currentEntitlement?.toLowerCase() === 'forbidden'
|
||||
? undefined
|
||||
: current.grok_entitlement_status
|
||||
)
|
||||
const merged: AccountUsageInfo = {
|
||||
...current,
|
||||
grok_billing: result.billing ?? current.grok_billing,
|
||||
grok_local_usage_24h: result.local_usage_24h ?? current.grok_local_usage_24h,
|
||||
grok_local_usage_7d: result.local_usage_7d ?? current.grok_local_usage_7d,
|
||||
grok_local_usage_monthly: result.local_usage_monthly ?? current.grok_local_usage_monthly,
|
||||
grok_request_quota: snapshot?.requests ?? current.grok_request_quota,
|
||||
grok_token_quota: snapshot?.tokens ?? current.grok_token_quota,
|
||||
grok_retry_after_seconds: snapshot?.retry_after_seconds ?? current.grok_retry_after_seconds,
|
||||
grok_entitlement_status: entitlementStatus,
|
||||
grok_quota_snapshot_state: result.billing
|
||||
? 'billing_observed'
|
||||
: snapshot?.headers_observed
|
||||
? 'observed'
|
||||
: current.grok_quota_snapshot_state,
|
||||
grok_last_quota_probe_at: result.billing?.fetched_at ?? snapshot?.last_probe_at ?? current.grok_last_quota_probe_at,
|
||||
grok_last_headers_seen_at: snapshot?.last_headers_seen_at ?? current.grok_last_headers_seen_at,
|
||||
grok_last_status_code: result.status_code ?? snapshot?.status_code ?? current.grok_last_status_code,
|
||||
is_forbidden: probeSucceeded ? false : current.is_forbidden,
|
||||
forbidden_reason: probeSucceeded ? undefined : current.forbidden_reason,
|
||||
forbidden_type: probeSucceeded ? undefined : current.forbidden_type,
|
||||
validation_url: probeSucceeded ? undefined : current.validation_url,
|
||||
needs_verify: probeSucceeded ? false : current.needs_verify,
|
||||
is_banned: probeSucceeded ? false : current.is_banned,
|
||||
error: result.billing || snapshot ? undefined : current.error,
|
||||
error_code: result.billing || snapshot ? undefined : current.error_code
|
||||
}
|
||||
usageInfo.value = merged
|
||||
_usageCache.set(props.account.id, { data: merged, ts: Date.now() })
|
||||
// The probe persists upstream quota state; refresh this cell so its compact
|
||||
// bars and entitlement status reflect the newly observed snapshot.
|
||||
const handleGrokProbed = async () => {
|
||||
await loadUsage({ source: 'active', bypassCache: true })
|
||||
}
|
||||
|
||||
// ===== API Key quota progress bars =====
|
||||
@@ -1529,11 +1513,49 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isBatchManaged.value) {
|
||||
syncManagedUsageState()
|
||||
requestParentBatchUsage()
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldAutoLoadUsageOnMount.value) return
|
||||
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
||||
requestAutoLoad(source)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.batchedUsage, props.batchedUsageError, props.batchedUsageLoading, isBatchManaged.value] as const,
|
||||
() => {
|
||||
syncManagedUsageState()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(isBatchManaged, (managed, wasManaged) => {
|
||||
if (managed && !wasManaged) {
|
||||
syncManagedUsageState()
|
||||
requestParentBatchUsage()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.account.id, props.account.platform, props.account.type, isBatchManaged.value] as const,
|
||||
([accountID, platform, accountType, managed], [previousAccountID, previousPlatform, previousAccountType]) => {
|
||||
if (
|
||||
accountID === previousAccountID &&
|
||||
platform === previousPlatform &&
|
||||
accountType === previousAccountType
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!managed || !shouldFetchUsage.value) return
|
||||
syncManagedUsageState()
|
||||
requestParentBatchUsage()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
if (!prevKey || nextKey === prevKey) return
|
||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
||||
@@ -1542,6 +1564,11 @@ watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (isBatchManaged.value) {
|
||||
requestParentBatchUsage({ force: true })
|
||||
return
|
||||
}
|
||||
|
||||
_usageCache.delete(props.account.id)
|
||||
requestAutoLoad()
|
||||
})
|
||||
@@ -1552,6 +1579,11 @@ watch(
|
||||
if (nextToken === prevToken) return
|
||||
if (!shouldFetchUsage.value) return
|
||||
|
||||
if (isBatchManaged.value) {
|
||||
requestParentBatchUsage({ force: true })
|
||||
return
|
||||
}
|
||||
|
||||
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
||||
_usageCache.delete(props.account.id)
|
||||
loadUsage({ source, bypassCache: true }).catch((e) => {
|
||||
|
||||
@@ -3212,6 +3212,7 @@
|
||||
:show-agent-identity-option="form.platform === 'openai'"
|
||||
:show-codex-pat-option="form.platform === 'openai'"
|
||||
:show-sso-option="form.platform === 'grok'"
|
||||
:show-email-password-option="false"
|
||||
:show-manual-option="true"
|
||||
:initial-input-method="'manual'"
|
||||
:platform="form.platform"
|
||||
@@ -3224,6 +3225,7 @@
|
||||
@import-codex-session="handleOpenAIImportCodexSession"
|
||||
@import-codex-pat="handleOpenAIImportCodexPAT"
|
||||
@import-sso="handleGrokImportSSO"
|
||||
@authorize-password="handleGrokAuthorizePassword"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -3314,8 +3316,8 @@
|
||||
<BaseDialog
|
||||
:show="showGeminiHelpDialog"
|
||||
:title="t('admin.accounts.gemini.helpDialog.title')"
|
||||
width="wide"
|
||||
@close="showGeminiHelpDialog = false"
|
||||
max-width="max-w-3xl"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Setup Guide Section -->
|
||||
@@ -5476,6 +5478,116 @@ const handleGrokImportSSO = async (ssoInput: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok password login: each line is email----password.
|
||||
* Password is only used for the authorize API call; buildCredentials never stores it.
|
||||
*/
|
||||
const handleGrokAuthorizePassword = async (emailPasswordInput: string) => {
|
||||
if (!emailPasswordInput.trim()) return
|
||||
if (!validateGrokOAuthUpstreamConfig()) return
|
||||
|
||||
const lines = emailPasswordInput
|
||||
.split('\n')
|
||||
// Keep the password portion byte-for-byte; trim is only for determining
|
||||
// whether this textarea line is blank.
|
||||
.filter((line) => line.trim() && line.includes('----'))
|
||||
|
||||
if (lines.length === 0) {
|
||||
grokOAuth.error.value = t(
|
||||
'admin.accounts.oauth.grok.pleaseEnterPassword',
|
||||
'Please enter email----password (one per line)'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
grokOAuth.loading.value = true
|
||||
grokOAuth.error.value = ''
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
const tokenInfo = await grokOAuth.authorizePassword(lines[i], form.proxy_id)
|
||||
if (!tokenInfo) {
|
||||
failedCount++
|
||||
errors.push(`#${i + 1}: ${grokOAuth.error.value || 'Authorization failed'}`)
|
||||
grokOAuth.error.value = ''
|
||||
continue
|
||||
}
|
||||
|
||||
const credentials = grokOAuth.buildCredentials(tokenInfo)
|
||||
applyGrokOAuthUpstreamConfig(credentials)
|
||||
const extra = grokOAuth.buildExtraInfo(tokenInfo)
|
||||
const accountName =
|
||||
lines.length > 1
|
||||
? `${form.name || tokenInfo.email || 'Grok OAuth Account'} #${i + 1}`
|
||||
: form.name || tokenInfo.email || 'Grok OAuth Account'
|
||||
|
||||
const modelMapping = buildModelMappingObject(
|
||||
modelRestrictionMode.value,
|
||||
allowedModels.value,
|
||||
modelMappings.value
|
||||
)
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
}
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: accountName,
|
||||
notes: form.notes,
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
load_factor: form.load_factor ?? undefined,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
successCount++
|
||||
} catch (error: any) {
|
||||
failedCount++
|
||||
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
|
||||
errors.push(`#${i + 1}: ${errMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 && failedCount === 0) {
|
||||
appStore.showSuccess(
|
||||
lines.length > 1
|
||||
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
|
||||
: t('admin.accounts.accountCreated')
|
||||
)
|
||||
emit('created')
|
||||
handleClose()
|
||||
} else if (successCount > 0) {
|
||||
appStore.showWarning(
|
||||
t('admin.accounts.oauth.batchPartialSuccess', {
|
||||
success: successCount,
|
||||
failed: failedCount
|
||||
})
|
||||
)
|
||||
grokOAuth.error.value = errors.join('\n')
|
||||
emit('created')
|
||||
} else {
|
||||
grokOAuth.error.value = errors.join('\n')
|
||||
appStore.showError(t('admin.accounts.oauth.batchFailed'))
|
||||
}
|
||||
} finally {
|
||||
grokOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI OAuth 授权码兑换
|
||||
const handleOpenAIExchange = async (authCode: string) => {
|
||||
const oauthClient = openaiOAuth
|
||||
|
||||
@@ -1375,6 +1375,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
v-if="supportsAccountSchedulingThresholdOverride"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
data-testid="account-scheduling-threshold-section"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.accountSchedulingThresholdOverride') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.accountSchedulingThresholdOverrideHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="accountSchedulingThresholdOverrideEnabled"
|
||||
data-testid="account-scheduling-threshold-override-enabled"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="accountSchedulingThresholdOverrideEnabled">
|
||||
<label class="input-label">{{ t('admin.accounts.accountSchedulingThresholdOverrideValue') }}</label>
|
||||
<input
|
||||
v-model.number="accountSchedulingThresholdOverrideValue"
|
||||
data-testid="account-scheduling-threshold-override-value"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.accountSchedulingThresholdOverrideDisabledHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' || account?.platform === 'antigravity'"
|
||||
@@ -2867,6 +2901,12 @@ const antigravityWhitelistModels = ref<string[]>([])
|
||||
const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const isSyncingAntigravityUpstream = ref(false)
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const accountSchedulingThresholdOverrideEnabled = ref(false)
|
||||
const accountSchedulingThresholdOverrideValue = ref(100)
|
||||
const ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY = 'account_scheduling_threshold'
|
||||
const supportsAccountSchedulingThresholdOverride = computed(() =>
|
||||
supportsAccountSchedulingThresholdOverridePlatform(props.account?.platform)
|
||||
)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
|
||||
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
|
||||
@@ -3512,6 +3552,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
loadQuotaControlSettings(newAccount)
|
||||
|
||||
loadTempUnschedRules(credentials)
|
||||
loadAccountSchedulingThresholdOverride(newAccount.platform, credentials)
|
||||
|
||||
// Load header override state (anthropic/openai apikey + grok apikey/oauth)
|
||||
headerOverrideEnabled.value = false
|
||||
@@ -3874,6 +3915,69 @@ const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
function supportsAccountSchedulingThresholdOverridePlatform(platform: Account['platform'] | undefined) {
|
||||
return platform === 'openai' || platform === 'anthropic' || platform === 'grok'
|
||||
}
|
||||
|
||||
function normalizeAccountSchedulingThresholdOverride(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return null
|
||||
}
|
||||
const integer = Math.trunc(numeric)
|
||||
if (integer < 1 || integer > 100) {
|
||||
return null
|
||||
}
|
||||
return integer
|
||||
}
|
||||
|
||||
function clampAccountSchedulingThresholdOverride(value: unknown): number {
|
||||
return Math.min(100, Math.max(1, Math.trunc(Number(value) || 100)))
|
||||
}
|
||||
|
||||
function loadAccountSchedulingThresholdOverride(
|
||||
platform: Account['platform'] | undefined,
|
||||
credentials: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) {
|
||||
accountSchedulingThresholdOverrideEnabled.value = false
|
||||
accountSchedulingThresholdOverrideValue.value = 100
|
||||
return
|
||||
}
|
||||
const value = normalizeAccountSchedulingThresholdOverride(
|
||||
credentials?.[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY]
|
||||
)
|
||||
accountSchedulingThresholdOverrideEnabled.value = value !== null
|
||||
accountSchedulingThresholdOverrideValue.value = value ?? 100
|
||||
}
|
||||
|
||||
const applyAccountSchedulingThresholdOverridePatch = (
|
||||
credentials: Record<string, unknown>,
|
||||
currentCredentials: Record<string, unknown>,
|
||||
platform: Account['platform'] | undefined = props.account?.platform
|
||||
) => {
|
||||
if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) {
|
||||
return
|
||||
}
|
||||
const current = normalizeAccountSchedulingThresholdOverride(
|
||||
currentCredentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY]
|
||||
)
|
||||
if (!accountSchedulingThresholdOverrideEnabled.value) {
|
||||
if (current !== null) {
|
||||
credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const next = clampAccountSchedulingThresholdOverride(accountSchedulingThresholdOverrideValue.value)
|
||||
if (current !== next) {
|
||||
credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = next
|
||||
}
|
||||
}
|
||||
|
||||
function loadTempUnschedRules(credentials?: Record<string, unknown>) {
|
||||
tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true
|
||||
const rawRules = credentials?.temp_unschedulable_rules
|
||||
@@ -4233,6 +4337,7 @@ const handleSubmit = async () => {
|
||||
|
||||
// Add intercept warmup requests setting
|
||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
return
|
||||
}
|
||||
@@ -4251,6 +4356,7 @@ const handleSubmit = async () => {
|
||||
// Add intercept warmup requests setting
|
||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||
|
||||
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
return
|
||||
}
|
||||
@@ -4299,6 +4405,7 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
return
|
||||
}
|
||||
@@ -4356,6 +4463,7 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
return
|
||||
}
|
||||
@@ -4367,6 +4475,7 @@ const handleSubmit = async () => {
|
||||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||
|
||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -24,18 +24,13 @@
|
||||
</svg>
|
||||
{{ t('admin.accounts.usageWindow.grokProbe') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-not-allowed items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-gray-400 opacity-70 dark:text-gray-500"
|
||||
disabled
|
||||
:title="t('admin.accounts.usageWindow.grokResetUnsupportedTooltip')"
|
||||
>
|
||||
{{ t('admin.accounts.usageWindow.grokResetUnsupported') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="text-[10px] text-gray-600 dark:text-gray-300">
|
||||
<!-- Compact mode: parent already shows 7d/30d/prepaid or 24h — only surface errors. -->
|
||||
<div
|
||||
v-if="!compact && summary"
|
||||
class="text-[10px] text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{{ summary }}
|
||||
</div>
|
||||
<div v-if="error" class="truncate text-[10px] text-red-600 dark:text-red-400" :title="error">
|
||||
@@ -48,12 +43,17 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { GrokQuotaProbeResult, GrokQuotaWindow } from '@/api/admin/grok'
|
||||
import type { GrokQuotaProbeResult } from '@/api/admin/grok'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
account: Account
|
||||
/** When true, only show the probe button (+ errors). No duplicate weekly summary. */
|
||||
compact?: boolean
|
||||
}>(),
|
||||
{ compact: false }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ probed: [result: GrokQuotaProbeResult] }>()
|
||||
|
||||
@@ -79,42 +79,16 @@ const extractErrorMessage = (e: unknown): string => {
|
||||
)
|
||||
}
|
||||
|
||||
const formatWindow = (label: string, window?: GrokQuotaWindow | null): string | null => {
|
||||
if (!window || window.limit == null || window.remaining == null) return null
|
||||
return `${label} ${window.remaining}/${window.limit}`
|
||||
}
|
||||
|
||||
const retryAfterLabel = computed(() => {
|
||||
const seconds = data.value?.snapshot?.retry_after_seconds
|
||||
if (seconds == null || seconds <= 0) return null
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
return `${Math.ceil(seconds / 60)}m`
|
||||
})
|
||||
|
||||
const summary = computed(() => {
|
||||
const snapshot = data.value?.snapshot
|
||||
if (!data.value) return ''
|
||||
if (props.compact || !data.value) return ''
|
||||
// Non-compact fallback (rarely used): brief weekly percent if present.
|
||||
const billing = data.value.billing
|
||||
const parts: Array<string | null> = []
|
||||
if (billing?.period_type?.toLowerCase() === 'weekly' && billing.usage_percent != null) {
|
||||
parts.push(t('admin.accounts.usageWindow.grokWeeklyUsage', {
|
||||
return t('admin.accounts.usageWindow.grokWeeklyUsage', {
|
||||
percent: Math.round(Math.min(100, Math.max(0, billing.usage_percent)))
|
||||
}))
|
||||
})
|
||||
}
|
||||
if (snapshot) {
|
||||
parts.push(
|
||||
formatWindow(t('admin.accounts.usageWindow.grokRequests'), snapshot.requests),
|
||||
formatWindow(t('admin.accounts.usageWindow.grokTokens'), snapshot.tokens)
|
||||
)
|
||||
}
|
||||
if (retryAfterLabel.value) {
|
||||
parts.push(t('admin.accounts.usageWindow.grokRetryAfter', { time: retryAfterLabel.value }))
|
||||
}
|
||||
if (snapshot?.entitlement_status) {
|
||||
parts.push(snapshot.entitlement_status)
|
||||
}
|
||||
const visibleParts = parts.filter((part): part is string => Boolean(part))
|
||||
return visibleParts.length > 0 ? visibleParts.join(' | ') : t('admin.accounts.usageWindow.grokNoHeaders')
|
||||
return ''
|
||||
})
|
||||
|
||||
const truncatedError = computed(() => {
|
||||
|
||||
@@ -59,6 +59,17 @@
|
||||
t(getOAuthKey('ssoCookieAuth'))
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="emailPasswordOptionEnabled" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="email_password"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t(getOAuthKey('emailPasswordAuth'))
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="showMobileRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
@@ -287,6 +298,79 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grok email + password → ephemeral SSO → Build OAuth (password never stored) -->
|
||||
<div v-if="inputMethod === 'email_password'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t(getOAuthKey('emailPasswordDesc')) }}
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Icon name="user" size="sm" class="text-blue-500" />
|
||||
{{ t(getOAuthKey('emailPasswordInputLabel')) }}
|
||||
<span
|
||||
v-if="parsedEmailPasswordCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.keysCount', { count: parsedEmailPasswordCount }) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="emailPasswordInput"
|
||||
rows="4"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="t(getOAuthKey('emailPasswordPlaceholder'))"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ t(getOAuthKey('emailPasswordHint')) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || !emailPasswordInput.trim()"
|
||||
@click="handleAuthorizePassword"
|
||||
>
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||
{{ loading ? t(getOAuthKey('validating')) : t(getOAuthKey('validateAndCreate')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex auth.json / session credential batch import -->
|
||||
<div v-if="inputMethod === 'codex_session' || inputMethod === 'agent_identity'" class="space-y-4">
|
||||
<div
|
||||
@@ -816,6 +900,7 @@ import { useClipboard } from '@/composables/useClipboard'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
import type { AccountPlatform } from '@/types'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
interface Props {
|
||||
addMethod: AddMethod
|
||||
@@ -836,8 +921,15 @@ interface Props {
|
||||
showAgentIdentityOption?: boolean
|
||||
showCodexPatOption?: boolean
|
||||
showSsoOption?: boolean
|
||||
/** Grok email----password login (admin; password never persisted). */
|
||||
showEmailPasswordOption?: boolean
|
||||
showManualOption?: boolean
|
||||
initialInputMethod?: AuthInputMethod
|
||||
/**
|
||||
* Prefill for Grok email----password reauth. Password is never stored;
|
||||
* pass only the email (or "email----") so the operator types the password.
|
||||
*/
|
||||
initialEmailPassword?: string
|
||||
platform?: AccountPlatform // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
@@ -860,8 +952,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showAgentIdentityOption: false,
|
||||
showCodexPatOption: false,
|
||||
showSsoOption: false,
|
||||
showEmailPasswordOption: false,
|
||||
showManualOption: true,
|
||||
initialInputMethod: 'manual',
|
||||
initialEmailPassword: '',
|
||||
platform: 'anthropic',
|
||||
showProjectId: true
|
||||
})
|
||||
@@ -877,10 +971,15 @@ const emit = defineEmits<{
|
||||
'import-codex-session': [content: string]
|
||||
'import-codex-pat': [accessToken: string]
|
||||
'import-sso': [content: string]
|
||||
'authorize-password': [emailPasswordInput: string]
|
||||
'update:inputMethod': [method: AuthInputMethod]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const passwordAuthEnabled = ref(false)
|
||||
const emailPasswordOptionEnabled = computed(
|
||||
() => props.showEmailPasswordOption && props.platform === 'grok' && passwordAuthEnabled.value
|
||||
)
|
||||
|
||||
const showLocalCallbackNotice = computed(() => props.platform === 'openai' || props.platform === 'grok')
|
||||
|
||||
@@ -922,10 +1021,30 @@ const sessionTokenInput = ref('')
|
||||
const codexSessionInput = ref('')
|
||||
const codexPATInput = ref('')
|
||||
const ssoCookieInput = ref('')
|
||||
const emailPasswordInput = ref(props.initialEmailPassword || '')
|
||||
const showHelpDialog = ref(false)
|
||||
const oauthState = ref('')
|
||||
const projectId = ref('')
|
||||
|
||||
watch(
|
||||
() => [props.platform, props.showEmailPasswordOption] as const,
|
||||
async ([platform, requested]) => {
|
||||
passwordAuthEnabled.value = false
|
||||
if (platform !== 'grok' || !requested) return
|
||||
try {
|
||||
const capabilities = await adminAPI.grok.getCapabilities()
|
||||
passwordAuthEnabled.value = capabilities.password_auth_enabled
|
||||
} catch {
|
||||
// Fail closed; the backend enforces the same capability.
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(emailPasswordOptionEnabled, (enabled) => {
|
||||
if (!enabled && inputMethod.value === 'email_password') inputMethod.value = 'manual'
|
||||
})
|
||||
|
||||
// Computed: show method selection only when there is something to choose.
|
||||
const methodOptionCount = computed(() => [
|
||||
props.showManualOption,
|
||||
@@ -937,7 +1056,8 @@ const methodOptionCount = computed(() => [
|
||||
props.showCodexSessionImportOption,
|
||||
props.showAgentIdentityOption,
|
||||
props.showCodexPatOption,
|
||||
props.showSsoOption
|
||||
props.showSsoOption,
|
||||
emailPasswordOptionEnabled.value
|
||||
].filter(Boolean).length)
|
||||
const showMethodSelection = computed(() => methodOptionCount.value > 1)
|
||||
|
||||
@@ -977,11 +1097,34 @@ const parsedSSOCount = computed(() => {
|
||||
.filter((item) => item).length
|
||||
})
|
||||
|
||||
const parsedEmailPasswordCount = computed(() => {
|
||||
return emailPasswordInput.value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item && item.includes('----')).length
|
||||
})
|
||||
|
||||
const handleAuthorizePassword = () => {
|
||||
if (emailPasswordInput.value.trim()) {
|
||||
emit('authorize-password', emailPasswordInput.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.initialInputMethod, (newVal) => {
|
||||
inputMethod.value = newVal
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.initialEmailPassword,
|
||||
(newVal) => {
|
||||
// Only prefill when the field is empty so we never overwrite operator input.
|
||||
if (newVal && !emailPasswordInput.value.trim()) {
|
||||
emailPasswordInput.value = newVal
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
})
|
||||
@@ -1081,6 +1224,7 @@ defineExpose({
|
||||
codexSession: codexSessionInput,
|
||||
codexPAT: codexPATInput,
|
||||
ssoCookie: ssoCookieInput,
|
||||
emailPassword: emailPasswordInput,
|
||||
inputMethod,
|
||||
reset: () => {
|
||||
authCodeInput.value = ''
|
||||
@@ -1092,6 +1236,7 @@ defineExpose({
|
||||
codexSessionInput.value = ''
|
||||
codexPATInput.value = ''
|
||||
ssoCookieInput.value = ''
|
||||
emailPasswordInput.value = ''
|
||||
inputMethod.value = props.initialInputMethod
|
||||
showHelpDialog.value = false
|
||||
}
|
||||
|
||||
@@ -101,6 +101,14 @@
|
||||
{{ state?.error_message || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasThresholdEvidence"
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300"
|
||||
data-testid="temp-unsched-trigger-evidence"
|
||||
>
|
||||
{{ triggerEvidenceText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,10 +184,28 @@ const isActive = computed(() => {
|
||||
})
|
||||
|
||||
const ruleIndexDisplay = computed(() => {
|
||||
if (!state.value) return '-'
|
||||
if (!state.value || !state.value.matched_keyword || state.value.rule_index < 0) return '-'
|
||||
return state.value.rule_index + 1
|
||||
})
|
||||
|
||||
const hasThresholdEvidence = computed(() => (state.value?.trigger_count || 0) > 1)
|
||||
|
||||
const triggerEvidenceText = computed(() => {
|
||||
const count = state.value?.trigger_count || 0
|
||||
const threshold = state.value?.trigger_threshold || 0
|
||||
const minutes = state.value?.trigger_window_minutes || 0
|
||||
if (threshold > 0 && minutes > 0) {
|
||||
return t('admin.accounts.tempUnschedulable.multipleErrorTrigger', { count, threshold, minutes })
|
||||
}
|
||||
if (threshold > 0) {
|
||||
return t('admin.accounts.tempUnschedulable.multipleErrorTriggerNoWindow', { count, threshold })
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return t('admin.accounts.tempUnschedulable.multipleErrorCountInWindow', { count, minutes })
|
||||
}
|
||||
return t('admin.accounts.tempUnschedulable.multipleErrorCount', { count })
|
||||
})
|
||||
|
||||
const triggeredAtText = computed(() => {
|
||||
if (!state.value?.triggered_at_unix) return '-'
|
||||
return formatDateTime(new Date(state.value.triggered_at_unix * 1000))
|
||||
|
||||
@@ -651,7 +651,7 @@ describe('AccountUsageCell', () => {
|
||||
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
|
||||
})
|
||||
|
||||
it('Grok OAuth 会展示本地 user billed 用量并把耗尽配额显示为 0% 剩余', async () => {
|
||||
it('Grok OAuth compact UI drops local chips and header quota bars', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_local_usage: {
|
||||
requests: 4,
|
||||
@@ -670,12 +670,7 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 3861,
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
account: makeAccount({ id: 3861, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -683,66 +678,49 @@ describe('AccountUsageCell', () => {
|
||||
props: ['label', 'utilization', 'resetsAt', 'color'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledWith(3861)
|
||||
expect(wrapper.text()).toContain('4 req')
|
||||
expect(wrapper.text()).toContain('1.2K')
|
||||
expect(wrapper.text()).toContain('A $0.12')
|
||||
expect(wrapper.text()).toContain('U $0.34')
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|0|2026-07-09T16:00:00Z')
|
||||
|
||||
const badges = wrapper.findAll('span[title]')
|
||||
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
|
||||
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('4 req')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokRequests|')
|
||||
})
|
||||
|
||||
it('Grok OAuth 配额条按剩余容量显示 100% 满格和 25% 低量', async () => {
|
||||
it('Grok paid monthly limits show 30d bar without free 24h', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_request_quota: {
|
||||
limit: 100,
|
||||
remaining: 100,
|
||||
reset_at: '2026-07-09T16:00:00Z'
|
||||
grok_billing: {
|
||||
period_type: 'weekly',
|
||||
usage_percent: null,
|
||||
used_percent: 12,
|
||||
monthly_limit_cents: 25_000,
|
||||
used_cents: 3_000,
|
||||
plan: ''
|
||||
},
|
||||
grok_token_quota: {
|
||||
limit: 1000,
|
||||
remaining: 250,
|
||||
reset_at: '2026-07-09T16:00:00Z'
|
||||
},
|
||||
grok_quota_snapshot_state: 'observed'
|
||||
grok_entitlement_status: 'free',
|
||||
grok_token_quota: { limit: 1_000, remaining: 250 }
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 4073,
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
account: makeAccount({ id: 4402, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt', 'color', 'remainingCapacity'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ remainingCapacity }}</div>'
|
||||
props: ['label', 'utilization'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|100|true')
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25|true')
|
||||
expect(wrapper.text()).toContain('30d|')
|
||||
expect(wrapper.text()).not.toContain('24h|')
|
||||
})
|
||||
|
||||
it('Grok OAuth uses the official weekly billing percentage when available', async () => {
|
||||
@@ -775,7 +753,6 @@ describe('AccountUsageCell', () => {
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}|{{ remainingCapacity }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -789,11 +766,11 @@ describe('AccountUsageCell', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ tokens: 0, expected: 0, compact: '0' },
|
||||
{ tokens: 500_000, expected: 50, compact: '500.0K' },
|
||||
{ tokens: 1_000_000, expected: 100, compact: '1.0M' },
|
||||
{ tokens: 1_100_000, expected: 100, compact: '1.1M' }
|
||||
])('Grok Free derives its 1M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected, compact }) => {
|
||||
{ tokens: 0, expected: 0 },
|
||||
{ tokens: 500_000, expected: 50 },
|
||||
{ tokens: 1_000_000, expected: 100 },
|
||||
{ tokens: 1_100_000, expected: 100 }
|
||||
])('Grok Free derives its 1M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected }) => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_free_token_limit: 1_000_000,
|
||||
grok_billing: {
|
||||
@@ -823,7 +800,6 @@ describe('AccountUsageCell', () => {
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -831,10 +807,10 @@ describe('AccountUsageCell', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(`24h|${expected}`)
|
||||
expect(wrapper.findAll('span').filter((node) => node.text() === compact)).toHaveLength(1)
|
||||
expect(wrapper.findAll('.usage-bar')).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokRequests|')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokTokens|')
|
||||
expect(wrapper.text()).not.toContain('7d|')
|
||||
})
|
||||
|
||||
it('Grok Free uses rolling 24h usage instead of today-only usage', async () => {
|
||||
@@ -872,7 +848,6 @@ describe('AccountUsageCell', () => {
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ title }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -880,7 +855,6 @@ describe('AccountUsageCell', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('24h|75|admin.accounts.usageWindow.grokFreeQuota24hHint')
|
||||
expect(wrapper.text()).toContain('750.0K')
|
||||
expect(wrapper.text()).not.toContain('7d|')
|
||||
expect(wrapper.text()).not.toContain('200.0K')
|
||||
expect(wrapper.text()).not.toContain('250.0K')
|
||||
@@ -917,7 +891,6 @@ describe('AccountUsageCell', () => {
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -930,85 +903,6 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).not.toContain('250.0K')
|
||||
})
|
||||
|
||||
it('Grok paid plans are not mistaken for Free when weekly usage is temporarily missing', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_billing: {
|
||||
period_type: 'weekly',
|
||||
usage_percent: null,
|
||||
plan: 'SuperGrok Heavy'
|
||||
},
|
||||
grok_entitlement_status: 'free',
|
||||
grok_local_usage: {
|
||||
requests: 2,
|
||||
tokens: 2_000_000,
|
||||
cost: 1,
|
||||
standard_cost: 1
|
||||
},
|
||||
grok_token_quota: { limit: 1_000, remaining: 250 }
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({ id: 4401, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25')
|
||||
expect(wrapper.text()).not.toContain('2M|')
|
||||
})
|
||||
|
||||
it('Grok custom paid monthly limits override stale Free entitlement', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_billing: {
|
||||
period_type: 'weekly',
|
||||
usage_percent: null,
|
||||
monthly_limit_cents: 25_000,
|
||||
plan: ''
|
||||
},
|
||||
grok_entitlement_status: 'free',
|
||||
grok_local_usage: {
|
||||
requests: 2,
|
||||
tokens: 2_000_000,
|
||||
cost: 1,
|
||||
standard_cost: 1
|
||||
},
|
||||
grok_token_quota: { limit: 1_000, remaining: 250 }
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({ id: 4402, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25')
|
||||
expect(wrapper.text()).not.toContain('2M|')
|
||||
})
|
||||
|
||||
it('Grok credential Free tier keeps the 1M fallback when billing is unavailable', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_free_token_limit: 1_000_000,
|
||||
@@ -1032,7 +926,6 @@ describe('AccountUsageCell', () => {
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1042,257 +935,6 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('24h|100')
|
||||
})
|
||||
|
||||
it('Grok paid manual probes keep the weekly/local summary when 24h usage is returned', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_quota_snapshot_state: 'no_headers',
|
||||
error: 'stale error',
|
||||
error_code: 'quota_unknown'
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({ id: 4501, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: {
|
||||
emits: ['probed'],
|
||||
template: `<button class="probe" @click="$emit('probed', {
|
||||
source: 'hybrid_probe',
|
||||
billing: { period_type: 'weekly', usage_percent: 42, period_end: '2026-07-17T00:00:00Z' },
|
||||
snapshot: {
|
||||
headers_observed: true,
|
||||
updated_at: '2026-07-13T00:00:00Z',
|
||||
entitlement_status: 'ACTIVE',
|
||||
requests: { limit: 100, remaining: 20 }
|
||||
},
|
||||
local_usage_24h: { requests: 3, tokens: 750000, cost: 0.75, standard_cost: 0.75, user_cost: 0.25 },
|
||||
local_usage_7d: { requests: 4, tokens: 1000000, cost: 1, standard_cost: 1, user_cost: 0.5 },
|
||||
local_usage_monthly: { requests: 7, tokens: 1500000, cost: 2, standard_cost: 2, user_cost: 1 },
|
||||
status_code: 200,
|
||||
headers_observed: true,
|
||||
reset_supported: false,
|
||||
fetched_at: 1
|
||||
})">probe</button>`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('.probe').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('7d|42|2026-07-17T00:00:00Z')
|
||||
expect(wrapper.text()).toContain('1.0M')
|
||||
expect(wrapper.text()).not.toContain('750.0K')
|
||||
expect(wrapper.text()).toContain('ACTIVE')
|
||||
expect(wrapper.text()).not.toContain('stale error')
|
||||
})
|
||||
|
||||
it('Grok successful probes immediately clear stale forbidden state', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
is_forbidden: true,
|
||||
forbidden_reason: 'stale forbidden response',
|
||||
forbidden_type: 'validation',
|
||||
validation_url: 'https://example.com/verify',
|
||||
needs_verify: true,
|
||||
is_banned: true,
|
||||
grok_entitlement_status: 'forbidden',
|
||||
grok_quota_snapshot_state: 'no_headers',
|
||||
error: 'stale forbidden response',
|
||||
error_code: 'forbidden'
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({ id: 4503, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('forbidden')
|
||||
|
||||
const setupState = wrapper.vm.$.setupState as {
|
||||
handleGrokProbed: (result: Record<string, unknown>) => void
|
||||
usageInfo: Record<string, unknown> | null
|
||||
}
|
||||
setupState.handleGrokProbed({
|
||||
source: 'active_probe',
|
||||
snapshot: {
|
||||
headers_observed: false,
|
||||
updated_at: '2026-07-18T00:00:00Z',
|
||||
status_code: 200
|
||||
},
|
||||
status_code: 200,
|
||||
headers_observed: false,
|
||||
reset_supported: false,
|
||||
fetched_at: 1
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(setupState.usageInfo).toMatchObject({
|
||||
is_forbidden: false,
|
||||
needs_verify: false,
|
||||
is_banned: false,
|
||||
grok_last_status_code: 200
|
||||
})
|
||||
expect(setupState.usageInfo?.forbidden_reason).toBeUndefined()
|
||||
expect(setupState.usageInfo?.forbidden_type).toBeUndefined()
|
||||
expect(setupState.usageInfo?.validation_url).toBeUndefined()
|
||||
expect(setupState.usageInfo?.grok_entitlement_status).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.forbidden')
|
||||
})
|
||||
|
||||
it('Grok successful probes preserve the entitlement reported by the latest snapshot', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
is_forbidden: true,
|
||||
grok_entitlement_status: 'forbidden'
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({ id: 4504, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const setupState = wrapper.vm.$.setupState as {
|
||||
handleGrokProbed: (result: Record<string, unknown>) => void
|
||||
usageInfo: Record<string, unknown> | null
|
||||
}
|
||||
setupState.handleGrokProbed({
|
||||
source: 'active_probe',
|
||||
snapshot: {
|
||||
headers_observed: true,
|
||||
updated_at: '2026-07-18T00:00:00Z',
|
||||
entitlement_status: 'ACTIVE',
|
||||
status_code: 200
|
||||
},
|
||||
status_code: 200,
|
||||
headers_observed: true,
|
||||
reset_supported: false,
|
||||
fetched_at: 1
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(setupState.usageInfo?.grok_entitlement_status).toBe('ACTIVE')
|
||||
expect(wrapper.text()).toContain('ACTIVE')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.forbidden')
|
||||
})
|
||||
|
||||
it('Grok billing-only success does not clear an active-probe forbidden state', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
is_forbidden: true,
|
||||
forbidden_type: 'forbidden',
|
||||
needs_verify: true,
|
||||
is_banned: true,
|
||||
grok_entitlement_status: 'forbidden'
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({ id: 4505, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const setupState = wrapper.vm.$.setupState as {
|
||||
handleGrokProbed: (result: Record<string, unknown>) => void
|
||||
usageInfo: Record<string, unknown> | null
|
||||
}
|
||||
setupState.handleGrokProbed({
|
||||
source: 'billing_probe',
|
||||
billing: {
|
||||
period_type: 'weekly',
|
||||
usage_percent: 10,
|
||||
plan: 'SuperGrok'
|
||||
},
|
||||
status_code: 200,
|
||||
headers_observed: false,
|
||||
reset_supported: false,
|
||||
fetched_at: 1
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(setupState.usageInfo).toMatchObject({
|
||||
is_forbidden: true,
|
||||
forbidden_type: 'forbidden',
|
||||
needs_verify: true,
|
||||
is_banned: true,
|
||||
grok_entitlement_status: 'forbidden'
|
||||
})
|
||||
expect(wrapper.text()).toContain('forbidden')
|
||||
})
|
||||
|
||||
it('Grok Free manual probes merge rolling 24h usage', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_free_token_limit: 1_000_000,
|
||||
subscription_tier: 'FREE',
|
||||
grok_quota_snapshot_state: 'no_headers'
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({ id: 4502, platform: 'grok', type: 'oauth', extra: {} })
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: {
|
||||
emits: ['probed'],
|
||||
template: `<button class="probe" @click="$emit('probed', {
|
||||
source: 'hybrid_probe',
|
||||
billing: { period_type: 'weekly', usage_percent: null, plan: '' },
|
||||
local_usage_24h: { requests: 12, tokens: 750000, cost: 0, standard_cost: 0 },
|
||||
headers_observed: false,
|
||||
reset_supported: false,
|
||||
fetched_at: 1
|
||||
})">probe</button>`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('.probe').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('24h|75')
|
||||
expect(wrapper.text()).toContain('750.0K')
|
||||
expect(wrapper.text()).not.toContain('7d|')
|
||||
})
|
||||
|
||||
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
@@ -1424,7 +1066,6 @@ describe('AccountUsageCell', () => {
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1468,7 +1109,6 @@ describe('AccountUsageCell', () => {
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,9 +23,13 @@ describe('CreateAccountModal Grok account types', () => {
|
||||
expect(source).toContain('form.platform === \'grok\' && isOAuthFlow')
|
||||
})
|
||||
|
||||
it('validates and applies upstream config on all three Grok OAuth create paths', () => {
|
||||
// 授权码兑换 / RT 批量 / SSO 批量 3 处调用(定义为箭头函数,不计入)
|
||||
expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBe(3)
|
||||
expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBe(3)
|
||||
it('validates and applies upstream config on Grok OAuth create paths', () => {
|
||||
// 授权码兑换 / RT 批量 / SSO 批量(密码授权已隐藏)
|
||||
expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBeGreaterThanOrEqual(3)
|
||||
expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('hides Grok password authorize option in the create flow', () => {
|
||||
expect(source).toContain(':show-email-password-option="false"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,13 +41,28 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<!-- Grok: mode first, then optional model / mode params -->
|
||||
<div v-if="isGrokAccount" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.grok.testMode') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="grokTestMode"
|
||||
:options="grokTestModeOptions"
|
||||
:disabled="status === 'connecting'"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.grok.testModeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showModelSelect" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.selectTestModel') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="selectedModelId"
|
||||
:options="availableModels"
|
||||
:options="modelOptionsForMode"
|
||||
:disabled="loadingModels || status === 'connecting'"
|
||||
value-key="id"
|
||||
label-key="display_name"
|
||||
@@ -66,16 +81,94 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="supportsImageTest" class="space-y-1.5">
|
||||
<div v-if="supportsPromptInput" class="space-y-1.5">
|
||||
<TextArea
|
||||
v-model="testPrompt"
|
||||
:label="t('admin.accounts.imagePromptLabel')"
|
||||
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
|
||||
:hint="t('admin.accounts.imageTestHint')"
|
||||
:label="promptInputLabel"
|
||||
:placeholder="promptInputPlaceholder"
|
||||
:hint="promptInputHint"
|
||||
:disabled="status === 'connecting'"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="isGrokAccount && promptInputHint"
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ promptInputHint }}
|
||||
</p>
|
||||
|
||||
<!-- Optional media uploads for real generation / transcription -->
|
||||
<div v-if="supportsImageUpload" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ imageUploadLabel }}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm shrink-0"
|
||||
:disabled="status === 'connecting'"
|
||||
@click="imageFileInput?.click()"
|
||||
>
|
||||
{{ t('admin.accounts.grok.chooseImageFile') }}
|
||||
</button>
|
||||
<span class="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
uploadImageName
|
||||
? t('common.selectedFile', { name: uploadImageName })
|
||||
: t('common.noFileSelected')
|
||||
}}
|
||||
</span>
|
||||
<input
|
||||
ref="imageFileInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
class="hidden"
|
||||
:disabled="status === 'connecting'"
|
||||
@change="onImageFileChange"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ imageUploadHint }}</p>
|
||||
<div v-if="uploadImagePreview" class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-500">
|
||||
<img
|
||||
:src="uploadImagePreview"
|
||||
:alt="t('admin.accounts.grok.uploadPreviewAlt')"
|
||||
class="max-h-40 w-full object-contain bg-gray-50 dark:bg-dark-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="supportsAudioUpload" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.grok.audioUploadLabel') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm shrink-0"
|
||||
:disabled="status === 'connecting'"
|
||||
@click="audioFileInput?.click()"
|
||||
>
|
||||
{{ t('admin.accounts.grok.chooseAudioFile') }}
|
||||
</button>
|
||||
<span class="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
uploadAudioName
|
||||
? t('common.selectedFile', { name: uploadAudioName })
|
||||
: t('common.noFileSelected')
|
||||
}}
|
||||
</span>
|
||||
<input
|
||||
ref="audioFileInput"
|
||||
type="file"
|
||||
accept="audio/*,.wav,.mp3,.m4a,.ogg,.webm"
|
||||
class="hidden"
|
||||
:disabled="status === 'connecting'"
|
||||
@change="onAudioFileChange"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.audioUploadHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div class="group relative">
|
||||
@@ -142,7 +235,11 @@
|
||||
class="group/img relative cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
|
||||
@click="previewImageUrl = image.url"
|
||||
>
|
||||
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="t('admin.accounts.imagePreviewAlt', { index: index + 1 })"
|
||||
class="max-h-[360px] w-full object-contain"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
|
||||
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
|
||||
</div>
|
||||
@@ -153,6 +250,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="generatedAudios.length > 0" class="space-y-2">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.accounts.audioPreview') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(audio, index) in generatedAudios"
|
||||
:key="`audio-${index}`"
|
||||
class="rounded-xl border border-gray-200 bg-white p-3 dark:border-dark-500 dark:bg-dark-700"
|
||||
>
|
||||
<audio :src="audio.url" controls class="w-full" :type="audio.mimeType" />
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-300">{{ audio.mimeType || 'audio/*' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="generatedVideos.length > 0" class="space-y-2">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.accounts.videoPreview') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(video, index) in generatedVideos"
|
||||
:key="`video-${index}`"
|
||||
class="overflow-hidden rounded-xl border border-gray-200 bg-black dark:border-dark-500"
|
||||
>
|
||||
<video :src="video.url" controls class="max-h-[360px] w-full" :type="video.mimeType" />
|
||||
<div class="border-t border-gray-100 bg-white px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-300">
|
||||
{{ video.mimeType || 'video/*' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Lightbox -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
@@ -169,7 +296,7 @@
|
||||
</button>
|
||||
<img
|
||||
:src="previewImageUrl"
|
||||
alt="preview"
|
||||
:alt="t('admin.accounts.imageLightboxAlt')"
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
@@ -186,11 +313,7 @@
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{
|
||||
supportsImageTest
|
||||
? t('admin.accounts.imageTestMode')
|
||||
: t('admin.accounts.testPrompt')
|
||||
}}
|
||||
{{ testModeSummary }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,10 +328,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:disabled="!canStartTest"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || !selectedModelId
|
||||
!canStartTest
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
@@ -262,7 +385,7 @@ interface OutputLine {
|
||||
class: string
|
||||
}
|
||||
|
||||
interface PreviewImage {
|
||||
interface PreviewMedia {
|
||||
url: string
|
||||
mimeType?: string
|
||||
}
|
||||
@@ -286,14 +409,34 @@ const selectedModelId = ref('')
|
||||
const testPrompt = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
const generatedImages = ref<PreviewImage[]>([])
|
||||
const generatedImages = ref<PreviewMedia[]>([])
|
||||
const generatedAudios = ref<PreviewMedia[]>([])
|
||||
const generatedVideos = ref<PreviewMedia[]>([])
|
||||
const previewImageUrl = ref('')
|
||||
const testMode = ref<'default' | 'compact'>('default')
|
||||
const grokTestMode = ref<'text' | 'image' | 'video' | 'search' | 'tts' | 'stt' | 'realtime'>('text')
|
||||
const uploadImageDataURL = ref('')
|
||||
const uploadImagePreview = ref('')
|
||||
const uploadImageName = ref('')
|
||||
const uploadAudioDataURL = ref('')
|
||||
const uploadAudioName = ref('')
|
||||
const imageFileInput = ref<HTMLInputElement | null>(null)
|
||||
const audioFileInput = ref<HTMLInputElement | null>(null)
|
||||
const isOpenAIAccount = computed(() => props.account?.platform === 'openai')
|
||||
const isGrokAccount = computed(() => props.account?.platform === 'grok')
|
||||
const openAITestModeOptions = computed(() => [
|
||||
{ value: 'default', label: t('admin.accounts.openai.testModeDefault') },
|
||||
{ value: 'compact', label: t('admin.accounts.openai.testModeCompact') }
|
||||
])
|
||||
const grokTestModeOptions = computed(() => [
|
||||
{ value: 'text', label: t('admin.accounts.grok.testModeText') },
|
||||
{ value: 'image', label: t('admin.accounts.grok.testModeImage') },
|
||||
{ value: 'video', label: t('admin.accounts.grok.testModeVideo') },
|
||||
{ value: 'search', label: t('admin.accounts.grok.testModeSearch') },
|
||||
{ value: 'tts', label: t('admin.accounts.grok.testModeTTS') },
|
||||
{ value: 'stt', label: t('admin.accounts.grok.testModeSTT') },
|
||||
{ value: 'realtime', label: t('admin.accounts.grok.testModeRealtime') }
|
||||
])
|
||||
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-3.5-flash', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
|
||||
const supportsGeminiImageTest = computed(() => {
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
@@ -308,7 +451,243 @@ const supportsOpenAIImageTest = computed(() => {
|
||||
return props.account?.platform === 'openai'
|
||||
})
|
||||
|
||||
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
|
||||
const isGrokImageModel = (id: string) => {
|
||||
const modelID = id.toLowerCase()
|
||||
return (
|
||||
modelID === 'grok-imagine' ||
|
||||
modelID === 'grok-imagine-edit' ||
|
||||
modelID.startsWith('grok-imagine-image')
|
||||
)
|
||||
}
|
||||
const isGrokVideoModel = (id: string) => {
|
||||
const modelID = id.toLowerCase()
|
||||
return modelID.startsWith('grok-imagine-video') || modelID.startsWith('grok-video')
|
||||
}
|
||||
const isGrokTextModel = (id: string) => !isGrokImageModel(id) && !isGrokVideoModel(id)
|
||||
|
||||
const supportsGrokImageTest = computed(
|
||||
() => isGrokAccount.value && grokTestMode.value === 'image'
|
||||
)
|
||||
const supportsGrokVideoTest = computed(
|
||||
() => isGrokAccount.value && grokTestMode.value === 'video'
|
||||
)
|
||||
|
||||
const supportsImageTest = computed(
|
||||
() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value || supportsGrokImageTest.value
|
||||
)
|
||||
|
||||
// Model select only when the mode needs a model.
|
||||
const showModelSelect = computed(() => {
|
||||
if (!isGrokAccount.value) return true
|
||||
return grokTestMode.value === 'text' || grokTestMode.value === 'image' || grokTestMode.value === 'video'
|
||||
})
|
||||
|
||||
const modelOptionsForMode = computed(() => {
|
||||
if (!isGrokAccount.value) return availableModels.value
|
||||
if (grokTestMode.value === 'image') {
|
||||
return availableModels.value.filter((m) => isGrokImageModel(m.id))
|
||||
}
|
||||
if (grokTestMode.value === 'video') {
|
||||
return availableModels.value.filter((m) => isGrokVideoModel(m.id))
|
||||
}
|
||||
if (grokTestMode.value === 'text') {
|
||||
return availableModels.value.filter((m) => isGrokTextModel(m.id))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const supportsPromptInput = computed(() => {
|
||||
if (!isGrokAccount.value) {
|
||||
return supportsImageTest.value
|
||||
}
|
||||
return (
|
||||
grokTestMode.value === 'image' ||
|
||||
grokTestMode.value === 'video' ||
|
||||
grokTestMode.value === 'search' ||
|
||||
grokTestMode.value === 'tts'
|
||||
)
|
||||
})
|
||||
|
||||
const supportsImageUpload = computed(
|
||||
() => isGrokAccount.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')
|
||||
)
|
||||
const supportsAudioUpload = computed(() => isGrokAccount.value && grokTestMode.value === 'stt')
|
||||
const imageUploadLabel = computed(() =>
|
||||
grokTestMode.value === 'video'
|
||||
? t('admin.accounts.grok.videoFirstFrameLabel')
|
||||
: t('admin.accounts.grok.imageUploadLabel')
|
||||
)
|
||||
|
||||
const imageUploadHint = computed(() =>
|
||||
grokTestMode.value === 'video'
|
||||
? t('admin.accounts.grok.videoFirstFrameHint')
|
||||
: t('admin.accounts.grok.imageUploadHint')
|
||||
)
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = () => reject(new Error(t('admin.accounts.grok.fileReadFailed')))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
const onImageFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) {
|
||||
uploadImageDataURL.value = ''
|
||||
uploadImagePreview.value = ''
|
||||
uploadImageName.value = ''
|
||||
return
|
||||
}
|
||||
if (file.size > 6 * 1024 * 1024) {
|
||||
errorMessage.value = t('admin.accounts.grok.mediaTooLarge')
|
||||
status.value = 'error'
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dataURL = await readFileAsDataURL(file)
|
||||
uploadImageDataURL.value = dataURL
|
||||
uploadImagePreview.value = dataURL
|
||||
uploadImageName.value = file.name
|
||||
} catch {
|
||||
uploadImageDataURL.value = ''
|
||||
uploadImagePreview.value = ''
|
||||
uploadImageName.value = ''
|
||||
errorMessage.value = t('admin.accounts.grok.fileReadFailed')
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
const onAudioFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) {
|
||||
uploadAudioDataURL.value = ''
|
||||
uploadAudioName.value = ''
|
||||
return
|
||||
}
|
||||
if (file.size > 6 * 1024 * 1024) {
|
||||
errorMessage.value = t('admin.accounts.grok.mediaTooLarge')
|
||||
status.value = 'error'
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
uploadAudioDataURL.value = await readFileAsDataURL(file)
|
||||
uploadAudioName.value = file.name
|
||||
} catch {
|
||||
uploadAudioDataURL.value = ''
|
||||
uploadAudioName.value = ''
|
||||
errorMessage.value = t('admin.accounts.grok.fileReadFailed')
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
const clearMediaUploads = () => {
|
||||
uploadImageDataURL.value = ''
|
||||
uploadImagePreview.value = ''
|
||||
uploadImageName.value = ''
|
||||
uploadAudioDataURL.value = ''
|
||||
uploadAudioName.value = ''
|
||||
if (imageFileInput.value) imageFileInput.value.value = ''
|
||||
if (audioFileInput.value) audioFileInput.value.value = ''
|
||||
}
|
||||
|
||||
const promptInputLabel = computed(() => {
|
||||
if (supportsGrokVideoTest.value || grokTestMode.value === 'video') {
|
||||
return t('admin.accounts.videoPromptLabel')
|
||||
}
|
||||
if (supportsImageTest.value || grokTestMode.value === 'image') {
|
||||
return t('admin.accounts.imagePromptLabel')
|
||||
}
|
||||
if (grokTestMode.value === 'search') {
|
||||
return t('admin.accounts.grok.searchQueryLabel')
|
||||
}
|
||||
if (grokTestMode.value === 'tts') {
|
||||
return t('admin.accounts.grok.ttsTextLabel')
|
||||
}
|
||||
return t('admin.accounts.imagePromptLabel')
|
||||
})
|
||||
|
||||
const promptInputPlaceholder = computed(() => {
|
||||
if (grokTestMode.value === 'video') {
|
||||
return t('admin.accounts.videoPromptPlaceholder')
|
||||
}
|
||||
if (grokTestMode.value === 'image' || supportsImageTest.value) {
|
||||
return t('admin.accounts.imagePromptPlaceholder')
|
||||
}
|
||||
if (grokTestMode.value === 'search') {
|
||||
return t('admin.accounts.grok.searchQueryPlaceholder')
|
||||
}
|
||||
if (grokTestMode.value === 'tts') {
|
||||
return t('admin.accounts.grok.ttsTextPlaceholder')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const promptInputHint = computed(() => {
|
||||
if (grokTestMode.value === 'video') {
|
||||
return t('admin.accounts.videoTestHint')
|
||||
}
|
||||
if (grokTestMode.value === 'image' || supportsImageTest.value) {
|
||||
return t('admin.accounts.imageTestHint')
|
||||
}
|
||||
if (grokTestMode.value === 'search') {
|
||||
return t('admin.accounts.grok.searchTestHint')
|
||||
}
|
||||
if (grokTestMode.value === 'tts') {
|
||||
return t('admin.accounts.grok.ttsTestHint')
|
||||
}
|
||||
if (grokTestMode.value === 'stt') {
|
||||
return t('admin.accounts.grok.sttTestHint')
|
||||
}
|
||||
if (grokTestMode.value === 'realtime') {
|
||||
return t('admin.accounts.grok.realtimeTestHint')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const testModeSummary = computed(() => {
|
||||
if (isGrokAccount.value) {
|
||||
switch (grokTestMode.value) {
|
||||
case 'video':
|
||||
return t('admin.accounts.videoTestMode')
|
||||
case 'image':
|
||||
return t('admin.accounts.imageTestMode')
|
||||
case 'search':
|
||||
return t('admin.accounts.grok.searchTestMode')
|
||||
case 'tts':
|
||||
return t('admin.accounts.grok.ttsTestMode')
|
||||
case 'stt':
|
||||
return t('admin.accounts.grok.sttTestMode')
|
||||
case 'realtime':
|
||||
return t('admin.accounts.grok.realtimeTestMode')
|
||||
default:
|
||||
return t('admin.accounts.grok.textTestMode')
|
||||
}
|
||||
}
|
||||
if (supportsImageTest.value) return t('admin.accounts.imageTestMode')
|
||||
return t('admin.accounts.testPrompt')
|
||||
})
|
||||
|
||||
const canStartTest = computed(() => {
|
||||
if (status.value === 'connecting') return false
|
||||
if (isGrokAccount.value) {
|
||||
if (
|
||||
grokTestMode.value === 'search' ||
|
||||
grokTestMode.value === 'tts' ||
|
||||
grokTestMode.value === 'stt' ||
|
||||
grokTestMode.value === 'realtime'
|
||||
) {
|
||||
return true // standalone modes (prompt/model optional)
|
||||
}
|
||||
return Boolean(selectedModelId.value)
|
||||
}
|
||||
return Boolean(selectedModelId.value)
|
||||
})
|
||||
|
||||
const sortTestModels = (models: ClaudeModel[]) => {
|
||||
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
||||
@@ -322,24 +701,63 @@ const sortTestModels = (models: ClaudeModel[]) => {
|
||||
}
|
||||
|
||||
// Load available models when modal opens
|
||||
const applyDefaultPromptForMode = () => {
|
||||
if (!supportsPromptInput.value) return
|
||||
if (testPrompt.value.trim()) return
|
||||
if (grokTestMode.value === 'video') {
|
||||
testPrompt.value = t('admin.accounts.videoPromptDefault')
|
||||
} else if (grokTestMode.value === 'image' || supportsImageTest.value) {
|
||||
testPrompt.value = t('admin.accounts.imagePromptDefault')
|
||||
} else if (grokTestMode.value === 'search') {
|
||||
testPrompt.value = t('admin.accounts.grok.searchQueryDefault')
|
||||
} else if (grokTestMode.value === 'tts') {
|
||||
testPrompt.value = t('admin.accounts.grok.ttsTextDefault')
|
||||
}
|
||||
}
|
||||
|
||||
const pickDefaultModelForMode = () => {
|
||||
const opts = modelOptionsForMode.value
|
||||
if (!opts.length) {
|
||||
selectedModelId.value = ''
|
||||
return
|
||||
}
|
||||
if (opts.some((m) => m.id === selectedModelId.value)) return
|
||||
if (grokTestMode.value === 'text') {
|
||||
const preferred =
|
||||
opts.find((m) => m.id.includes('grok-4.5')) ||
|
||||
opts.find((m) => m.id === 'grok') ||
|
||||
opts[0]
|
||||
selectedModelId.value = preferred.id
|
||||
return
|
||||
}
|
||||
selectedModelId.value = opts[0].id
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
testPrompt.value = ''
|
||||
testMode.value = 'default'
|
||||
grokTestMode.value = 'text'
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
if (isGrokAccount.value) {
|
||||
pickDefaultModelForMode()
|
||||
applyDefaultPromptForMode()
|
||||
}
|
||||
} else {
|
||||
abortStream()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(selectedModelId, () => {
|
||||
if (supportsImageTest.value && !testPrompt.value.trim()) {
|
||||
testPrompt.value = t('admin.accounts.imagePromptDefault')
|
||||
}
|
||||
watch(grokTestMode, () => {
|
||||
if (!isGrokAccount.value) return
|
||||
testPrompt.value = ''
|
||||
clearMediaUploads()
|
||||
pickDefaultModelForMode()
|
||||
applyDefaultPromptForMode()
|
||||
})
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
@@ -378,6 +796,8 @@ const resetState = () => {
|
||||
streamingContent.value = ''
|
||||
errorMessage.value = ''
|
||||
generatedImages.value = []
|
||||
generatedAudios.value = []
|
||||
generatedVideos.value = []
|
||||
previewImageUrl.value = ''
|
||||
}
|
||||
|
||||
@@ -406,12 +826,17 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
|
||||
const startTest = async () => {
|
||||
if (!props.account || !selectedModelId.value) return
|
||||
if (!props.account || !canStartTest.value) return
|
||||
|
||||
resetState()
|
||||
status.value = 'connecting'
|
||||
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
|
||||
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
|
||||
if (isGrokAccount.value) {
|
||||
const modeLabel =
|
||||
grokTestModeOptions.value.find((o) => o.value === grokTestMode.value)?.label || grokTestMode.value
|
||||
addLine(t('admin.accounts.grok.selectedTestMode', { mode: modeLabel }), 'text-gray-400')
|
||||
}
|
||||
addLine('', 'text-gray-300')
|
||||
|
||||
abortStream()
|
||||
@@ -422,14 +847,35 @@ const startTest = async () => {
|
||||
const requestBody: {
|
||||
model_id: string
|
||||
prompt: string
|
||||
mode?: 'default' | 'compact'
|
||||
mode?: string
|
||||
image_data_url?: string
|
||||
audio_data_url?: string
|
||||
} = {
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
|
||||
model_id: showModelSelect.value ? selectedModelId.value : '',
|
||||
prompt: supportsPromptInput.value ? testPrompt.value.trim() : ''
|
||||
}
|
||||
if (isOpenAIAccount.value) {
|
||||
requestBody.mode = testMode.value
|
||||
}
|
||||
if (isGrokAccount.value) {
|
||||
// Always send explicit Grok mode. search/tts/stt/realtime are standalone
|
||||
// endpoints (no free-form model select). text/image/video use optional model.
|
||||
requestBody.mode = grokTestMode.value
|
||||
if (
|
||||
grokTestMode.value === 'search' ||
|
||||
grokTestMode.value === 'tts' ||
|
||||
grokTestMode.value === 'stt' ||
|
||||
grokTestMode.value === 'realtime'
|
||||
) {
|
||||
requestBody.model_id = ''
|
||||
}
|
||||
if (uploadImageDataURL.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')) {
|
||||
requestBody.image_data_url = uploadImageDataURL.value
|
||||
}
|
||||
if (uploadAudioDataURL.value && grokTestMode.value === 'stt') {
|
||||
requestBody.audio_data_url = uploadAudioDataURL.value
|
||||
}
|
||||
}
|
||||
|
||||
// Use the configured API base; EventSource does not support POST.
|
||||
const url = buildApiUrl(`/admin/accounts/${props.account.id}/test`)
|
||||
@@ -452,7 +898,7 @@ const startTest = async () => {
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('No response body')
|
||||
throw new Error(t('admin.accounts.grok.noResponseBody'))
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
@@ -486,9 +932,9 @@ const startTest = async () => {
|
||||
return
|
||||
}
|
||||
status.value = 'error'
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error'
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError')
|
||||
errorMessage.value = msg
|
||||
addLine(`Error: ${msg}`, 'text-red-400')
|
||||
addLine(t('admin.accounts.errorPrefix', { message: msg }), 'text-red-400')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +945,8 @@ const handleEvent = (event: {
|
||||
success?: boolean
|
||||
error?: string
|
||||
image_url?: string
|
||||
audio_url?: string
|
||||
video_url?: string
|
||||
mime_type?: string
|
||||
}) => {
|
||||
switch (event.type) {
|
||||
@@ -508,7 +956,21 @@ const handleEvent = (event: {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(
|
||||
supportsImageTest.value
|
||||
isGrokAccount.value
|
||||
? grokTestMode.value === 'video'
|
||||
? t('admin.accounts.sendingVideoRequest')
|
||||
: grokTestMode.value === 'image'
|
||||
? t('admin.accounts.sendingImageRequest')
|
||||
: grokTestMode.value === 'search'
|
||||
? t('admin.accounts.grok.sendingSearchRequest')
|
||||
: grokTestMode.value === 'tts'
|
||||
? t('admin.accounts.grok.sendingTTSRequest')
|
||||
: grokTestMode.value === 'stt'
|
||||
? t('admin.accounts.grok.sendingSTTRequest')
|
||||
: grokTestMode.value === 'realtime'
|
||||
? t('admin.accounts.grok.sendingRealtimeRequest')
|
||||
: t('admin.accounts.sendingTestMessage')
|
||||
: supportsImageTest.value
|
||||
? t('admin.accounts.sendingImageRequest')
|
||||
: t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
@@ -534,6 +996,26 @@ const handleEvent = (event: {
|
||||
}
|
||||
break
|
||||
|
||||
case 'audio':
|
||||
if (event.audio_url) {
|
||||
generatedAudios.value.push({
|
||||
url: event.audio_url,
|
||||
mimeType: event.mime_type
|
||||
})
|
||||
addLine(t('admin.accounts.audioReceived', { count: generatedAudios.value.length }), 'text-purple-300')
|
||||
}
|
||||
break
|
||||
|
||||
case 'video':
|
||||
if (event.video_url) {
|
||||
generatedVideos.value.push({
|
||||
url: event.video_url,
|
||||
mimeType: event.mime_type
|
||||
})
|
||||
addLine(t('admin.accounts.videoReceived', { count: generatedVideos.value.length }), 'text-purple-300')
|
||||
}
|
||||
break
|
||||
|
||||
case 'status':
|
||||
if (event.text) {
|
||||
addLine(event.text, 'text-cyan-300')
|
||||
@@ -550,13 +1032,13 @@ const handleEvent = (event: {
|
||||
status.value = 'success'
|
||||
} else {
|
||||
status.value = 'error'
|
||||
errorMessage.value = event.error || 'Test failed'
|
||||
errorMessage.value = event.error || t('admin.accounts.testFailed')
|
||||
}
|
||||
break
|
||||
|
||||
case 'error':
|
||||
status.value = 'error'
|
||||
errorMessage.value = event.error || 'Unknown error'
|
||||
errorMessage.value = event.error || t('common.unknownError')
|
||||
if (streamingContent.value) {
|
||||
addLine(streamingContent.value, 'text-green-300')
|
||||
streamingContent.value = ''
|
||||
|
||||
@@ -130,12 +130,18 @@
|
||||
:show-help="isAnthropic"
|
||||
:show-proxy-warning="isAnthropic"
|
||||
:show-cookie-option="isAnthropic"
|
||||
:show-refresh-token-option="isOpenAI || isAntigravity || isGrok"
|
||||
:show-sso-option="isGrok"
|
||||
:show-email-password-option="false"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : isGrok ? 'grok' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
:initial-input-method="grokInitialInputMethod"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@validate-refresh-token="handleGrokValidateRefreshToken"
|
||||
@import-sso="handleGrokImportSSO"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -249,6 +255,21 @@ const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
|
||||
const isGrok = computed(() => props.account?.platform === 'grok')
|
||||
|
||||
/**
|
||||
* Grok reauth default tab (password auth is hidden):
|
||||
* - refresh_token when RT may still work
|
||||
* - SSO cookie otherwise
|
||||
*/
|
||||
const grokInitialInputMethod = computed<AuthInputMethod>(() => {
|
||||
if (!isGrok.value) return 'manual'
|
||||
const creds = (props.account?.credentials || {}) as Record<string, unknown>
|
||||
const hasRT =
|
||||
(typeof creds.refresh_token === 'string' && creds.refresh_token.trim() !== '') ||
|
||||
(typeof creds.has_refresh_token === 'boolean' && creds.has_refresh_token)
|
||||
if (hasRT) return 'refresh_token'
|
||||
return 'sso_cookie'
|
||||
})
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (isOpenAILike.value) return openaiOAuth.authUrl.value
|
||||
@@ -279,10 +300,20 @@ const currentError = computed(() => {
|
||||
return claudeOAuth.error.value
|
||||
})
|
||||
|
||||
// Computed
|
||||
// Computed — footer "complete auth" only for code-exchange flows, not SSO/password/RT.
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || isGrok.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
const method = oauthFlowRef.value?.inputMethod
|
||||
if (method === 'sso_cookie' || method === 'email_password' || method === 'refresh_token') {
|
||||
return false
|
||||
}
|
||||
// OpenAI/Gemini/Antigravity/Grok use manual code paste by default (no cookie auth)
|
||||
return (
|
||||
isOpenAILike.value ||
|
||||
isGemini.value ||
|
||||
isAntigravity.value ||
|
||||
isGrok.value ||
|
||||
method === 'manual'
|
||||
)
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
@@ -574,4 +605,79 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
claudeOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply Grok Build OAuth tokens onto the existing account (never store password/SSO). */
|
||||
const applyGrokReauthTokenInfo = async (tokenInfo: {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
email?: string
|
||||
[key: string]: unknown
|
||||
}) => {
|
||||
if (!props.account) return
|
||||
const credentials = grokOAuth.buildCredentials(tokenInfo as any)
|
||||
const extra = grokOAuth.buildExtraInfo(tokenInfo as any)
|
||||
const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, {
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra
|
||||
})
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
/** Re-auth with a single SSO cookie → Build OAuth (not batch create). */
|
||||
const handleGrokImportSSO = async (ssoInput: string) => {
|
||||
if (!props.account || !isGrok.value) return
|
||||
const ssoToken = ssoInput
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)[0]
|
||||
if (!ssoToken) return
|
||||
|
||||
grokOAuth.loading.value = true
|
||||
grokOAuth.error.value = ''
|
||||
try {
|
||||
const tokenInfo = await grokOAuth.validateSSOToken(ssoToken, props.account.proxy_id)
|
||||
if (!tokenInfo) return
|
||||
await applyGrokReauthTokenInfo(tokenInfo)
|
||||
} catch (error: any) {
|
||||
grokOAuth.error.value =
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
t('admin.accounts.oauth.grok.failedToValidateSSO', 'Failed to validate Grok SSO')
|
||||
appStore.showError(grokOAuth.error.value)
|
||||
} finally {
|
||||
grokOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-auth with a single refresh token. */
|
||||
const handleGrokValidateRefreshToken = async (refreshTokenInput: string) => {
|
||||
if (!props.account || !isGrok.value) return
|
||||
const refreshToken = refreshTokenInput
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)[0]
|
||||
if (!refreshToken) {
|
||||
grokOAuth.error.value = t('admin.accounts.oauth.grok.pleaseEnterRefreshToken')
|
||||
return
|
||||
}
|
||||
|
||||
grokOAuth.loading.value = true
|
||||
grokOAuth.error.value = ''
|
||||
try {
|
||||
const tokenInfo = await grokOAuth.validateRefreshToken(refreshToken, props.account.proxy_id)
|
||||
if (!tokenInfo) return
|
||||
await applyGrokReauthTokenInfo(tokenInfo)
|
||||
} catch (error: any) {
|
||||
grokOAuth.error.value =
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
t('admin.accounts.oauth.grok.failedToValidateRT')
|
||||
appStore.showError(grokOAuth.error.value)
|
||||
} finally {
|
||||
grokOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,6 +33,9 @@ vi.mock('vue-i18n', async () => {
|
||||
if (key === 'admin.accounts.imageReceived' && params?.count) {
|
||||
return `received-${params.count}`
|
||||
}
|
||||
if (key === 'admin.accounts.imagePreviewAlt' && params?.index) {
|
||||
return `test-image-${params.index}`
|
||||
}
|
||||
return messages[key] || key
|
||||
}
|
||||
})
|
||||
@@ -179,7 +182,8 @@ describe('AccountTestModal', () => {
|
||||
const [, request] = (global.fetch as any).mock.calls[0]
|
||||
expect(JSON.parse(request.body)).toEqual({
|
||||
model_id: 'grok-4.3',
|
||||
prompt: ''
|
||||
prompt: '',
|
||||
mode: 'text'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'src/components/admin/account/ReAuthAccountModal.vue'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('ReAuthAccountModal Grok re-auth paths', () => {
|
||||
it('exposes SSO cookie and refresh-token options; password auth stays hidden', () => {
|
||||
expect(source).toContain(':show-sso-option="isGrok"')
|
||||
expect(source).toContain(':show-email-password-option="false"')
|
||||
expect(source).toContain(':show-refresh-token-option="isOpenAI || isAntigravity || isGrok"')
|
||||
expect(source).not.toContain('@authorize-password=')
|
||||
})
|
||||
|
||||
it('wires SSO and RT reauth without batch account create', () => {
|
||||
expect(source).toContain('@import-sso="handleGrokImportSSO"')
|
||||
expect(source).toContain('@validate-refresh-token="handleGrokValidateRefreshToken"')
|
||||
expect(source).toContain('grokOAuth.validateSSOToken')
|
||||
expect(source).toContain('grokOAuth.buildCredentials')
|
||||
// Re-auth updates the existing account; must not call createFromSSO batch create
|
||||
expect(source).not.toContain('createFromSSO')
|
||||
expect(source).toContain('applyOAuthCredentials')
|
||||
})
|
||||
|
||||
it('hides footer code-exchange button for SSO/RT input methods', () => {
|
||||
expect(source).toContain("method === 'sso_cookie'")
|
||||
expect(source).toContain("method === 'refresh_token'")
|
||||
})
|
||||
|
||||
it('defaults reauth to refresh_token or sso_cookie (not password)', () => {
|
||||
expect(source).toContain('grokInitialInputMethod')
|
||||
expect(source).toContain(':initial-input-method="grokInitialInputMethod"')
|
||||
expect(source).toContain("return 'sso_cookie'")
|
||||
expect(source).toContain("return 'refresh_token'")
|
||||
expect(source).not.toContain("return 'email_password'")
|
||||
expect(source).not.toContain('grokPrefillEmailPassword')
|
||||
})
|
||||
})
|
||||
@@ -156,7 +156,7 @@ const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.onerror = () => reject(reader.error || new Error(t('common.fileReadFailed')))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,6 +139,11 @@
|
||||
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'usage'" class="w-full sm:w-auto sm:min-w-[220px]">
|
||||
<label class="input-label">{{ t('admin.usage.upstreamModelAudit') }}</label>
|
||||
<Select v-model="filters.upstream_model_mismatch" :options="upstreamModelMismatchOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Error Phase Filter (errors only) -->
|
||||
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('admin.ops.errorLog.type') }}</label>
|
||||
@@ -307,6 +312,12 @@ const billingModeOptions = ref<SelectOption[]>([
|
||||
{ value: 'video', label: t('admin.usage.billingModeVideo') }
|
||||
])
|
||||
|
||||
const upstreamModelMismatchOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allUpstreamModelAudit') },
|
||||
{ value: true, label: t('admin.usage.upstreamModelMismatchOnly') },
|
||||
{ value: false, label: t('admin.usage.upstreamModelMatchedOnly') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
|
||||
const clearPendingUserSearch = () => {
|
||||
|
||||
@@ -53,23 +53,41 @@
|
||||
</template>
|
||||
|
||||
<template #cell-model="{ row }">
|
||||
<div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5 text-xs">
|
||||
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
|
||||
class="break-all"
|
||||
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
|
||||
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
|
||||
<span v-if="i > 0" class="mr-0.5">↳</span>{{ step }}
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5">
|
||||
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
|
||||
class="break-all"
|
||||
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
|
||||
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
|
||||
<span v-if="i > 0" class="mr-0.5">↳</span>{{ step }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5">
|
||||
<div class="break-all font-medium text-gray-900 dark:text-white">
|
||||
{{ row.model }}
|
||||
</div>
|
||||
<div class="break-all text-gray-500 dark:text-gray-400">
|
||||
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
|
||||
<div
|
||||
v-if="row.upstream_model_mismatch === true && row.upstream_response_model"
|
||||
class="break-all pl-3 text-[11px]"
|
||||
:class="isLikelyModelVariant(row) ? 'text-amber-600 dark:text-amber-400' : 'text-orange-600 dark:text-orange-400'"
|
||||
:title="modelAuditTitle(row)"
|
||||
>
|
||||
<span class="mr-1">↳ {{ t('usage.upstreamResponseModel') }}:</span>{{ row.upstream_response_model }}
|
||||
<span
|
||||
class="ml-1 inline-flex rounded px-1 py-px text-[10px] font-medium ring-1 ring-inset"
|
||||
:class="isLikelyModelVariant(row)
|
||||
? 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30'
|
||||
: 'bg-orange-50 text-orange-700 ring-orange-200 dark:bg-orange-500/10 dark:text-orange-300 dark:ring-orange-500/30'"
|
||||
>
|
||||
{{ isLikelyModelVariant(row) ? t('usage.modelVariant') : t('usage.modelMismatch') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs">
|
||||
<div class="break-all font-medium text-gray-900 dark:text-white">
|
||||
{{ row.model }}
|
||||
</div>
|
||||
<div class="break-all text-gray-500 dark:text-gray-400">
|
||||
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-reasoning_effort="{ row }">
|
||||
@@ -548,6 +566,27 @@ const ipGeoBatchLoading = ref(false)
|
||||
|
||||
const showIpGeoToolbar = computed(() => props.columns.some((col) => col.key === 'ip_address'))
|
||||
|
||||
const sentUpstreamModel = (row: AdminUsageLog): string => row.upstream_model?.trim() || row.model?.trim() || ''
|
||||
|
||||
const normalizeModelVariant = (model: string): string => model
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/-latest$/, '')
|
||||
.replace(/-\d{4}-\d{2}-\d{2}$/, '')
|
||||
.replace(/-\d{8}$/, '')
|
||||
|
||||
const isLikelyModelVariant = (row: AdminUsageLog): boolean => {
|
||||
const sent = sentUpstreamModel(row)
|
||||
const response = row.upstream_response_model?.trim() || ''
|
||||
return sent !== '' && response !== '' && normalizeModelVariant(sent) === normalizeModelVariant(response)
|
||||
}
|
||||
|
||||
const modelAuditTitle = (row: AdminUsageLog): string => [
|
||||
`${t('usage.requestedModel')}: ${row.model || '-'}`,
|
||||
`${t('usage.sentUpstreamModel')}: ${sentUpstreamModel(row) || '-'}`,
|
||||
`${t('usage.upstreamResponseModel')}: ${row.upstream_response_model || '-'}`,
|
||||
].join('\n')
|
||||
|
||||
const currentPageIps = computed(() =>
|
||||
Array.from(new Set(props.data.map((row) => row.ip_address).filter((ip): ip is string => Boolean(ip))))
|
||||
)
|
||||
|
||||
@@ -28,6 +28,10 @@ const messages: Record<string, string> = {
|
||||
'admin.usage.billingModeToken': 'Token',
|
||||
'admin.usage.billingModePerRequest': 'Per Request',
|
||||
'admin.usage.billingModeImage': 'Image',
|
||||
'admin.usage.upstreamModelAudit': 'Upstream model audit',
|
||||
'admin.usage.allUpstreamModelAudit': 'All response model states',
|
||||
'admin.usage.upstreamModelMismatchOnly': 'Mismatched only',
|
||||
'admin.usage.upstreamModelMatchedOnly': 'Matched only',
|
||||
'admin.usage.group': 'Group',
|
||||
'admin.usage.allGroups': 'All Groups',
|
||||
'common.refresh': 'Refresh',
|
||||
@@ -75,6 +79,7 @@ const defaultFilters = () => ({
|
||||
request_type: null,
|
||||
billing_type: null,
|
||||
billing_mode: null,
|
||||
upstream_model_mismatch: null,
|
||||
group_id: null,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
|
||||
@@ -51,6 +51,11 @@ const messages: Record<string, string> = {
|
||||
'admin.usage.billingModeToken': 'Token',
|
||||
'admin.usage.billingModePerRequest': 'Per request',
|
||||
'admin.usage.billingModeImage': 'Image',
|
||||
'usage.requestedModel': 'Requested',
|
||||
'usage.sentUpstreamModel': 'Sent upstream',
|
||||
'usage.upstreamResponseModel': 'Upstream response',
|
||||
'usage.modelVariant': 'Possible version variant',
|
||||
'usage.modelMismatch': 'Different model',
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
@@ -240,6 +245,48 @@ describe('admin UsageTable tooltip', () => {
|
||||
expect(text).toContain('claude-sonnet-4-20250514')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'possible version variant',
|
||||
responseModel: 'gpt-5.5-2026-08-01',
|
||||
expectedBadge: 'Possible version variant',
|
||||
},
|
||||
{
|
||||
name: 'different upstream model',
|
||||
responseModel: 'gpt-5.4',
|
||||
expectedBadge: 'Different model',
|
||||
},
|
||||
])('shows a compact upstream response audit marker for $name', ({ responseModel, expectedBadge }) => {
|
||||
const wrapper = mount(UsageTable, {
|
||||
props: {
|
||||
data: [{
|
||||
request_id: `req-${responseModel}`,
|
||||
model: 'gpt-5.6-sol',
|
||||
upstream_model: 'gpt-5.5',
|
||||
model_mapping_chain: 'gpt-5.6-sol→gpt-5.5',
|
||||
upstream_response_model: responseModel,
|
||||
upstream_model_mismatch: true,
|
||||
}],
|
||||
loading: false,
|
||||
columns: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
DataTable: DataTableStub,
|
||||
EmptyState: true,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('gpt-5.6-sol')
|
||||
expect(text).toContain('gpt-5.5')
|
||||
expect(text).toContain(responseModel)
|
||||
expect(text).toContain(expectedBadge)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'defaulted row',
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<div ref="modalBodyRef" class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,7 @@ const dialogId = `modal-title-${++dialogIdCounter}`
|
||||
|
||||
// 焦点管理
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
const modalBodyRef = ref<HTMLElement | null>(null)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
|
||||
@@ -123,6 +124,9 @@ watch(
|
||||
|
||||
// 等待DOM更新后设置焦点到对话框
|
||||
await nextTick()
|
||||
if (modalBodyRef.value) {
|
||||
modalBodyRef.value.scrollTop = 0
|
||||
}
|
||||
if (dialogRef.value) {
|
||||
const firstFocusable = dialogRef.value.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('useGrokOAuth.exchangeAuthCode', () => {
|
||||
})
|
||||
|
||||
describe('useGrokOAuth.buildCredentials', () => {
|
||||
it('persists the Grok CLI subscription proxy for OAuth inference', () => {
|
||||
it('builds OAuth credentials without forcing base_url or leaking sso/password', () => {
|
||||
const oauth = useGrokOAuth()
|
||||
|
||||
const credentials = oauth.buildCredentials({
|
||||
@@ -64,9 +64,20 @@ describe('useGrokOAuth.buildCredentials', () => {
|
||||
expires_at: 1_900_000_000,
|
||||
client_id: 'client-id',
|
||||
scope: 'openid grok-cli:access',
|
||||
email: 'grok@example.com'
|
||||
})
|
||||
email: 'grok@example.com',
|
||||
password: 'super-secret',
|
||||
sso_token: 'sso-cookie',
|
||||
sso: 'sso-cookie',
|
||||
'sso-rw': 'sso-cookie'
|
||||
} as any)
|
||||
|
||||
expect(credentials.base_url).toBe('https://cli-chat-proxy.grok.com/v1')
|
||||
expect(credentials.access_token).toBe('access-token')
|
||||
expect(credentials.email).toBe('grok@example.com')
|
||||
// System/CLI mode chooses the correct host; do not pin public API URL.
|
||||
expect(credentials.base_url).toBeUndefined()
|
||||
expect(credentials).not.toHaveProperty('password')
|
||||
expect(credentials).not.toHaveProperty('sso_token')
|
||||
expect(credentials).not.toHaveProperty('sso')
|
||||
expect(credentials).not.toHaveProperty('sso-rw')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('useModelWhitelist', () => {
|
||||
expect(models).toContain('grok-4.5')
|
||||
expect(models).toContain('grok-4.5-latest')
|
||||
expect(models).toContain('grok-build-latest')
|
||||
expect(models).toContain('grok-imagine-video-1.5-preview')
|
||||
})
|
||||
|
||||
it('combined 模式支持 Grok 4.5 官方别名映射', () => {
|
||||
@@ -74,7 +75,7 @@ describe('useModelWhitelist', () => {
|
||||
const models = getModelsByPlatform('grok')
|
||||
|
||||
expect(models).toContain('grok-composer-2.5-fast')
|
||||
expect(models).toContain('grok-composer')
|
||||
expect(models).not.toContain('grok-composer')
|
||||
expect(models).toContain('composer-2.5')
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,18 @@ import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
export type AddMethod = 'oauth' | 'setup-token'
|
||||
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'agent_identity' | 'codex_pat' | 'sso_cookie'
|
||||
export type AuthInputMethod =
|
||||
| 'manual'
|
||||
| 'cookie'
|
||||
| 'refresh_token'
|
||||
| 'mobile_refresh_token'
|
||||
| 'session_token'
|
||||
| 'access_token'
|
||||
| 'codex_session'
|
||||
| 'agent_identity'
|
||||
| 'codex_pat'
|
||||
| 'sso_cookie'
|
||||
| 'email_password'
|
||||
|
||||
export interface OAuthState {
|
||||
authUrl: string
|
||||
|
||||
@@ -113,6 +113,8 @@ export function useGrokOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// Build account credentials for create/re-auth. Never persist raw SSO cookies
|
||||
// or passwords: those exist only for the one-shot authorize API call.
|
||||
const buildCredentials = (tokenInfo: GrokTokenInfo): Record<string, unknown> => {
|
||||
const credentials: Record<string, unknown> = {
|
||||
access_token: tokenInfo.access_token,
|
||||
@@ -125,11 +127,16 @@ export function useGrokOAuth() {
|
||||
team_id: tokenInfo.team_id,
|
||||
subscription_tier: tokenInfo.subscription_tier,
|
||||
entitlement_status: tokenInfo.entitlement_status,
|
||||
base_url: 'https://cli-chat-proxy.grok.com/v1'
|
||||
// Leave base_url unset so system/CLI mode can choose the correct host.
|
||||
}
|
||||
if (tokenInfo.refresh_token) credentials.refresh_token = tokenInfo.refresh_token
|
||||
if (tokenInfo.id_token) credentials.id_token = tokenInfo.id_token
|
||||
return Object.fromEntries(Object.entries(credentials).filter(([, value]) => value !== undefined && value !== ''))
|
||||
const blocked = new Set(['sso_token', 'password', 'sso', 'sso-rw'])
|
||||
return Object.fromEntries(
|
||||
Object.entries(credentials).filter(
|
||||
([key, value]) => !blocked.has(key) && value !== undefined && value !== ''
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const buildExtraInfo = (tokenInfo: GrokTokenInfo): Record<string, unknown> => {
|
||||
@@ -140,6 +147,58 @@ export function useGrokOAuth() {
|
||||
return extra
|
||||
}
|
||||
|
||||
const validateSSOToken = async (
|
||||
ssoToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo | null> => {
|
||||
if (!ssoToken.trim()) {
|
||||
error.value = t('admin.accounts.oauth.grok.pleaseEnterSSOToken', 'Please enter an SSO token')
|
||||
return null
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await adminAPI.grok.validateSSOToken(ssoToken.trim(), proxyId)
|
||||
} catch (err: any) {
|
||||
error.value = extractI18nErrorMessage(
|
||||
err,
|
||||
t,
|
||||
'admin.accounts.oauth.grok.errors',
|
||||
t('admin.accounts.oauth.grok.failedToValidateSSO', 'Failed to validate SSO token')
|
||||
)
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const authorizePassword = async (
|
||||
emailAndPassword: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo | null> => {
|
||||
if (!emailAndPassword.trim()) {
|
||||
error.value = t('admin.accounts.oauth.grok.pleaseEnterPassword', 'Please enter email----password')
|
||||
return null
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await adminAPI.grok.authorizePassword(emailAndPassword, proxyId)
|
||||
} catch (err: any) {
|
||||
error.value = extractI18nErrorMessage(
|
||||
err,
|
||||
t,
|
||||
'admin.accounts.oauth.grok.errors',
|
||||
t('admin.accounts.oauth.grok.failedToAuthorizePassword', 'Password authorization failed')
|
||||
)
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authUrl,
|
||||
sessionId,
|
||||
@@ -150,6 +209,8 @@ export function useGrokOAuth() {
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
validateRefreshToken,
|
||||
validateSSOToken,
|
||||
authorizePassword,
|
||||
buildCredentials,
|
||||
buildExtraInfo
|
||||
}
|
||||
|
||||
@@ -143,20 +143,20 @@ const xaiModels = [
|
||||
'grok-4.20-0309-reasoning',
|
||||
'grok-4.20-0309-non-reasoning',
|
||||
'grok-4.20-multi-agent-0309',
|
||||
'grok',
|
||||
'grok-4.20-multi-agent',
|
||||
'grok-4.20-multi-agent-latest',
|
||||
'grok-4.3-latest',
|
||||
'grok-latest',
|
||||
'grok-4.5-latest',
|
||||
'grok-build',
|
||||
'grok-build-latest',
|
||||
'grok-composer',
|
||||
'composer-2.5',
|
||||
'grok-4.20-reasoning',
|
||||
'grok-4.20-non-reasoning',
|
||||
'grok-imagine',
|
||||
'grok-imagine-image-quality',
|
||||
'grok-imagine-image',
|
||||
'grok-imagine-edit',
|
||||
'grok-imagine-video',
|
||||
'grok-imagine-video-1.5-preview',
|
||||
'grok-imagine-video-1.5'
|
||||
]
|
||||
|
||||
@@ -314,7 +314,7 @@ const grokPresetMappings = [
|
||||
{ label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' },
|
||||
{ label: 'Imagine Image', from: 'grok-imagine', to: 'grok-imagine-image-quality', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: 'Imagine Edit', from: 'grok-imagine-edit', to: 'grok-imagine-edit', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
|
||||
{ label: 'Imagine Edit', from: 'grok-imagine-edit', to: 'grok-imagine-image-quality', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
|
||||
{ label: 'Imagine Video', from: 'grok-imagine-video-1.5', to: 'grok-imagine-video-1.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
]
|
||||
|
||||
|
||||
@@ -131,6 +131,10 @@ export default {
|
||||
rateLimited: 'Rate Limited',
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
accountSchedulingThresholdOverride: 'Account Auto-Pause Threshold Override',
|
||||
accountSchedulingThresholdOverrideHint: 'Override the platform auto-pause threshold for this account only. Disable to use platform settings.',
|
||||
accountSchedulingThresholdOverrideValue: 'Account threshold percent',
|
||||
accountSchedulingThresholdOverrideDisabledHint: 'Use 1-100. The account becomes temporarily unschedulable after reaching this usage percent; 100 disables it for this account.',
|
||||
quotaExceeded: 'Quota Exceeded',
|
||||
unschedulable: 'Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
|
||||
@@ -140,7 +144,8 @@ export default {
|
||||
creditsExhausted: 'Credits Exhausted',
|
||||
creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||
viewTempUnschedDetails: 'View temp unschedulable details',
|
||||
tempUnschedulableUntil: 'Resumes {time}'
|
||||
},
|
||||
columns: {
|
||||
name: 'Name',
|
||||
@@ -304,6 +309,10 @@ export default {
|
||||
notice: 'Rules are evaluated in order and require both error code and keyword match.',
|
||||
addRule: 'Add Rule',
|
||||
ruleOrder: 'Rule Order',
|
||||
multipleErrorTrigger: '{count} matching errors in {minutes} minutes reached the trigger threshold ({threshold}).',
|
||||
multipleErrorTriggerNoWindow: '{count} matching errors reached the trigger threshold ({threshold}).',
|
||||
multipleErrorCountInWindow: '{count} matching errors occurred within {minutes} minutes.',
|
||||
multipleErrorCount: '{count} matching errors contributed to this block.',
|
||||
ruleIndex: 'Rule #{index}',
|
||||
errorCode: 'Error Code',
|
||||
errorCodePlaceholder: 'e.g. 429',
|
||||
@@ -598,7 +607,55 @@ export default {
|
||||
},
|
||||
grok: {
|
||||
baseUrlHint: 'Grok OAuth accounts forward to the official xAI API base URL.',
|
||||
apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.'
|
||||
apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.',
|
||||
// Account connectivity test modal
|
||||
testMode: 'Test mode',
|
||||
testModeHint:
|
||||
'Text / image / video use the selected model. Web search, TTS, STT and Realtime hit standalone endpoints (not free-form chat tools).',
|
||||
testModeText: 'Text (Responses)',
|
||||
testModeImage: 'Image (/images/generations)',
|
||||
testModeVideo: 'Video (/videos/generations)',
|
||||
testModeSearch: 'Web search (/web_search)',
|
||||
testModeTTS: 'TTS (/tts)',
|
||||
testModeSTT: 'STT (/stt)',
|
||||
testModeRealtime: 'Realtime (WS /realtime)',
|
||||
textTestMode: 'Mode: Text (Responses)',
|
||||
searchTestMode: 'Mode: Web search (/web_search)',
|
||||
ttsTestMode: 'Mode: TTS (/tts)',
|
||||
sttTestMode: 'Mode: STT (/stt)',
|
||||
realtimeTestMode: 'Mode: Realtime (WS /realtime)',
|
||||
searchQueryLabel: 'Search query',
|
||||
searchQueryPlaceholder: 'Example: xAI Grok',
|
||||
searchQueryDefault: 'xAI Grok',
|
||||
searchTestHint:
|
||||
'Standalone web_search probe (same as gateway /v1/web_search). Not a free-form chat with tools.',
|
||||
ttsTextLabel: 'TTS text',
|
||||
ttsTextPlaceholder: 'Example: Hello from Sub2API connectivity test.',
|
||||
ttsTextDefault: 'Hello from Sub2API account connectivity test.',
|
||||
ttsTestHint: 'Standalone /v1/tts with language=en; success reports audio byte size.',
|
||||
sttTestHint: 'Standalone /v1/stt with a synthetic silent WAV; success means the endpoint is reachable.',
|
||||
realtimeTestHint:
|
||||
'Standalone WebSocket dial to /v1/realtime (model=grok-voice-latest). Handshake success = connectivity OK; may also show the first server event.',
|
||||
sendingSearchRequest: 'Sending standalone web_search request...',
|
||||
sendingTTSRequest: 'Sending standalone /tts request...',
|
||||
sendingSTTRequest: 'Sending standalone /stt request...',
|
||||
sendingRealtimeRequest: 'Dialing standalone /realtime WebSocket...',
|
||||
selectedTestMode: 'Test mode: {mode}',
|
||||
imageUploadLabel: 'Source image (optional, for edits)',
|
||||
videoFirstFrameLabel: 'First-frame / reference image (optional)',
|
||||
imageUploadHint:
|
||||
'PNG/JPEG recommended, both sides ≥ 8 px, under ~4 MB for edits. Uploading a source image switches to /images/edits (image-to-image). Leave empty for text-to-image /images/generations.',
|
||||
videoFirstFrameHint:
|
||||
'Optional first-frame / reference image for image-to-video. PNG/JPEG recommended, both sides ≥ 8 px.',
|
||||
audioUploadLabel: 'Audio file (optional for STT)',
|
||||
audioUploadHint:
|
||||
'Upload a real audio clip to transcribe. Without a file, a silent WAV is used for connectivity only.',
|
||||
mediaTooLarge: 'File is too large (max ~6 MB for admin test uploads).',
|
||||
chooseImageFile: 'Choose image',
|
||||
chooseAudioFile: 'Choose audio',
|
||||
uploadPreviewAlt: 'Upload preview',
|
||||
fileReadFailed: 'Failed to read the selected file',
|
||||
noResponseBody: 'No response body from server'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: 'Auto passthrough (auth only)',
|
||||
@@ -1009,6 +1066,17 @@ export default {
|
||||
ssoCookieLabel: 'Grok Web SSO Key',
|
||||
ssoCookiePlaceholder: 'One SSO key per line\nSupports multiple, one per line',
|
||||
ssoCookieHint: 'One SSO key per line. Multiple keys are imported with 3-way concurrency; expect about 90 seconds per batch. Use a matching-region proxy if needed.',
|
||||
emailPasswordAuth: 'Email + password',
|
||||
emailPasswordDesc:
|
||||
'Sign in with a Grok web email and password. The server uses the password only to obtain an ephemeral SSO cookie, then converts it to Build OAuth credentials. Neither the password nor raw SSO is stored on the account.',
|
||||
emailPasswordInputLabel: 'email----password',
|
||||
emailPasswordPlaceholder: "user{'@'}example.com----your-password\nMultiple lines supported",
|
||||
emailPasswordHint:
|
||||
'Format: email----password (password may contain -). Requires YesCaptcha keys; use a matching-region proxy when needed.',
|
||||
pleaseEnterPassword: 'Please enter email----password (one per line)',
|
||||
pleaseEnterSSOToken: 'Please enter an SSO token',
|
||||
failedToValidateSSO: 'Failed to validate Grok SSO',
|
||||
failedToAuthorizePassword: 'Grok password authorization failed',
|
||||
convertingSSO: 'Converting...',
|
||||
convertSSOAndCreate: 'Convert & Create Account',
|
||||
validating: 'Validating...',
|
||||
@@ -1272,6 +1340,9 @@ export default {
|
||||
reAuthorizedSuccess: 'Account re-authorized successfully',
|
||||
// Test Modal
|
||||
testAccountConnection: 'Test Account Connection',
|
||||
errorPrefix: 'Error: {message}',
|
||||
imagePreviewAlt: 'Test image {index}',
|
||||
imageLightboxAlt: 'Image preview',
|
||||
account: 'Account',
|
||||
readyToTest: 'Ready to test. Click "Start Test" to begin...',
|
||||
connectingToApi: 'Connecting to API...',
|
||||
@@ -1295,10 +1366,22 @@ export default {
|
||||
imagePromptLabel: 'Image prompt',
|
||||
imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
|
||||
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||
imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.',
|
||||
imageTestHint:
|
||||
'Calls standalone /v1/images/generations and shows the returned image below.',
|
||||
imageTestMode: 'Mode: Image generation test',
|
||||
videoPromptLabel: 'Video prompt',
|
||||
videoPromptPlaceholder: 'Example: A red ball bouncing once on a white floor, short simple motion.',
|
||||
videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.',
|
||||
videoTestHint:
|
||||
'Calls standalone /v1/videos/generations, polls until done, then downloads the finished video for on-page preview.',
|
||||
videoTestMode: 'Mode: Video generation test',
|
||||
sendingVideoRequest: 'Sending video generation request...',
|
||||
imagePreview: 'Generated images:',
|
||||
imageReceived: 'Received test image #{count}',
|
||||
audioPreview: 'Generated audio:',
|
||||
audioReceived: 'Received test audio #{count}',
|
||||
videoPreview: 'Generated video:',
|
||||
videoReceived: 'Received test video #{count}',
|
||||
// Stats Modal
|
||||
viewStats: 'View Stats',
|
||||
usageStatistics: 'Usage Statistics',
|
||||
@@ -1346,6 +1429,12 @@ export default {
|
||||
grokTokens: 'Tok',
|
||||
grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window ({limit} limit)',
|
||||
grokWeeklyUsage: 'Weekly {percent}%',
|
||||
grokUsed: 'Used $',
|
||||
grokBalance: 'Bal $',
|
||||
grokPrepaid: 'Prepaid balance',
|
||||
grokMonthlyLimit: 'Monthly used / limit (USD)',
|
||||
grokOverage: 'Overage onDemandUsed/onDemandCap',
|
||||
grokOverageShort: 'OD $',
|
||||
grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.',
|
||||
grokRetryAfter: 'Retry after {time}',
|
||||
grokProbe: 'Probe',
|
||||
|
||||
@@ -985,6 +985,8 @@ export default {
|
||||
title: 'Video Generation Pricing',
|
||||
description:
|
||||
'Configure Grok video generation prices in USD per second of output video. Leave empty to use the default per-second rates (grok-imagine-video: $0.05/s 480p, $0.07/s 720p; video-1.5: $0.08/s 480p, $0.14/s 720p, $0.25/s 1080p).',
|
||||
modelOverridesTitle: 'Per-model video price overrides',
|
||||
modelOverridesDescription: 'Each populated cell overrides the flat resolution price for that model family. Preview and legacy aliases for video-1.5 use the same family; empty cells fall back to the flat resolution price.',
|
||||
independentMultiplier: 'Use independent video multiplier',
|
||||
videoMultiplier: 'Video multiplier',
|
||||
modeHint:
|
||||
@@ -992,6 +994,20 @@ export default {
|
||||
finalPricePreview: 'Final per-second price preview',
|
||||
notConfigured: 'Not configured'
|
||||
},
|
||||
explicitPricing: {
|
||||
title: 'Grok Search & Voice Pricing',
|
||||
description: 'Optional per-group prices for web_search (per 1k calls) and Voice realtime / TTS / STT (USD). Leave empty if unused.',
|
||||
searchPricePer1k: 'Search price per 1k calls (USD)',
|
||||
pricePlaceholder: 'optional'
|
||||
},
|
||||
voicePricing: {
|
||||
title: 'Grok Voice Pricing',
|
||||
description: 'Optional per-group prices for Voice realtime / TTS / STT (USD). Leave empty to leave unpriced.',
|
||||
audioRealtimePerMin: 'Realtime price per minute (USD)',
|
||||
audioTtsPerMillionChars: 'TTS price per million chars (USD)',
|
||||
audioSttPerHour: 'STT price per hour (USD)',
|
||||
pricePlaceholder: 'optional'
|
||||
},
|
||||
webSearchPricing: {
|
||||
title: 'Codex Web Search Pricing',
|
||||
pricePerCall: 'Price per search call (USD)',
|
||||
|
||||
@@ -544,6 +544,10 @@ export default {
|
||||
billingModeImage: 'Image',
|
||||
billingModeVideo: 'Video',
|
||||
allBillingModes: 'All Billing Modes',
|
||||
upstreamModelAudit: 'Upstream model audit',
|
||||
allUpstreamModelAudit: 'All response model states',
|
||||
upstreamModelMismatchOnly: 'Mismatched only',
|
||||
upstreamModelMatchedOnly: 'Matched only',
|
||||
ipAddress: 'IP',
|
||||
clickToViewBalance: 'Click to view balance history',
|
||||
failedToLoadUser: 'Failed to load user info',
|
||||
|
||||
@@ -418,7 +418,12 @@ export default {
|
||||
title: 'Gateway Scheduling Settings',
|
||||
description: 'Control API Key scheduling behavior',
|
||||
allowUngroupedKey: 'Allow Ungrouped Key Scheduling',
|
||||
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.'
|
||||
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.',
|
||||
accountSchedulingThresholdsTitle: 'Platform Account Auto-Pause Thresholds',
|
||||
accountSchedulingThresholdsDescription: 'When an account\'s current native usage window (OpenAI Codex/Anthropic session, or Grok request/token utilization) reaches this percent, Sub2API temporarily removes it from scheduling until the window resets. Use 100 to disable.',
|
||||
accountSchedulingThresholdsGlobalHint: 'System-wide default for every account on that platform. Individual accounts can still override this in the account editor.',
|
||||
accountSchedulingThresholdsDisabledHint: '100 disables platform auto-pause. Values 1–99 pause scheduling once utilization reaches that percent.',
|
||||
accountSchedulingThresholdsRangeHint: 'Integer 1–100 (percent). OpenAI/Anthropic/Grok only.'
|
||||
},
|
||||
upstreamBillingProbe: {
|
||||
title: 'Upstream Rate Auto Detection',
|
||||
@@ -445,6 +450,17 @@ export default {
|
||||
gatewayForwarding: {
|
||||
title: 'Request Forwarding',
|
||||
description: 'Control how requests are forwarded to upstream OAuth accounts',
|
||||
grokDefaultTextModel: 'Default Grok text model',
|
||||
grokDefaultTextModelHint: 'Used for empty model values and, only when the switch is enabled, requests from other client model namespaces. Custom Grok model IDs are accepted.',
|
||||
grokCrossClientMap: 'Map other clients to Grok',
|
||||
grokCrossClientMapHint: 'Disabled by default. When enabled, GPT, Codex, o-series, and Claude model IDs are routed to the default Grok text model above.',
|
||||
grokDefaultBaseURLMode: 'Default Grok upstream',
|
||||
grokDefaultBaseURLModeHint: 'Used only when a Grok account has no explicit base URL. Media and voice endpoints continue to use their official API hosts.',
|
||||
grokBaseURLModeCLI: 'CLI chat proxy',
|
||||
grokBaseURLModeAPI: 'Public API',
|
||||
grokBaseURLModeUSEast1: 'Regional API (us-east-1)',
|
||||
grokBaseURLModeUSWest2: 'Regional API (us-west-2)',
|
||||
grokBaseURLModeEUWest1: 'Regional API (eu-west-1)',
|
||||
fingerprintUnification: 'Fingerprint Unification',
|
||||
fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.',
|
||||
metadataPassthrough: 'Metadata Passthrough',
|
||||
|
||||
@@ -81,6 +81,13 @@ export default {
|
||||
view: 'View',
|
||||
settings: 'Settings',
|
||||
chooseFile: 'Choose File',
|
||||
upload: 'Upload',
|
||||
remove: 'Remove',
|
||||
noFileSelected: 'No file selected',
|
||||
selectedFile: 'Selected: {name}',
|
||||
fileReadFailed: 'Failed to read file',
|
||||
selectImageFile: 'Please select an image file',
|
||||
fileTooLargeKb: 'File too large ({size} KB), max {max} KB',
|
||||
copy: 'Copy',
|
||||
notAvailable: 'N/A',
|
||||
now: 'Now',
|
||||
|
||||
@@ -169,16 +169,24 @@ export default {
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
},
|
||||
grok: {
|
||||
description: 'Configure Grok Build, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group.',
|
||||
description:
|
||||
'Configure Grok CLI, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group. Text models use Responses; image/video use Imagine model IDs on media endpoints.',
|
||||
claudeDescription: 'Configure Claude Code to send Messages API traffic through your Sub2API Grok group.',
|
||||
codexDescription: 'Configure Codex to send Responses API traffic through your Sub2API Grok group.',
|
||||
configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.',
|
||||
codexConfigTomlHint: 'Back up an existing config.toml before merging this provider configuration.',
|
||||
note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select grok from /model.',
|
||||
noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select grok from /model.',
|
||||
claudeNote: 'Choose one method: run the terminal commands for the current session, or save settings.json for user-level persistent configuration.',
|
||||
codexNote: 'Save config.toml under ~/.codex and set SUB2API_API_KEY before starting Codex.',
|
||||
codexNoteWindows: 'Save config.toml under %USERPROFILE%\\.codex and set SUB2API_API_KEY in PowerShell before starting Codex.',
|
||||
configTomlHint:
|
||||
'Official path: ~/.grok/config.toml (or $GROK_HOME). Fill [endpoints] (models_base_url / models_list_url / xai_api_base_url / cli_chat_proxy_base_url), [auth] preferred_method=api_key, [models], [session], and [features] image/video overrides. Prefer env_key over api_key; every text model needs api_backend=responses. Back up before merge, then run grok inspect.',
|
||||
codexConfigTomlHint:
|
||||
'Official Codex: wire_api = "responses" only; prefer env_key over experimental_bearer_token; supports_websockets = false for non-OpenAI gateways (Sub2API can still accept client WS and bridge to HTTP/SSE). Back up ~/.codex/config.toml before merge.',
|
||||
note:
|
||||
'Export GROK_MODELS_BASE_URL and XAI_API_KEY, save the full config.toml (endpoints/auth/models/session/features) as ~/.grok/config.toml, run grok inspect, then /model grok-4.5 (or grok-build-0.1 for coding).',
|
||||
noteWindows:
|
||||
'Set GROK_MODELS_BASE_URL and XAI_API_KEY, save the full config.toml as %USERPROFILE%\\.grok\\config.toml, run grok inspect, then /model grok-4.5 (or grok-build-0.1 for coding).',
|
||||
claudeNote:
|
||||
'Choose one method: terminal env for this session, or ~/.claude/settings.json for persistence. Do not commit files that contain your API key.',
|
||||
codexNote:
|
||||
'Export SUB2API_API_KEY, save config.toml under ~/.codex (mkdir -p ~/.codex). Prefer env_key auth; do not commit secrets.',
|
||||
codexNoteWindows:
|
||||
'Set $env:SUB2API_API_KEY, save config.toml under %USERPROFILE%\\.codex. Prefer env_key auth; do not commit secrets.',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
@@ -295,6 +303,11 @@ export default {
|
||||
model: 'Model',
|
||||
requestedModel: 'Requested',
|
||||
upstreamModel: 'Upstream',
|
||||
sentUpstreamModel: 'Sent upstream',
|
||||
upstreamResponseModel: 'Upstream response',
|
||||
upstreamModelMismatch: 'Response model mismatch',
|
||||
modelVariant: 'Possible version variant',
|
||||
modelMismatch: 'Different model',
|
||||
reasoningEffort: 'Reasoning Effort',
|
||||
endpoint: 'Endpoint',
|
||||
endpointDistribution: 'Endpoint Distribution',
|
||||
|
||||
@@ -334,6 +334,10 @@ export default {
|
||||
rateLimited: '限流中',
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
accountSchedulingThresholdOverride: '账号自动停调阈值覆盖',
|
||||
accountSchedulingThresholdOverrideHint: '仅对当前账号覆盖平台级自动停调阈值;关闭后使用平台设置。',
|
||||
accountSchedulingThresholdOverrideValue: '账号阈值百分比',
|
||||
accountSchedulingThresholdOverrideDisabledHint: '1-100,达到该用量百分比后临时不可调度;100 表示禁用当前账号自动停调。',
|
||||
quotaExceeded: '配额超限',
|
||||
unschedulable: '不可调度',
|
||||
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
|
||||
@@ -343,7 +347,8 @@ export default {
|
||||
creditsExhausted: '积分已用尽',
|
||||
creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||
viewTempUnschedDetails: '查看临时不可调度详情',
|
||||
tempUnschedulableUntil: '预计 {time} 恢复'
|
||||
},
|
||||
tempUnschedulable: {
|
||||
title: '临时不可调度',
|
||||
@@ -352,6 +357,10 @@ export default {
|
||||
notice: '规则按顺序匹配,需同时满足错误码与关键词。',
|
||||
addRule: '添加规则',
|
||||
ruleOrder: '规则序号',
|
||||
multipleErrorTrigger: '{minutes} 分钟内累计 {count} 次匹配错误,达到触发阈值({threshold})。',
|
||||
multipleErrorTriggerNoWindow: '累计 {count} 次匹配错误,达到触发阈值({threshold})。',
|
||||
multipleErrorCountInWindow: '{minutes} 分钟内累计发生 {count} 次匹配错误。',
|
||||
multipleErrorCount: '本次不可调度由累计 {count} 次匹配错误触发。',
|
||||
ruleIndex: '规则 #{index}',
|
||||
errorCode: '错误码',
|
||||
errorCodePlaceholder: '例如 429',
|
||||
@@ -401,6 +410,12 @@ export default {
|
||||
grokTokens: 'Token',
|
||||
grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 {limit})',
|
||||
grokWeeklyUsage: '周额度已用 {percent}%',
|
||||
grokUsed: '已用 $',
|
||||
grokBalance: '余额 $',
|
||||
grokPrepaid: '预付余额',
|
||||
grokMonthlyLimit: '月度已用/上限(USD)',
|
||||
grokOverage: '超额 onDemandUsed/onDemandCap',
|
||||
grokOverageShort: '超额 $',
|
||||
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
|
||||
grokRetryAfter: '{time} 后重试',
|
||||
grokProbe: '探测',
|
||||
@@ -662,7 +677,54 @@ export default {
|
||||
},
|
||||
grok: {
|
||||
baseUrlHint: 'Grok OAuth 账号会转发到官方 xAI API Base URL。',
|
||||
apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。'
|
||||
apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。',
|
||||
// 账号连通性测试弹窗
|
||||
testMode: '测试模式',
|
||||
testModeHint:
|
||||
'文本 / 图片 / 视频使用所选模型。网页搜索、TTS、STT、Realtime 走独立接口探测(不是对话里的 tools)。',
|
||||
testModeText: '文本(Responses)',
|
||||
testModeImage: '图片(/images/generations)',
|
||||
testModeVideo: '视频(/videos/generations)',
|
||||
testModeSearch: '网页搜索(/web_search)',
|
||||
testModeTTS: '语音合成 TTS(/tts)',
|
||||
testModeSTT: '语音识别 STT(/stt)',
|
||||
testModeRealtime: '实时语音 Realtime(WS /realtime)',
|
||||
textTestMode: '模式:文本(Responses)',
|
||||
searchTestMode: '模式:网页搜索(/web_search)',
|
||||
ttsTestMode: '模式:TTS(/tts)',
|
||||
sttTestMode: '模式:STT(/stt)',
|
||||
realtimeTestMode: '模式:Realtime(WS /realtime)',
|
||||
searchQueryLabel: '搜索关键词',
|
||||
searchQueryPlaceholder: '例如:xAI Grok',
|
||||
searchQueryDefault: 'xAI Grok',
|
||||
searchTestHint:
|
||||
'独立网页搜索探测(与网关 /v1/web_search 语义一致),不是带 tools 的自由对话。',
|
||||
ttsTextLabel: 'TTS 文本',
|
||||
ttsTextPlaceholder: '例如:Hello from Sub2API connectivity test.',
|
||||
ttsTextDefault: 'Hello from Sub2API account connectivity test.',
|
||||
ttsTestHint: '独立调用 /v1/tts(language=en);成功时显示音频字节数。',
|
||||
sttTestHint: '独立调用 /v1/stt,使用合成静音 WAV;成功表示接口可达。',
|
||||
realtimeTestHint:
|
||||
'独立 WebSocket 拨号 /v1/realtime(model=grok-voice-latest)。握手成功即连通;若有首包服务端事件会一并显示。',
|
||||
sendingSearchRequest: '正在发送独立 web_search 请求...',
|
||||
sendingTTSRequest: '正在发送独立 /tts 请求...',
|
||||
sendingSTTRequest: '正在发送独立 /stt 请求...',
|
||||
sendingRealtimeRequest: '正在拨号独立 /realtime WebSocket...',
|
||||
selectedTestMode: '测试模式:{mode}',
|
||||
imageUploadLabel: '源图片(可选,图生图/编辑)',
|
||||
videoFirstFrameLabel: '首帧 / 参考图(可选)',
|
||||
imageUploadHint:
|
||||
'建议 PNG/JPEG,宽高均 ≥ 8 像素,编辑建议小于约 4MB。上传源图会走 /images/edits(图生图);不上传则走 /images/generations 文生图。',
|
||||
videoFirstFrameHint:
|
||||
'可选首帧/参考图用于图生视频。建议 PNG/JPEG,宽高均 ≥ 8 像素。',
|
||||
audioUploadLabel: '音频文件(STT 可选)',
|
||||
audioUploadHint: '上传真实音频做转写;不上传则用静音 WAV 仅测连通。',
|
||||
mediaTooLarge: '文件过大(管理端测试上传上限约 6MB)。',
|
||||
chooseImageFile: '选择图片',
|
||||
chooseAudioFile: '选择音频',
|
||||
uploadPreviewAlt: '上传预览',
|
||||
fileReadFailed: '读取所选文件失败',
|
||||
noResponseBody: '服务器未返回响应体'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: '自动透传(仅替换认证)',
|
||||
@@ -1062,6 +1124,16 @@ export default {
|
||||
ssoCookieLabel: 'Grok Web SSO Key',
|
||||
ssoCookiePlaceholder: '每行一个 SSO key\n支持多个,每行一个',
|
||||
ssoCookieHint: '每行一个 SSO key;多个 key 会 3 路并发导入,耗时约 90 秒 × 批次数,建议使用对应地区代理。',
|
||||
emailPasswordAuth: '邮箱密码登录',
|
||||
emailPasswordDesc:
|
||||
'使用 Grok 网页邮箱与密码登录。服务端仅用密码换取临时 SSO 再转 Build OAuth;密码与 raw SSO 均不会写入账号凭据。',
|
||||
emailPasswordInputLabel: '邮箱----密码',
|
||||
emailPasswordPlaceholder: "user{'@'}example.com----your-password\n支持多个,每行一组",
|
||||
emailPasswordHint: '格式:email----password(密码可含 -)。需要配置 YesCaptcha 密钥;建议搭配代理。',
|
||||
pleaseEnterPassword: '请输入 email----password(每行一组)',
|
||||
pleaseEnterSSOToken: '请输入 SSO Token',
|
||||
failedToValidateSSO: '校验 Grok SSO 失败',
|
||||
failedToAuthorizePassword: 'Grok 密码授权失败',
|
||||
convertingSSO: '转换中...',
|
||||
convertSSOAndCreate: '转换并创建账号',
|
||||
validating: '验证中...',
|
||||
@@ -1320,6 +1392,9 @@ export default {
|
||||
reAuthorizedSuccess: '账号重新授权成功',
|
||||
// Test Modal
|
||||
testAccountConnection: '测试账号连接',
|
||||
errorPrefix: '错误:{message}',
|
||||
imagePreviewAlt: '测试图片 {index}',
|
||||
imageLightboxAlt: '图片预览',
|
||||
account: '账号',
|
||||
readyToTest: '准备测试。点击"开始测试"按钮开始...',
|
||||
connectingToApi: '连接 API 中...',
|
||||
@@ -1341,10 +1416,22 @@ export default {
|
||||
imagePromptLabel: '生图提示词',
|
||||
imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
|
||||
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||
imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
|
||||
imageTestHint:
|
||||
'调用独立 /v1/images/generations 生图,并在下方预览返回图片。',
|
||||
imageTestMode: '模式:生图测试',
|
||||
videoPromptLabel: '视频提示词',
|
||||
videoPromptPlaceholder: '例如:一只红球在白地板上弹跳一次,动作简短。',
|
||||
videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.',
|
||||
videoTestHint:
|
||||
'调用独立 /v1/videos/generations,轮询至完成后下载成品视频并在页面上预览。',
|
||||
videoTestMode: '模式:视频生成测试',
|
||||
sendingVideoRequest: '正在发送视频生成测试请求...',
|
||||
imagePreview: '生成结果:',
|
||||
imageReceived: '已收到第 {count} 张测试图片',
|
||||
audioPreview: '生成音频:',
|
||||
audioReceived: '已收到第 {count} 段测试音频',
|
||||
videoPreview: '生成视频:',
|
||||
videoReceived: '已收到第 {count} 段测试视频',
|
||||
// Stats Modal
|
||||
viewStats: '查看统计',
|
||||
usageStatistics: '使用统计',
|
||||
|
||||
@@ -982,6 +982,8 @@ export default {
|
||||
title: '视频生成计费',
|
||||
description:
|
||||
'配置 Grok 视频生成的每秒单价(USD/秒),留空则使用默认每秒价(grok-imagine-video:480p $0.05/s、720p $0.07/s;video-1.5:480p $0.08/s、720p $0.14/s、1080p $0.25/s)',
|
||||
modelOverridesTitle: '按模型覆盖视频价格',
|
||||
modelOverridesDescription: '已填写的单元格会覆盖该模型族的平面分辨率价格。video-1.5 的 preview 与 legacy 别名共用同一模型族;留空则回退到平面分辨率价格。',
|
||||
independentMultiplier: '视频倍率独立',
|
||||
videoMultiplier: '视频独立倍率',
|
||||
modeHint:
|
||||
@@ -989,6 +991,20 @@ export default {
|
||||
finalPricePreview: '最终每秒价格预览',
|
||||
notConfigured: '未配置'
|
||||
},
|
||||
explicitPricing: {
|
||||
title: 'Grok 搜索与 Voice 定价',
|
||||
description: '分组级 web_search(每千次)与 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。',
|
||||
searchPricePer1k: '搜索每千次价格(USD)',
|
||||
pricePlaceholder: '可选'
|
||||
},
|
||||
voicePricing: {
|
||||
title: 'Grok Voice 定价',
|
||||
description: '分组级 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。',
|
||||
audioRealtimePerMin: 'Realtime 每分钟价格(USD)',
|
||||
audioTtsPerMillionChars: 'TTS 每百万字符价格(USD)',
|
||||
audioSttPerHour: 'STT 每小时价格(USD)',
|
||||
pricePlaceholder: '可选'
|
||||
},
|
||||
webSearchPricing: {
|
||||
title: 'Codex 网页搜索计费',
|
||||
pricePerCall: '搜索单次价格(USD/次)',
|
||||
|
||||
@@ -541,6 +541,10 @@ export default {
|
||||
billingModeImage: '按次(图片)',
|
||||
billingModeVideo: '按次(视频)',
|
||||
allBillingModes: '全部计费模式',
|
||||
upstreamModelAudit: '上游模型审计',
|
||||
allUpstreamModelAudit: '全部响应模型状态',
|
||||
upstreamModelMismatchOnly: '仅不一致',
|
||||
upstreamModelMatchedOnly: '仅一致',
|
||||
ipAddress: 'IP',
|
||||
clickToViewBalance: '点击查看充值记录',
|
||||
failedToLoadUser: '加载用户信息失败',
|
||||
|
||||
@@ -411,7 +411,12 @@ export default {
|
||||
title: '网关调度设置',
|
||||
description: '控制 API Key 的调度行为',
|
||||
allowUngroupedKey: '允许未分组 Key 调度',
|
||||
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。'
|
||||
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。',
|
||||
accountSchedulingThresholdsTitle: '平台账号自动停调阈值',
|
||||
accountSchedulingThresholdsDescription: '当账号当前原生用量窗口(OpenAI Codex/Anthropic 会话,或 Grok 请求/Token 利用率)达到该百分比时,Sub2API 会临时将其移出调度,直到窗口重置。填 100 表示禁用。',
|
||||
accountSchedulingThresholdsGlobalHint: '系统级默认值,作用于该平台全部账号。可在账号编辑页对单个账号覆盖。',
|
||||
accountSchedulingThresholdsDisabledHint: '100 表示禁用该平台自动停调;1–99 表示达到该利用率后暂停调度。',
|
||||
accountSchedulingThresholdsRangeHint: '整数 1–100(百分比)。仅 OpenAI / Anthropic / Grok。'
|
||||
},
|
||||
upstreamBillingProbe: {
|
||||
title: '上游倍率自动探测',
|
||||
@@ -438,6 +443,17 @@ export default {
|
||||
gatewayForwarding: {
|
||||
title: '请求转发行为',
|
||||
description: '控制请求转发到上游 OAuth 账号时的行为',
|
||||
grokDefaultTextModel: '默认 Grok 文本模型',
|
||||
grokDefaultTextModelHint: '用于空模型值;仅在右侧开关开启时也用于其他客户端模型命名空间。允许填写自定义 Grok 模型 ID。',
|
||||
grokCrossClientMap: '映射其他客户端模型到 Grok',
|
||||
grokCrossClientMapHint: '默认关闭。开启后,GPT、Codex、o 系列和 Claude 模型 ID 会路由到左侧默认 Grok 文本模型。',
|
||||
grokDefaultBaseURLMode: '默认 Grok 上游',
|
||||
grokDefaultBaseURLModeHint: '仅用于 Grok 账号未配置显式 base URL 的文本请求;媒体和语音仍使用官方 API 主机。',
|
||||
grokBaseURLModeCLI: 'CLI 聊天代理',
|
||||
grokBaseURLModeAPI: '公共 API',
|
||||
grokBaseURLModeUSEast1: '区域 API(us-east-1)',
|
||||
grokBaseURLModeUSWest2: '区域 API(us-west-2)',
|
||||
grokBaseURLModeEUWest1: '区域 API(eu-west-1)',
|
||||
fingerprintUnification: '指纹统一化',
|
||||
fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。',
|
||||
metadataPassthrough: 'Metadata 透传',
|
||||
|
||||
@@ -81,6 +81,13 @@ export default {
|
||||
view: '查看',
|
||||
settings: '设置',
|
||||
chooseFile: '选择文件',
|
||||
upload: '上传',
|
||||
remove: '移除',
|
||||
noFileSelected: '未选择文件',
|
||||
selectedFile: '已选:{name}',
|
||||
fileReadFailed: '读取文件失败',
|
||||
selectImageFile: '请选择图片文件',
|
||||
fileTooLargeKb: '文件过大({size} KB),上限 {max} KB',
|
||||
copy: '复制',
|
||||
notAvailable: '不可用',
|
||||
now: '现在',
|
||||
|
||||
@@ -173,16 +173,24 @@ export default {
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
grok: {
|
||||
description: '配置 Grok Build、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。',
|
||||
description:
|
||||
'配置 Grok CLI、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。文本模型走 Responses;图片/视频使用 Imagine 模型 ID 与媒体端点。',
|
||||
claudeDescription: '配置 Claude Code,让 Messages API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
codexDescription: '配置 Codex,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。',
|
||||
codexConfigTomlHint: '如已有 config.toml,请先备份再合并此服务商配置。',
|
||||
note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 grok。',
|
||||
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 grok。',
|
||||
claudeNote: '二选一即可:终端命令仅在当前会话生效;保存 settings.json 可作为用户级持久配置。',
|
||||
codexNote: '将 config.toml 保存到 ~/.codex,并在启动 Codex 前设置 SUB2API_API_KEY。',
|
||||
codexNoteWindows: '将 config.toml 保存到 %USERPROFILE%\\.codex,并在 PowerShell 中设置 SUB2API_API_KEY 后启动 Codex。'
|
||||
configTomlHint:
|
||||
'官方路径:~/.grok/config.toml(或 $GROK_HOME)。请填写 [endpoints](models_base_url / models_list_url / xai_api_base_url / cli_chat_proxy_base_url)、[auth] preferred_method=api_key、[models]、[session]、[features] 图片/视频覆盖。优先 env_key,勿硬编码 api_key;文本模型必须 api_backend=responses。合并前备份,保存后运行 grok inspect。',
|
||||
codexConfigTomlHint:
|
||||
'Codex 官方:wire_api 仅支持 "responses";优先 env_key,勿与 experimental_bearer_token 混用;非 OpenAI 网关默认 supports_websockets = false(Sub2API 仍可接客户端 WS 并桥接到 HTTP/SSE)。合并前备份 ~/.codex/config.toml。',
|
||||
note:
|
||||
'导出 GROK_MODELS_BASE_URL 与 XAI_API_KEY,将完整 config.toml(endpoints/auth/models/session/features)保存为 ~/.grok/config.toml,运行 grok inspect,再用 /model 选择 grok-4.5(编程场景可用 grok-build-0.1)。',
|
||||
noteWindows:
|
||||
'设置 GROK_MODELS_BASE_URL 与 XAI_API_KEY,将完整 config.toml 保存为 %USERPROFILE%\\.grok\\config.toml,运行 grok inspect,再用 /model 选择 grok-4.5(编程场景可用 grok-build-0.1)。',
|
||||
claudeNote:
|
||||
'二选一:终端环境变量仅当前会话;~/.claude/settings.json 可持久化。请勿把含 API Key 的文件提交到仓库。',
|
||||
codexNote:
|
||||
'导出 SUB2API_API_KEY,将 config.toml 保存到 ~/.codex(可用 mkdir -p ~/.codex)。优先 env_key,勿提交密钥。',
|
||||
codexNoteWindows:
|
||||
'设置 $env:SUB2API_API_KEY,将 config.toml 保存到 %USERPROFILE%\\.codex。优先 env_key,勿提交密钥。'
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
@@ -300,6 +308,11 @@ export default {
|
||||
model: '模型',
|
||||
requestedModel: '请求',
|
||||
upstreamModel: '上游',
|
||||
sentUpstreamModel: '发往上游',
|
||||
upstreamResponseModel: '上游响应',
|
||||
upstreamModelMismatch: '上游响应模型不一致',
|
||||
modelVariant: '疑似版本变体',
|
||||
modelMismatch: '模型不一致',
|
||||
reasoningEffort: '推理强度',
|
||||
endpoint: '端点',
|
||||
endpointDistribution: '端点分布',
|
||||
|
||||
@@ -526,6 +526,8 @@ export interface PaginationConfig {
|
||||
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'composite'
|
||||
|
||||
export type VideoModelPrices = Record<string, Record<string, number>>
|
||||
|
||||
export type SubscriptionType = 'standard' | 'subscription'
|
||||
|
||||
export interface OpenAIMessagesDispatchModelConfig {
|
||||
@@ -570,8 +572,15 @@ export interface Group {
|
||||
video_price_480p: number | null
|
||||
video_price_720p: number | null
|
||||
video_price_1080p: number | null
|
||||
// Optional model-family x resolution overrides for Grok video pricing.
|
||||
video_model_prices?: VideoModelPrices
|
||||
// Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
|
||||
web_search_price_per_call: number | null
|
||||
// Grok Voice 显式定价(分组级)
|
||||
search_price_per_1k: number | null
|
||||
audio_realtime_price_per_min: number | null
|
||||
audio_tts_price_per_million_chars: number | null
|
||||
audio_stt_price_per_hour: number | null
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: boolean
|
||||
peak_start: string
|
||||
@@ -770,7 +779,12 @@ export interface CreateGroupRequest {
|
||||
video_price_480p?: number | null
|
||||
video_price_720p?: number | null
|
||||
video_price_1080p?: number | null
|
||||
video_model_prices?: VideoModelPrices
|
||||
web_search_price_per_call?: number | null
|
||||
search_price_per_1k?: number | null
|
||||
audio_realtime_price_per_min?: number | null
|
||||
audio_tts_price_per_million_chars?: number | null
|
||||
audio_stt_price_per_hour?: number | null
|
||||
peak_rate_enabled?: boolean
|
||||
peak_start?: string
|
||||
peak_end?: string
|
||||
@@ -825,7 +839,12 @@ export interface UpdateGroupRequest {
|
||||
video_price_480p?: number | null
|
||||
video_price_720p?: number | null
|
||||
video_price_1080p?: number | null
|
||||
video_model_prices?: VideoModelPrices
|
||||
web_search_price_per_call?: number | null
|
||||
search_price_per_1k?: number | null
|
||||
audio_realtime_price_per_min?: number | null
|
||||
audio_tts_price_per_million_chars?: number | null
|
||||
audio_stt_price_per_hour?: number | null
|
||||
peak_rate_enabled?: boolean
|
||||
peak_start?: string
|
||||
peak_end?: string
|
||||
@@ -976,6 +995,9 @@ export interface TempUnschedulableState {
|
||||
matched_keyword: string
|
||||
rule_index: number
|
||||
error_message: string
|
||||
trigger_count?: number
|
||||
trigger_threshold?: number
|
||||
trigger_window_minutes?: number
|
||||
}
|
||||
|
||||
export interface TempUnschedulableStatus {
|
||||
@@ -1264,6 +1286,14 @@ export interface GrokBillingSummary {
|
||||
billing_period_start?: string
|
||||
billing_period_end?: string
|
||||
used_percent?: number | null
|
||||
/** Absolute USD money from billing probes */
|
||||
prepaid_balance?: number | null
|
||||
monthly_limit?: number | null
|
||||
monthly_used?: number | null
|
||||
on_demand_cap?: number | null
|
||||
on_demand_used?: number | null
|
||||
top_up_method?: string
|
||||
is_unified_billing_user?: boolean
|
||||
plan?: string
|
||||
status_code?: number
|
||||
source?: string
|
||||
@@ -1282,6 +1312,7 @@ export interface AccountUsageInfo {
|
||||
seven_day: UsageProgress | null
|
||||
seven_day_sonnet: UsageProgress | null
|
||||
seven_day_fable?: UsageProgress | null
|
||||
thirty_day?: UsageProgress | null
|
||||
gemini_shared_daily?: UsageProgress | null
|
||||
gemini_pro_daily?: UsageProgress | null
|
||||
gemini_flash_daily?: UsageProgress | null
|
||||
@@ -1653,6 +1684,8 @@ export interface UsageLogAccountSummary {
|
||||
|
||||
export interface AdminUsageLog extends UsageLog {
|
||||
upstream_model?: string | null
|
||||
upstream_response_model?: string | null
|
||||
upstream_model_mismatch?: boolean | null
|
||||
model_mapping_chain?: string | null
|
||||
|
||||
// 账号计费倍率(仅管理员可见)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BILLING_MODE_IMAGE,
|
||||
BILLING_MODE_TOKEN,
|
||||
BILLING_MODE_VIDEO,
|
||||
getDisplayBillingMode,
|
||||
isImageUsage
|
||||
} from '../billingMode'
|
||||
|
||||
describe('billingMode helpers', () => {
|
||||
it('prefers explicit video mode over image_count', () => {
|
||||
expect(
|
||||
getDisplayBillingMode({ image_count: 1, billing_mode: BILLING_MODE_VIDEO })
|
||||
).toBe(BILLING_MODE_VIDEO)
|
||||
expect(isImageUsage({ image_count: 1, billing_mode: BILLING_MODE_VIDEO })).toBe(false)
|
||||
})
|
||||
|
||||
it('infers image when image_count set and mode missing', () => {
|
||||
expect(getDisplayBillingMode({ image_count: 2, billing_mode: null })).toBe(BILLING_MODE_IMAGE)
|
||||
})
|
||||
|
||||
it('keeps token mode even with image_count', () => {
|
||||
expect(
|
||||
getDisplayBillingMode({ image_count: 1, billing_mode: BILLING_MODE_TOKEN })
|
||||
).toBe(BILLING_MODE_TOKEN)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,10 @@ export function isImageUsage(row: Pick<ImageBillingRow, 'image_count' | 'billing
|
||||
}
|
||||
|
||||
export function getDisplayBillingMode(row: Pick<ImageBillingRow, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
|
||||
// Explicit video/token modes always win over image_count heuristics.
|
||||
if (row?.billing_mode === BILLING_MODE_VIDEO || row?.billing_mode === BILLING_MODE_TOKEN) {
|
||||
return row.billing_mode
|
||||
}
|
||||
if ((row?.image_count ?? 0) > 0 && !row?.billing_mode) {
|
||||
return BILLING_MODE_IMAGE
|
||||
}
|
||||
|
||||
@@ -318,7 +318,12 @@
|
||||
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
|
||||
:today-stats-loading="todayStatsLoading"
|
||||
:manual-refresh-token="usageManualRefreshToken"
|
||||
:batched-usage="usageBatchByAccountId[String(row.id)] ?? null"
|
||||
:batched-usage-error="usageBatchErrorByAccountId[String(row.id)] ?? null"
|
||||
:batched-usage-loading="usageBatchLoadingByAccountId[String(row.id)] === true"
|
||||
:request-batched-usage="isDesktopViewport ? queueBatchedUsage : null"
|
||||
@account-updated="handleAccountUpdated"
|
||||
@usage-loaded="handleAccountUsageLoaded(row.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-proxy="{ row }">
|
||||
@@ -527,7 +532,7 @@ import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import { getFloatingPanelPosition } from '@/utils/floatingPanel'
|
||||
import { formatMultiplier } from '@/utils/formatters'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, AccountUsageInfo, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -692,6 +697,24 @@ const todayStatsReqSeq = ref(0)
|
||||
const pendingTodayStatsRefresh = ref(false)
|
||||
const usageManualRefreshToken = ref(0)
|
||||
|
||||
const desktopViewportQuery = '(min-width: 768px)'
|
||||
const isDesktopViewport = ref(
|
||||
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
|
||||
)
|
||||
let desktopViewportMediaQuery: MediaQueryList | null = null
|
||||
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
const usageBatchByAccountId = ref<Record<string, AccountUsageInfo | null>>({})
|
||||
const usageBatchErrorByAccountId = ref<Record<string, string | null>>({})
|
||||
const usageBatchLoadingByAccountId = ref<Record<string, boolean>>({})
|
||||
const usageBatchRequestTokenByAccountId = ref<Record<string, number>>({})
|
||||
const usageBatchCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
const USAGE_BATCH_CACHE_TTL = 5 * 60 * 1000
|
||||
const pendingUsageBatchIds = new Set<number>()
|
||||
let usageBatchFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let queuedUsageBatchForce = false
|
||||
let usageBatchRequestToken = 0
|
||||
|
||||
const buildDefaultTodayStats = (): WindowStats => ({
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
@@ -700,6 +723,138 @@ const buildDefaultTodayStats = (): WindowStats => ({
|
||||
user_cost: 0
|
||||
})
|
||||
|
||||
const accountSupportsBatchUsage = (account: Account) => {
|
||||
if (account.platform === 'anthropic') {
|
||||
return account.type === 'oauth' || account.type === 'setup-token'
|
||||
}
|
||||
if (account.platform === 'gemini') return true
|
||||
if (account.platform === 'antigravity') return account.type === 'oauth'
|
||||
if (account.platform === 'openai') return account.type === 'oauth'
|
||||
if (account.platform === 'grok') return account.type === 'oauth'
|
||||
return false
|
||||
}
|
||||
|
||||
const setUsageBatchLoading = (accountID: number, loadingState: boolean) => {
|
||||
usageBatchLoadingByAccountId.value = {
|
||||
...usageBatchLoadingByAccountId.value,
|
||||
[String(accountID)]: loadingState
|
||||
}
|
||||
}
|
||||
|
||||
const setUsageBatchState = (accountID: number, usage: AccountUsageInfo | null, error: string | null) => {
|
||||
const key = String(accountID)
|
||||
usageBatchByAccountId.value = {
|
||||
...usageBatchByAccountId.value,
|
||||
[key]: usage
|
||||
}
|
||||
usageBatchErrorByAccountId.value = {
|
||||
...usageBatchErrorByAccountId.value,
|
||||
[key]: error
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccountUsageLoaded = (accountID: number, usage: AccountUsageInfo) => {
|
||||
if (usageBatchByAccountId.value[String(accountID)] === usage) return
|
||||
setUsageBatchState(accountID, usage, null)
|
||||
}
|
||||
|
||||
const flushQueuedUsageBatch = async () => {
|
||||
usageBatchFlushTimer = null
|
||||
const accountIDs = Array.from(pendingUsageBatchIds)
|
||||
const force = queuedUsageBatchForce
|
||||
pendingUsageBatchIds.clear()
|
||||
queuedUsageBatchForce = false
|
||||
|
||||
if (accountIDs.length === 0) return
|
||||
|
||||
const requestTokensByAccount = accountIDs.reduce<Record<string, number>>((acc, accountID) => {
|
||||
acc[String(accountID)] = usageBatchRequestTokenByAccountId.value[String(accountID)] ?? 0
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
try {
|
||||
const result = await adminAPI.accounts.getBatchUsage(accountIDs, force)
|
||||
|
||||
const usageMap = result.usage ?? {}
|
||||
const errorMap = result.errors ?? {}
|
||||
const now = Date.now()
|
||||
const nextUsage = { ...usageBatchByAccountId.value }
|
||||
const nextErrors = { ...usageBatchErrorByAccountId.value }
|
||||
const nextLoading = { ...usageBatchLoadingByAccountId.value }
|
||||
|
||||
for (const accountID of accountIDs) {
|
||||
const key = String(accountID)
|
||||
if ((usageBatchRequestTokenByAccountId.value[key] ?? 0) !== requestTokensByAccount[key]) {
|
||||
continue
|
||||
}
|
||||
const usage = usageMap[key] ?? null
|
||||
nextUsage[key] = usage
|
||||
nextErrors[key] = errorMap[key] ?? null
|
||||
nextLoading[key] = false
|
||||
if (usage) {
|
||||
usageBatchCache.set(accountID, { data: usage, ts: now })
|
||||
} else {
|
||||
usageBatchCache.delete(accountID)
|
||||
}
|
||||
}
|
||||
|
||||
usageBatchByAccountId.value = nextUsage
|
||||
usageBatchErrorByAccountId.value = nextErrors
|
||||
usageBatchLoadingByAccountId.value = nextLoading
|
||||
} catch (error) {
|
||||
const nextErrors = { ...usageBatchErrorByAccountId.value }
|
||||
const nextLoading = { ...usageBatchLoadingByAccountId.value }
|
||||
for (const accountID of accountIDs) {
|
||||
const key = String(accountID)
|
||||
if ((usageBatchRequestTokenByAccountId.value[key] ?? 0) !== requestTokensByAccount[key]) {
|
||||
continue
|
||||
}
|
||||
nextErrors[key] = 'Failed'
|
||||
nextLoading[key] = false
|
||||
}
|
||||
usageBatchErrorByAccountId.value = nextErrors
|
||||
usageBatchLoadingByAccountId.value = nextLoading
|
||||
console.error('Failed to load account usage batch:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const queueBatchedUsage = (account: Account, options?: { force?: boolean }) => {
|
||||
if (!isDesktopViewport.value) return
|
||||
if (!accountSupportsBatchUsage(account)) return
|
||||
|
||||
const force = options?.force === true
|
||||
const cacheKey = account.id
|
||||
const key = String(cacheKey)
|
||||
|
||||
if (force) {
|
||||
usageBatchCache.delete(cacheKey)
|
||||
} else {
|
||||
const cached = usageBatchCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.ts < USAGE_BATCH_CACHE_TTL) {
|
||||
setUsageBatchState(cacheKey, cached.data, null)
|
||||
setUsageBatchLoading(cacheKey, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
usageBatchErrorByAccountId.value = {
|
||||
...usageBatchErrorByAccountId.value,
|
||||
[key]: null
|
||||
}
|
||||
usageBatchRequestTokenByAccountId.value = {
|
||||
...usageBatchRequestTokenByAccountId.value,
|
||||
[key]: ++usageBatchRequestToken
|
||||
}
|
||||
setUsageBatchLoading(cacheKey, true)
|
||||
pendingUsageBatchIds.add(cacheKey)
|
||||
queuedUsageBatchForce = queuedUsageBatchForce || force
|
||||
|
||||
if (usageBatchFlushTimer !== null) return
|
||||
usageBatchFlushTimer = setTimeout(() => {
|
||||
void flushQueuedUsageBatch()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const refreshTodayStatsBatch = async () => {
|
||||
// Why this checks both columns:
|
||||
// - today_stats column shows dedicated today's metrics.
|
||||
@@ -1084,6 +1239,22 @@ watch(loading, (isLoading, wasLoading) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(accounts, (rows) => {
|
||||
const visibleIDs = new Set(rows.map((row) => String(row.id)))
|
||||
usageBatchByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchErrorByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchErrorByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchLoadingByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchLoadingByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchRequestTokenByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchRequestTokenByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
})
|
||||
|
||||
watch(upstreamBillingNow, () => {
|
||||
if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
|
||||
if (typeof document !== 'undefined' && document.hidden) return
|
||||
@@ -2183,6 +2354,19 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
|
||||
isDesktopViewport.value = desktopViewportMediaQuery.matches
|
||||
desktopViewportListener = (event: MediaQueryListEvent) => {
|
||||
isDesktopViewport.value = event.matches
|
||||
}
|
||||
if (typeof desktopViewportMediaQuery.addEventListener === 'function') {
|
||||
desktopViewportMediaQuery.addEventListener('change', desktopViewportListener)
|
||||
} else {
|
||||
desktopViewportMediaQuery.addListener(desktopViewportListener)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
loadUpstreamBillingProbeGlobalState()
|
||||
try {
|
||||
@@ -2205,9 +2389,23 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (usageBatchFlushTimer !== null) {
|
||||
clearTimeout(usageBatchFlushTimer)
|
||||
usageBatchFlushTimer = null
|
||||
}
|
||||
pendingUsageBatchIds.clear()
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleViewportResize)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (desktopViewportMediaQuery && desktopViewportListener) {
|
||||
if (typeof desktopViewportMediaQuery.removeEventListener === 'function') {
|
||||
desktopViewportMediaQuery.removeEventListener('change', desktopViewportListener)
|
||||
} else {
|
||||
desktopViewportMediaQuery.removeListener(desktopViewportListener)
|
||||
}
|
||||
}
|
||||
desktopViewportListener = null
|
||||
desktopViewportMediaQuery = null
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1087,6 +1087,45 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700"
|
||||
data-testid="create-grok-video-model-prices"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.groups.videoPricing.modelOverridesTitle") }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.videoPricing.modelOverridesDescription") }}
|
||||
</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="family in videoModelPriceFamilyRows(createForm.video_model_prices)"
|
||||
:key="family.key"
|
||||
class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,minmax(0,7rem))] sm:items-end"
|
||||
>
|
||||
<div class="min-w-0 pb-1 font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ family.label }}
|
||||
</div>
|
||||
<label
|
||||
v-for="resolution in grokVideoPriceResolutions"
|
||||
:key="resolution.key"
|
||||
class="block"
|
||||
>
|
||||
<span class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ resolution.label }} ($/s)
|
||||
</span>
|
||||
<input
|
||||
v-model.number="createForm.video_model_prices[family.key][resolution.key]"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:data-testid="`create-grok-video-price-${family.key}-${resolution.key}`"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t(videoPricingI18nKey("modeHint")) }}
|
||||
</p>
|
||||
@@ -1444,6 +1483,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Grok Voice 显式定价(仅 grok 平台) -->
|
||||
<div
|
||||
v-if="createForm.platform === 'grok'"
|
||||
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
|
||||
>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ t("admin.groups.explicitPricing.title") }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t("admin.groups.explicitPricing.description") }}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.explicitPricing.searchPricePer1k") }}</label>
|
||||
<input
|
||||
v-model.number="createForm.search_price_per_1k"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.explicitPricing.pricePlaceholder')"
|
||||
data-testid="create-search-price"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
|
||||
<input
|
||||
v-model.number="createForm.audio_realtime_price_per_min"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
|
||||
data-testid="create-audio-realtime-price"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
|
||||
<input
|
||||
v-model.number="createForm.audio_tts_price_per_million_chars"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
|
||||
data-testid="create-audio-tts-price"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
|
||||
<input
|
||||
v-model.number="createForm.audio_stt_price_per_hour"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
|
||||
data-testid="create-audio-stt-price"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OpenAI Live 开关(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="createForm.platform === 'openai'"
|
||||
@@ -2692,6 +2794,45 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700"
|
||||
data-testid="edit-grok-video-model-prices"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.groups.videoPricing.modelOverridesTitle") }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.videoPricing.modelOverridesDescription") }}
|
||||
</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="family in videoModelPriceFamilyRows(editForm.video_model_prices)"
|
||||
:key="family.key"
|
||||
class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,minmax(0,7rem))] sm:items-end"
|
||||
>
|
||||
<div class="min-w-0 pb-1 font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ family.label }}
|
||||
</div>
|
||||
<label
|
||||
v-for="resolution in grokVideoPriceResolutions"
|
||||
:key="resolution.key"
|
||||
class="block"
|
||||
>
|
||||
<span class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ resolution.label }} ($/s)
|
||||
</span>
|
||||
<input
|
||||
v-model.number="editForm.video_model_prices[family.key][resolution.key]"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:data-testid="`edit-grok-video-price-${family.key}-${resolution.key}`"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t(videoPricingI18nKey("modeHint")) }}
|
||||
</p>
|
||||
@@ -3045,6 +3186,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Grok Voice 显式定价(仅 grok 平台) -->
|
||||
<div
|
||||
v-if="editForm.platform === 'grok'"
|
||||
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
|
||||
>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ t("admin.groups.explicitPricing.title") }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t("admin.groups.explicitPricing.description") }}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.explicitPricing.searchPricePer1k") }}</label>
|
||||
<input
|
||||
v-model.number="editForm.search_price_per_1k"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.explicitPricing.pricePlaceholder')"
|
||||
data-testid="edit-search-price"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
|
||||
<input
|
||||
v-model.number="editForm.audio_realtime_price_per_min"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
|
||||
data-testid="edit-audio-realtime-price"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
|
||||
<input
|
||||
v-model.number="editForm.audio_tts_price_per_million_chars"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
|
||||
data-testid="edit-audio-tts-price"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
|
||||
<input
|
||||
v-model.number="editForm.audio_stt_price_per_hour"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
|
||||
data-testid="edit-audio-stt-price"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OpenAI Live 开关(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="editForm.platform === 'openai'"
|
||||
@@ -4219,6 +4423,12 @@ import {
|
||||
supportsVideoPricingPlatform,
|
||||
videoPricingI18nKey,
|
||||
} from "./groupsImagePricing";
|
||||
import {
|
||||
createVideoModelPricesForm,
|
||||
grokVideoPriceResolutions,
|
||||
serializeVideoModelPrices,
|
||||
videoModelPriceFamilyRows,
|
||||
} from "./groupsVideoModelPricing";
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
@@ -4719,8 +4929,13 @@ const createForm = reactive({
|
||||
video_price_480p: null as number | null,
|
||||
video_price_720p: null as number | null,
|
||||
video_price_1080p: null as number | null,
|
||||
video_model_prices: createVideoModelPricesForm(),
|
||||
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
|
||||
web_search_price_per_call: null as number | null,
|
||||
search_price_per_1k: null as number | null,
|
||||
audio_realtime_price_per_min: null as number | null,
|
||||
audio_tts_price_per_million_chars: null as number | null,
|
||||
audio_stt_price_per_hour: null as number | null,
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: false,
|
||||
peak_start: "",
|
||||
@@ -5073,8 +5288,13 @@ const editForm = reactive({
|
||||
video_price_480p: null as number | null,
|
||||
video_price_720p: null as number | null,
|
||||
video_price_1080p: null as number | null,
|
||||
video_model_prices: createVideoModelPricesForm(),
|
||||
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
|
||||
web_search_price_per_call: null as number | null,
|
||||
search_price_per_1k: null as number | null,
|
||||
audio_realtime_price_per_min: null as number | null,
|
||||
audio_tts_price_per_million_chars: null as number | null,
|
||||
audio_stt_price_per_hour: null as number | null,
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: false,
|
||||
peak_start: "",
|
||||
@@ -5523,7 +5743,12 @@ const closeCreateModal = () => {
|
||||
createForm.video_price_480p = null;
|
||||
createForm.video_price_720p = null;
|
||||
createForm.video_price_1080p = null;
|
||||
createForm.video_model_prices = createVideoModelPricesForm();
|
||||
createForm.web_search_price_per_call = null;
|
||||
createForm.search_price_per_1k = null;
|
||||
createForm.audio_realtime_price_per_min = null;
|
||||
createForm.audio_tts_price_per_million_chars = null;
|
||||
createForm.audio_stt_price_per_hour = null;
|
||||
createForm.peak_rate_enabled = false;
|
||||
createForm.peak_start = "";
|
||||
createForm.peak_end = "";
|
||||
@@ -5608,9 +5833,16 @@ const handleCreateGroup = async () => {
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const {
|
||||
video_model_prices: _createFormVideoModelPrices,
|
||||
...createGroupForm
|
||||
} = createForm;
|
||||
const videoModelPrices = serializeVideoModelPrices(
|
||||
createForm.video_model_prices,
|
||||
);
|
||||
// 构建请求数据,包含模型路由配置
|
||||
const requestData = {
|
||||
...createForm,
|
||||
...createGroupForm,
|
||||
daily_limit_usd: normalizeOptionalLimit(
|
||||
createForm.daily_limit_usd as number | string | null,
|
||||
),
|
||||
@@ -5620,6 +5852,9 @@ const handleCreateGroup = async () => {
|
||||
monthly_limit_usd: normalizeOptionalLimit(
|
||||
createForm.monthly_limit_usd as number | string | null,
|
||||
),
|
||||
...(Object.keys(videoModelPrices).length > 0
|
||||
? { video_model_prices: videoModelPrices }
|
||||
: {}),
|
||||
model_routing: convertRoutingRulesToApiFormat(
|
||||
createModelRoutingRules.value,
|
||||
),
|
||||
@@ -5678,6 +5913,18 @@ const handleCreateGroup = async () => {
|
||||
requestData.video_price_480p = emptyToNull(requestData.video_price_480p);
|
||||
requestData.video_price_720p = emptyToNull(requestData.video_price_720p);
|
||||
requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p);
|
||||
requestData.search_price_per_1k = emptyToNull(
|
||||
requestData.search_price_per_1k,
|
||||
);
|
||||
requestData.audio_realtime_price_per_min = emptyToNull(
|
||||
requestData.audio_realtime_price_per_min,
|
||||
);
|
||||
requestData.audio_tts_price_per_million_chars = emptyToNull(
|
||||
requestData.audio_tts_price_per_million_chars,
|
||||
);
|
||||
requestData.audio_stt_price_per_hour = emptyToNull(
|
||||
requestData.audio_stt_price_per_hour,
|
||||
);
|
||||
requestData.web_search_price_per_call = emptyToNull(
|
||||
requestData.web_search_price_per_call,
|
||||
);
|
||||
@@ -5734,7 +5981,14 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.video_price_480p = group.video_price_480p;
|
||||
editForm.video_price_720p = group.video_price_720p;
|
||||
editForm.video_price_1080p = group.video_price_1080p;
|
||||
editForm.video_model_prices = createVideoModelPricesForm(
|
||||
group.video_model_prices,
|
||||
);
|
||||
editForm.web_search_price_per_call = group.web_search_price_per_call ?? null;
|
||||
editForm.search_price_per_1k = group.search_price_per_1k ?? null;
|
||||
editForm.audio_realtime_price_per_min = group.audio_realtime_price_per_min ?? null;
|
||||
editForm.audio_tts_price_per_million_chars = group.audio_tts_price_per_million_chars ?? null;
|
||||
editForm.audio_stt_price_per_hour = group.audio_stt_price_per_hour ?? null;
|
||||
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
|
||||
editForm.peak_start = group.peak_start ?? "";
|
||||
editForm.peak_end = group.peak_end ?? "";
|
||||
@@ -5814,7 +6068,12 @@ const closeEditModal = () => {
|
||||
editForm.video_price_480p = null;
|
||||
editForm.video_price_720p = null;
|
||||
editForm.video_price_1080p = null;
|
||||
editForm.video_model_prices = createVideoModelPricesForm();
|
||||
editForm.web_search_price_per_call = null;
|
||||
editForm.search_price_per_1k = null;
|
||||
editForm.audio_realtime_price_per_min = null;
|
||||
editForm.audio_tts_price_per_million_chars = null;
|
||||
editForm.audio_stt_price_per_hour = null;
|
||||
resetMessagesDispatchFormState(editForm);
|
||||
editForm.allow_live = false;
|
||||
resetModelsListState(editModelsListState);
|
||||
@@ -5851,6 +6110,9 @@ const handleUpdateGroup = async () => {
|
||||
monthly_limit_usd: normalizeOptionalLimit(
|
||||
editForm.monthly_limit_usd as number | string | null,
|
||||
),
|
||||
video_model_prices: serializeVideoModelPrices(
|
||||
editForm.video_model_prices,
|
||||
),
|
||||
fallback_group_id:
|
||||
editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
|
||||
fallback_group_id_on_invalid_request:
|
||||
@@ -5917,6 +6179,18 @@ const handleUpdateGroup = async () => {
|
||||
payload.video_price_480p = emptyPriceToClear(payload.video_price_480p);
|
||||
payload.video_price_720p = emptyPriceToClear(payload.video_price_720p);
|
||||
payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p);
|
||||
payload.search_price_per_1k = emptyPriceToClear(
|
||||
payload.search_price_per_1k,
|
||||
);
|
||||
payload.audio_realtime_price_per_min = emptyPriceToClear(
|
||||
payload.audio_realtime_price_per_min,
|
||||
);
|
||||
payload.audio_tts_price_per_million_chars = emptyPriceToClear(
|
||||
payload.audio_tts_price_per_million_chars,
|
||||
);
|
||||
payload.audio_stt_price_per_hour = emptyPriceToClear(
|
||||
payload.audio_stt_price_per_hour,
|
||||
);
|
||||
payload.web_search_price_per_call = emptyPriceToClear(
|
||||
payload.web_search_price_per_call,
|
||||
);
|
||||
|
||||
@@ -4871,6 +4871,78 @@
|
||||
<Toggle v-model="form.allow_ungrouped_key_scheduling" />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div class="mb-3">
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsTitle",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsDescription",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsGlobalHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsDisabledHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="platform in schedulingThresholdPlatforms"
|
||||
:key="platform"
|
||||
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<label
|
||||
class="font-mono text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ platform }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsRangeHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="form.account_scheduling_thresholds[platform]"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
class="input mt-3"
|
||||
:data-testid="`account-scheduling-threshold-${platform}`"
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!form.openai_advanced_scheduler_enabled"
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-5 dark:border-dark-700"
|
||||
@@ -5066,6 +5138,71 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="grid gap-5 border-b border-gray-100 pb-5 dark:border-dark-700 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||
<div>
|
||||
<label
|
||||
for="grok-default-text-model"
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.gatewayForwarding.grokDefaultTextModel") }}
|
||||
</label>
|
||||
<input
|
||||
id="grok-default-text-model"
|
||||
v-model.trim="form.grok_default_text_model"
|
||||
type="text"
|
||||
class="input mt-2 w-full"
|
||||
list="grok-default-text-model-options"
|
||||
data-testid="grok-default-text-model"
|
||||
placeholder="grok-4.5"
|
||||
/>
|
||||
<datalist id="grok-default-text-model-options">
|
||||
<option value="grok-4.5" />
|
||||
<option value="grok-4.1-fast" />
|
||||
<option value="grok-4" />
|
||||
</datalist>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.gatewayForwarding.grokDefaultTextModelHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-5 md:min-w-72">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.gatewayForwarding.grokCrossClientMap") }}
|
||||
</label>
|
||||
<p class="mt-0.5 max-w-sm text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.gatewayForwarding.grokCrossClientMapHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.grok_cross_client_model_map_enabled"
|
||||
data-testid="grok-cross-client-model-map-toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label
|
||||
for="grok-default-base-url-mode"
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.gatewayForwarding.grokDefaultBaseURLMode") }}
|
||||
</label>
|
||||
<select
|
||||
id="grok-default-base-url-mode"
|
||||
v-model="form.grok_default_base_url_mode"
|
||||
class="input mt-2 w-full"
|
||||
data-testid="grok-default-base-url-mode"
|
||||
>
|
||||
<option value="cli">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeCLI") }}</option>
|
||||
<option value="api">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeAPI") }}</option>
|
||||
<option value="us-east-1">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeUSEast1") }}</option>
|
||||
<option value="us-west-2">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeUSWest2") }}</option>
|
||||
<option value="eu-west-1">{{ t("admin.settings.gatewayForwarding.grokBaseURLModeEUWest1") }}</option>
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.gatewayForwarding.grokDefaultBaseURLModeHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fingerprint Unification -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -8505,8 +8642,11 @@ import { adminAPI } from "@/api";
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
normalizeAccountSchedulingThresholdsMap,
|
||||
normalizePlatformQuotasMap,
|
||||
sanitizeAccountSchedulingThresholdsMap,
|
||||
sanitizePlatformQuotasMap,
|
||||
SCHEDULING_THRESHOLD_PLATFORMS,
|
||||
defaultWeChatConnectScopesForMode,
|
||||
deriveWeChatConnectStoredMode,
|
||||
normalizeDefaultSubscriptionSettings,
|
||||
@@ -9252,8 +9392,11 @@ type SettingsForm = Omit<
|
||||
openai_advanced_scheduler_weight_session_sticky: string;
|
||||
// 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量)
|
||||
default_platform_quotas: DefaultPlatformQuotasMap;
|
||||
account_scheduling_thresholds: ReturnType<typeof normalizeAccountSchedulingThresholdsMap>;
|
||||
};
|
||||
|
||||
const schedulingThresholdPlatforms = SCHEDULING_THRESHOLD_PLATFORMS;
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
@@ -9276,6 +9419,7 @@ const form = reactive<SettingsForm>({
|
||||
login_agreement_documents: defaultLoginAgreementDocuments(),
|
||||
default_balance: 0,
|
||||
default_platform_quotas: normalizePlatformQuotasMap() as DefaultPlatformQuotasMap,
|
||||
account_scheduling_thresholds: normalizeAccountSchedulingThresholdsMap(),
|
||||
affiliate_rebate_rate: 20,
|
||||
affiliate_rebate_freeze_hours: 0,
|
||||
affiliate_rebate_duration_days: 0,
|
||||
@@ -9455,6 +9599,9 @@ const form = reactive<SettingsForm>({
|
||||
fallback_model_openai: "gpt-4o",
|
||||
fallback_model_gemini: "gemini-2.5-pro",
|
||||
fallback_model_antigravity: "gemini-2.5-pro",
|
||||
grok_default_text_model: "grok-4.5",
|
||||
grok_cross_client_model_map_enabled: false,
|
||||
grok_default_base_url_mode: "cli",
|
||||
// Identity patch (Claude -> Gemini)
|
||||
enable_identity_patch: true,
|
||||
identity_patch_prompt: "",
|
||||
@@ -10533,6 +10680,9 @@ async function loadSettings() {
|
||||
: defaultLoginAgreementDocuments();
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas);
|
||||
form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap(
|
||||
settings.account_scheduling_thresholds,
|
||||
);
|
||||
form.backend_mode_enabled = settings.backend_mode_enabled;
|
||||
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
|
||||
settings.default_subscriptions,
|
||||
@@ -11037,6 +11187,11 @@ async function saveSettings() {
|
||||
fallback_model_openai: form.fallback_model_openai,
|
||||
fallback_model_gemini: form.fallback_model_gemini,
|
||||
fallback_model_antigravity: form.fallback_model_antigravity,
|
||||
grok_default_text_model:
|
||||
form.grok_default_text_model.trim() || "grok-4.5",
|
||||
grok_cross_client_model_map_enabled:
|
||||
form.grok_cross_client_model_map_enabled,
|
||||
grok_default_base_url_mode: form.grok_default_base_url_mode,
|
||||
enable_identity_patch: form.enable_identity_patch,
|
||||
identity_patch_prompt: form.identity_patch_prompt,
|
||||
min_claude_code_version: form.min_claude_code_version,
|
||||
@@ -11205,6 +11360,9 @@ async function saveSettings() {
|
||||
}
|
||||
|
||||
payload.default_platform_quotas = sanitizePlatformQuotasMap(form.default_platform_quotas);
|
||||
payload.account_scheduling_thresholds = sanitizeAccountSchedulingThresholdsMap(
|
||||
form.account_scheduling_thresholds,
|
||||
);
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
|
||||
|
||||
const updated = await settingsStepUp.run(() =>
|
||||
@@ -11218,6 +11376,9 @@ async function saveSettings() {
|
||||
}
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(updated.default_platform_quotas);
|
||||
form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap(
|
||||
updated.account_scheduling_thresholds,
|
||||
);
|
||||
registrationEmailSuffixWhitelistTags.value =
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist,
|
||||
|
||||
@@ -450,6 +450,7 @@ const loadModelStats = async (source: ModelDistributionSource, force = false) =>
|
||||
request_type: requestType,
|
||||
stream: legacyStream === null ? undefined : legacyStream,
|
||||
billing_type: filters.value.billing_type,
|
||||
upstream_model_mismatch: filters.value.upstream_model_mismatch,
|
||||
}
|
||||
|
||||
const response = await adminAPI.dashboard.getModelStats({ ...baseParams, model_source: source })
|
||||
@@ -499,6 +500,7 @@ const loadChartData = async () => {
|
||||
request_type: requestType,
|
||||
stream: legacyStream === null ? undefined : legacyStream,
|
||||
billing_type: filters.value.billing_type,
|
||||
upstream_model_mismatch: filters.value.upstream_model_mismatch,
|
||||
include_stats: false,
|
||||
include_trend: true,
|
||||
include_model_stats: false,
|
||||
@@ -573,7 +575,7 @@ const exportToExcel = async () => {
|
||||
const XLSX = await import('xlsx')
|
||||
const headers = [
|
||||
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
|
||||
t('admin.usage.account'), t('usage.model'), t('usage.upstreamModel'), t('usage.reasoningEffort'), t('admin.usage.group'),
|
||||
t('admin.usage.account'), t('usage.requestedModel'), t('usage.sentUpstreamModel'), t('usage.upstreamResponseModel'), t('usage.upstreamModelMismatch'), t('usage.reasoningEffort'), t('admin.usage.group'),
|
||||
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
|
||||
t('usage.type'),
|
||||
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
|
||||
@@ -593,7 +595,7 @@ const exportToExcel = async () => {
|
||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||
const rows = (res.items || []).map((log: AdminUsageLog) => [
|
||||
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
|
||||
log.upstream_model || '', formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
|
||||
log.upstream_model || log.model, log.upstream_response_model || '', log.upstream_model_mismatch == null ? '' : t(log.upstream_model_mismatch ? 'common.yes' : 'common.no'), formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
|
||||
log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log),
|
||||
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
|
||||
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
getModelsListCandidates,
|
||||
getUsageSummary,
|
||||
getCapacitySummary,
|
||||
getLiveCapability,
|
||||
listAccounts,
|
||||
showError,
|
||||
showSuccess,
|
||||
@@ -21,6 +22,7 @@ const {
|
||||
getModelsListCandidates: vi.fn(),
|
||||
getUsageSummary: vi.fn(),
|
||||
getCapacitySummary: vi.fn(),
|
||||
getLiveCapability: vi.fn(),
|
||||
listAccounts: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
@@ -51,6 +53,7 @@ vi.mock('@/api/admin', () => ({
|
||||
getModelsListCandidates,
|
||||
getUsageSummary,
|
||||
getCapacitySummary,
|
||||
getLiveCapability,
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
@@ -244,6 +247,7 @@ describe('admin GroupsView column settings', () => {
|
||||
getModelsListCandidates.mockResolvedValue([])
|
||||
getUsageSummary.mockResolvedValue([])
|
||||
getCapacitySummary.mockResolvedValue([])
|
||||
getLiveCapability.mockResolvedValue({ supported: false })
|
||||
listAccounts.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
|
||||
isCurrentStep.mockReturnValue(false)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
getModelsListCandidates,
|
||||
getUsageSummary,
|
||||
getCapacitySummary,
|
||||
getLiveCapability,
|
||||
showSuccess,
|
||||
showError
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -19,6 +20,7 @@ const {
|
||||
getModelsListCandidates: vi.fn(),
|
||||
getUsageSummary: vi.fn(),
|
||||
getCapacitySummary: vi.fn(),
|
||||
getLiveCapability: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn()
|
||||
}))
|
||||
@@ -31,6 +33,7 @@ vi.mock('@/api/admin', () => ({
|
||||
getModelsListCandidates,
|
||||
getUsageSummary,
|
||||
getCapacitySummary,
|
||||
getLiveCapability,
|
||||
getAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@@ -166,6 +169,7 @@ describe('GroupsView duplicate action', () => {
|
||||
getModelsListCandidates,
|
||||
getUsageSummary,
|
||||
getCapacitySummary,
|
||||
getLiveCapability,
|
||||
showSuccess,
|
||||
showError
|
||||
]) {
|
||||
@@ -188,6 +192,7 @@ describe('GroupsView duplicate action', () => {
|
||||
getModelsListCandidates.mockResolvedValue([])
|
||||
getUsageSummary.mockResolvedValue([])
|
||||
getCapacitySummary.mockResolvedValue([])
|
||||
getLiveCapability.mockResolvedValue({ supported: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -444,6 +444,8 @@ const baseSettingsResponse = {
|
||||
fallback_model_openai: "",
|
||||
fallback_model_gemini: "",
|
||||
fallback_model_antigravity: "",
|
||||
grok_default_text_model: "grok-4.5",
|
||||
grok_cross_client_model_map_enabled: false,
|
||||
enable_identity_patch: false,
|
||||
identity_patch_prompt: "",
|
||||
ops_monitoring_enabled: false,
|
||||
@@ -1265,6 +1267,34 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存");
|
||||
});
|
||||
|
||||
it("loads and saves configurable Grok cross-client model mapping", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
grok_default_text_model: "grok-4.1-fast",
|
||||
grok_cross_client_model_map_enabled: true,
|
||||
});
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openGatewayTab(wrapper);
|
||||
|
||||
const modelInput = wrapper.get('[data-testid="grok-default-text-model"]');
|
||||
const mappingToggle = wrapper.get(
|
||||
'[data-testid="grok-cross-client-model-map-toggle"]',
|
||||
);
|
||||
expect((modelInput.element as HTMLInputElement).value).toBe("grok-4.1-fast");
|
||||
expect((mappingToggle.element as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
await modelInput.setValue("grok-custom-text");
|
||||
await mappingToggle.setValue(false);
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
const payload = updateSettings.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(payload.grok_default_text_model).toBe("grok-custom-text");
|
||||
expect(payload.grok_cross_client_model_map_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("loads fail-safe-off Ollama Cloud usage refresh settings and saves an explicit opt-in", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineComponent, ref } from 'vue'
|
||||
|
||||
import UsageView from '../UsageView.vue'
|
||||
|
||||
const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery } = vi.hoisted(() => {
|
||||
const { list, exportList, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery, aoaToSheet, sheetAddAoa, saveAs, xlsxWrite } = vi.hoisted(() => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
@@ -13,12 +13,17 @@ const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, ro
|
||||
|
||||
return {
|
||||
list: vi.fn(),
|
||||
exportList: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getSnapshotV2: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getModelStats: vi.fn(),
|
||||
listErrorLogs: vi.fn(),
|
||||
routeQuery: {} as Record<string, string>,
|
||||
aoaToSheet: vi.fn(() => ({})),
|
||||
sheetAddAoa: vi.fn(),
|
||||
saveAs: vi.fn(),
|
||||
xlsxWrite: vi.fn(() => new Uint8Array([1, 2, 3])),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,6 +32,12 @@ const messages: Record<string, string> = {
|
||||
'admin.dashboard.day': 'Day',
|
||||
'admin.dashboard.hour': 'Hour',
|
||||
'admin.usage.failedToLoadUser': 'Failed to load user',
|
||||
'usage.requestedModel': 'Requested model',
|
||||
'usage.sentUpstreamModel': 'Sent upstream model',
|
||||
'usage.upstreamResponseModel': 'Upstream response model',
|
||||
'usage.upstreamModelMismatch': 'Upstream model mismatch',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
}
|
||||
|
||||
const formatLocalDate = (date: Date): string => {
|
||||
@@ -54,10 +65,22 @@ vi.mock('@/api/admin', () => ({
|
||||
|
||||
vi.mock('@/api/admin/usage', () => ({
|
||||
adminUsageAPI: {
|
||||
list: vi.fn(),
|
||||
list: exportList,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('file-saver', () => ({ saveAs }))
|
||||
|
||||
vi.mock('xlsx', () => ({
|
||||
utils: {
|
||||
aoa_to_sheet: aoaToSheet,
|
||||
sheet_add_aoa: sheetAddAoa,
|
||||
book_new: vi.fn(() => ({})),
|
||||
book_append_sheet: vi.fn(),
|
||||
},
|
||||
write: xlsxWrite,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/ops', () => ({
|
||||
listErrorLogs,
|
||||
}))
|
||||
@@ -539,3 +562,62 @@ describe('admin UsageView ranking tab', () => {
|
||||
expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 5 }), expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin UsageView model audit export', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
list.mockReset().mockResolvedValue({ items: [], total: 0, pages: 0 })
|
||||
exportList.mockReset().mockResolvedValue({
|
||||
items: [{
|
||||
id: 1,
|
||||
created_at: '2026-08-04T00:00:00Z',
|
||||
model: 'gpt-5.6-sol',
|
||||
upstream_model: 'gpt-5.5',
|
||||
upstream_response_model: 'gpt-5.4',
|
||||
upstream_model_mismatch: true,
|
||||
request_type: 'sync',
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
duration_ms: 10,
|
||||
}],
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
getStats.mockReset().mockResolvedValue({
|
||||
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
|
||||
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
|
||||
})
|
||||
getSnapshotV2.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] })
|
||||
getModelStats.mockReset().mockResolvedValue({ models: [] })
|
||||
aoaToSheet.mockClear()
|
||||
sheetAddAoa.mockClear()
|
||||
saveAs.mockClear()
|
||||
xlsxWrite.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('exports requested, sent, response, and mismatch as separate admin columns', async () => {
|
||||
const wrapper = mountRouteFilteredUsageView()
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
await (wrapper.vm as any).exportToExcel()
|
||||
await flushPromises()
|
||||
|
||||
const headers = aoaToSheet.mock.calls[0][0][0]
|
||||
expect(headers.slice(4, 8)).toEqual([
|
||||
'Requested model',
|
||||
'Sent upstream model',
|
||||
'Upstream response model',
|
||||
'Upstream model mismatch',
|
||||
])
|
||||
const row = sheetAddAoa.mock.calls[0][1][0]
|
||||
expect(row.slice(4, 8)).toEqual(['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4', 'Yes'])
|
||||
expect(saveAs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createVideoModelPricesForm,
|
||||
serializeVideoModelPrices,
|
||||
videoModelPriceFamilyRows
|
||||
} from '../groupsVideoModelPricing'
|
||||
|
||||
describe('Grok video model pricing form', () => {
|
||||
it('provides editable rows for both canonical Grok video families', () => {
|
||||
const form = createVideoModelPricesForm()
|
||||
|
||||
expect(videoModelPriceFamilyRows(form).map(({ key }) => key)).toEqual([
|
||||
'grok-imagine-video',
|
||||
'grok-imagine-video-1.5'
|
||||
])
|
||||
expect(form['grok-imagine-video']['480p']).toBeNull()
|
||||
expect(form['grok-imagine-video-1.5']['1080p']).toBeNull()
|
||||
})
|
||||
|
||||
it('serializes only finite non-negative prices and preserves future families', () => {
|
||||
const form = createVideoModelPricesForm({
|
||||
'grok-imagine-video-2': { '1080p': 0.4 }
|
||||
})
|
||||
form['grok-imagine-video']['480p'] = 0.05
|
||||
form['grok-imagine-video']['720p'] = ''
|
||||
form['grok-imagine-video-1.5']['1080p'] = -1
|
||||
|
||||
expect(serializeVideoModelPrices(form)).toEqual({
|
||||
'grok-imagine-video': { '480p': 0.05 },
|
||||
'grok-imagine-video-2': { '1080p': 0.4 }
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips unknown model families so editing does not discard them', () => {
|
||||
const form = createVideoModelPricesForm({
|
||||
'grok-imagine-video-2': { '480p': 0.2 }
|
||||
})
|
||||
|
||||
expect(videoModelPriceFamilyRows(form).map(({ key }) => key)).toContain(
|
||||
'grok-imagine-video-2'
|
||||
)
|
||||
expect(serializeVideoModelPrices(form)).toMatchObject({
|
||||
'grok-imagine-video-2': { '480p': 0.2 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
export const grokVideoPriceResolutions = [
|
||||
{ key: '480p', label: '480p' },
|
||||
{ key: '720p', label: '720p' },
|
||||
{ key: '1080p', label: '1080p' }
|
||||
] as const
|
||||
|
||||
export const grokVideoPriceFamilies = [
|
||||
{ key: 'grok-imagine-video', label: 'grok-imagine-video' },
|
||||
{ key: 'grok-imagine-video-1.5', label: 'grok-imagine-video-1.5' }
|
||||
] as const
|
||||
|
||||
export type VideoModelPrices = Record<string, Record<string, number>>
|
||||
export type VideoModelPricesForm = Record<string, Record<string, number | string | null>>
|
||||
|
||||
function normalizeFamily(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizePrice(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const price = Number(value)
|
||||
return Number.isFinite(price) && price >= 0 ? price : null
|
||||
}
|
||||
|
||||
function emptyTiers(): Record<string, number | string | null> {
|
||||
return Object.fromEntries(grokVideoPriceResolutions.map(({ key }) => [key, null]))
|
||||
}
|
||||
|
||||
// Keep unknown families from an existing group so a future backend catalog is
|
||||
// not silently discarded when an operator edits another group setting.
|
||||
export function createVideoModelPricesForm(
|
||||
prices?: VideoModelPrices | null
|
||||
): VideoModelPricesForm {
|
||||
const form: VideoModelPricesForm = {}
|
||||
|
||||
for (const [rawFamily, rawTiers] of Object.entries(prices ?? {})) {
|
||||
const family = normalizeFamily(rawFamily)
|
||||
if (!family || !rawTiers || typeof rawTiers !== 'object') continue
|
||||
form[family] = emptyTiers()
|
||||
for (const [rawResolution, rawPrice] of Object.entries(rawTiers)) {
|
||||
const price = normalizePrice(rawPrice)
|
||||
if (price !== null) form[family][rawResolution.trim().toLowerCase()] = price
|
||||
}
|
||||
}
|
||||
|
||||
for (const { key } of grokVideoPriceFamilies) {
|
||||
form[key] ??= emptyTiers()
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
export function serializeVideoModelPrices(form: VideoModelPricesForm): VideoModelPrices {
|
||||
const result: VideoModelPrices = {}
|
||||
for (const [rawFamily, tiers] of Object.entries(form)) {
|
||||
const family = normalizeFamily(rawFamily)
|
||||
if (!family || !tiers || typeof tiers !== 'object') continue
|
||||
|
||||
const normalizedTiers: Record<string, number> = {}
|
||||
for (const [rawResolution, rawPrice] of Object.entries(tiers)) {
|
||||
const resolution = rawResolution.trim().toLowerCase()
|
||||
const price = normalizePrice(rawPrice)
|
||||
if (resolution && price !== null) normalizedTiers[resolution] = price
|
||||
}
|
||||
if (Object.keys(normalizedTiers).length > 0) result[family] = normalizedTiers
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function videoModelPriceFamilyRows(form: VideoModelPricesForm) {
|
||||
const known = new Set<string>(grokVideoPriceFamilies.map(({ key }) => key))
|
||||
const extra = Object.keys(form)
|
||||
.map(normalizeFamily)
|
||||
.filter((family) => family && !known.has(family))
|
||||
.sort()
|
||||
.map((key) => ({ key, label: key }))
|
||||
return [...grokVideoPriceFamilies, ...extra]
|
||||
}
|
||||
@@ -114,6 +114,8 @@
|
||||
<OpsErrorDetailsModal
|
||||
:show="showErrorDetails"
|
||||
:time-range="timeRange"
|
||||
:custom-start-time="customStartTime"
|
||||
:custom-end-time="customEndTime"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:error-type="errorDetailsType"
|
||||
|
||||
@@ -5,10 +5,13 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import OpsErrorLogTable from './OpsErrorLogTable.vue'
|
||||
import { opsAPI, type OpsErrorLog } from '@/api/admin/ops'
|
||||
import { buildOpsErrorTimeParams } from '../utils/opsErrorParams'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
timeRange: string
|
||||
customStartTime?: string | null
|
||||
customEndTime?: string | null
|
||||
platform?: string
|
||||
groupId?: number | null
|
||||
errorType: 'request' | 'upstream'
|
||||
@@ -103,11 +106,11 @@ async function fetchErrorLogs() {
|
||||
const params: Record<string, any> = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
time_range: props.timeRange,
|
||||
view: viewMode.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value
|
||||
}
|
||||
Object.assign(params, buildOpsErrorTimeParams(props.timeRange, props.customStartTime, props.customEndTime))
|
||||
|
||||
const platform = String(props.platform || '').trim()
|
||||
if (platform) params.platform = platform
|
||||
@@ -160,7 +163,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.timeRange, props.platform, props.groupId] as const,
|
||||
() => [props.timeRange, props.customStartTime, props.customEndTime, props.platform, props.groupId] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
page.value = 1
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildOpsErrorTimeParams } from '../opsErrorParams'
|
||||
|
||||
describe('buildOpsErrorTimeParams', () => {
|
||||
it('uses explicit timestamps for a complete custom range', () => {
|
||||
expect(buildOpsErrorTimeParams('custom', '2026-08-01T00:00:00Z', '2026-08-02T00:00:00Z')).toEqual({
|
||||
start_time: '2026-08-01T00:00:00Z',
|
||||
end_time: '2026-08-02T00:00:00Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves predefined ranges and falls back for incomplete custom ranges', () => {
|
||||
expect(buildOpsErrorTimeParams('24h')).toEqual({ time_range: '24h' })
|
||||
expect(buildOpsErrorTimeParams('custom', null, null)).toEqual({ time_range: '1h' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export function buildOpsErrorTimeParams(
|
||||
timeRange: string,
|
||||
customStartTime?: string | null,
|
||||
customEndTime?: string | null
|
||||
): Record<string, string> {
|
||||
if (timeRange === 'custom' && customStartTime && customEndTime) {
|
||||
return { start_time: customStartTime, end_time: customEndTime }
|
||||
}
|
||||
|
||||
return { time_range: timeRange === 'custom' ? '1h' : timeRange }
|
||||
}
|
||||
@@ -231,7 +231,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { formatReasoningEffort } from '@/utils/format'
|
||||
import { BILLING_MODE_IMAGE, getBillingModeLabel } from '@/utils/billingMode'
|
||||
import { getBillingModeLabel, getDisplayBillingMode as resolveDisplayBillingMode } from '@/utils/billingMode'
|
||||
import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType'
|
||||
import type {
|
||||
ApiKey,
|
||||
@@ -605,10 +605,7 @@ const getRequestTypeExportText = (log: UsageLog): string => {
|
||||
|
||||
const getDisplayBillingMode = (
|
||||
row: Pick<UsageLog, 'billing_mode' | 'image_count'> | null | undefined
|
||||
): string | null | undefined => {
|
||||
if ((row?.image_count ?? 0) > 0) return BILLING_MODE_IMAGE
|
||||
return row?.billing_mode
|
||||
}
|
||||
): string | null | undefined => resolveDisplayBillingMode(row)
|
||||
|
||||
const escapeCSVValue = (value: unknown): string => {
|
||||
if (value == null) return ''
|
||||
|
||||
Reference in New Issue
Block a user