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:
shaw
2026-08-09 12:11:35 +08:00
352 changed files with 25184 additions and 2128 deletions
+199 -1
View File
@@ -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>
+275 -1
View File
@@ -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,
);
+161
View File
@@ -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,
+4 -2
View File
@@ -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 }
}
+2 -5
View File
@@ -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 ''