mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 00:27:30 +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:
@@ -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