Skip to content

Commit 62b4063

Browse files
committed
feat(frontend): display upstream model in usage table and distribution charts
Show upstream model mapping (requested -> upstream) in UsageTable with arrow notation. Add requested/upstream/mapping source toggle to ModelDistributionChart with lazy loading — only fetches data when user switches tab, with per-source cache invalidation on filter changes. Include upstream_model column in Excel export and i18n for en/zh.
1 parent eeff451 commit 62b4063

8 files changed

Lines changed: 177 additions & 18 deletions

File tree

frontend/src/api/admin/dashboard.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export interface ModelStatsParams {
8181
user_id?: number
8282
api_key_id?: number
8383
model?: string
84+
model_source?: 'requested' | 'upstream' | 'mapping'
8485
account_id?: number
8586
group_id?: number
8687
request_type?: UsageRequestType
@@ -162,6 +163,7 @@ export interface UserBreakdownParams {
162163
end_date?: string
163164
group_id?: number
164165
model?: string
166+
model_source?: 'requested' | 'upstream' | 'mapping'
165167
endpoint?: string
166168
endpoint_type?: 'inbound' | 'upstream' | 'path'
167169
limit?: number

frontend/src/components/admin/usage/UsageTable.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,16 @@
2525
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
2626
</template>
2727

28-
<template #cell-model="{ value }">
29-
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
28+
<template #cell-model="{ row }">
29+
<div v-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs">
30+
<div class="break-all font-medium text-gray-900 dark:text-white">
31+
{{ row.model }}
32+
</div>
33+
<div class="break-all text-gray-500 dark:text-gray-400">
34+
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
35+
</div>
36+
</div>
37+
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
3038
</template>
3139

3240
<template #cell-reasoning_effort="{ row }">

frontend/src/components/charts/EndpointDistributionChart.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<template>
22
<div class="card p-4">
3-
<div class="mb-4 flex items-start justify-between gap-3">
3+
<div class="mb-4 flex items-center justify-between gap-3">
44
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
55
{{ title || t('usage.endpointDistribution') }}
66
</h3>
7-
<div class="flex flex-col items-end gap-2">
7+
<div class="flex flex-wrap items-center justify-end gap-2">
88
<div
99
v-if="showSourceToggle"
1010
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"

frontend/src/components/charts/ModelDistributionChart.vue

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,42 @@
66
? t('admin.dashboard.modelDistribution')
77
: t('admin.dashboard.spendingRankingTitle') }}
88
</h3>
9-
<div class="flex items-center gap-2">
9+
<div class="flex flex-wrap items-center justify-end gap-2">
10+
<div
11+
v-if="showSourceToggle"
12+
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
13+
>
14+
<button
15+
type="button"
16+
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
17+
:class="source === 'requested'
18+
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
19+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
20+
@click="emit('update:source', 'requested')"
21+
>
22+
{{ t('usage.requestedModel') }}
23+
</button>
24+
<button
25+
type="button"
26+
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
27+
:class="source === 'upstream'
28+
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
29+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
30+
@click="emit('update:source', 'upstream')"
31+
>
32+
{{ t('usage.upstreamModel') }}
33+
</button>
34+
<button
35+
type="button"
36+
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
37+
:class="source === 'mapping'
38+
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
39+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
40+
@click="emit('update:source', 'mapping')"
41+
>
42+
{{ t('usage.mapping') }}
43+
</button>
44+
</div>
1045
<div
1146
v-if="showMetricToggle"
1247
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
@@ -215,29 +250,38 @@ ChartJS.register(ArcElement, Tooltip, Legend)
215250
const { t } = useI18n()
216251
217252
type DistributionMetric = 'tokens' | 'actual_cost'
253+
type ModelSource = 'requested' | 'upstream' | 'mapping'
218254
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
219255
const props = withDefaults(defineProps<{
220256
modelStats: ModelStat[]
257+
upstreamModelStats?: ModelStat[]
258+
mappingModelStats?: ModelStat[]
259+
source?: ModelSource
221260
enableRankingView?: boolean
222261
rankingItems?: UserSpendingRankingItem[]
223262
rankingTotalActualCost?: number
224263
rankingTotalRequests?: number
225264
rankingTotalTokens?: number
226265
loading?: boolean
227266
metric?: DistributionMetric
267+
showSourceToggle?: boolean
228268
showMetricToggle?: boolean
229269
rankingLoading?: boolean
230270
rankingError?: boolean
231271
startDate?: string
232272
endDate?: string
233273
}>(), {
274+
upstreamModelStats: () => [],
275+
mappingModelStats: () => [],
276+
source: 'requested',
234277
enableRankingView: false,
235278
rankingItems: () => [],
236279
rankingTotalActualCost: 0,
237280
rankingTotalRequests: 0,
238281
rankingTotalTokens: 0,
239282
loading: false,
240283
metric: 'tokens',
284+
showSourceToggle: false,
241285
showMetricToggle: false,
242286
rankingLoading: false,
243287
rankingError: false
@@ -261,6 +305,7 @@ const toggleBreakdown = async (type: string, id: string) => {
261305
start_date: props.startDate,
262306
end_date: props.endDate,
263307
model: id,
308+
model_source: props.source,
264309
})
265310
breakdownItems.value = res.users || []
266311
} catch {
@@ -272,6 +317,7 @@ const toggleBreakdown = async (type: string, id: string) => {
272317
273318
const emit = defineEmits<{
274319
'update:metric': [value: DistributionMetric]
320+
'update:source': [value: ModelSource]
275321
'ranking-click': [item: UserSpendingRankingItem]
276322
}>()
277323
@@ -294,14 +340,19 @@ const chartColors = [
294340
]
295341
296342
const displayModelStats = computed(() => {
297-
if (!props.modelStats?.length) return []
343+
const sourceStats = props.source === 'upstream'
344+
? props.upstreamModelStats
345+
: props.source === 'mapping'
346+
? props.mappingModelStats
347+
: props.modelStats
348+
if (!sourceStats?.length) return []
298349
299350
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
300-
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey])
351+
return [...sourceStats].sort((a, b) => b[metricKey] - a[metricKey])
301352
})
302353
303354
const chartData = computed(() => {
304-
if (!props.modelStats?.length) return null
355+
if (!displayModelStats.value.length) return null
305356
306357
return {
307358
labels: displayModelStats.value.map((m) => m.model),

frontend/src/i18n/locales/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,11 +718,14 @@ export default {
718718
exporting: 'Exporting...',
719719
preparingExport: 'Preparing export...',
720720
model: 'Model',
721+
requestedModel: 'Requested',
722+
upstreamModel: 'Upstream',
721723
reasoningEffort: 'Reasoning Effort',
722724
endpoint: 'Endpoint',
723725
endpointDistribution: 'Endpoint Distribution',
724726
inbound: 'Inbound',
725727
upstream: 'Upstream',
728+
mapping: 'Mapping',
726729
path: 'Path',
727730
inboundEndpoint: 'Inbound Endpoint',
728731
upstreamEndpoint: 'Upstream Endpoint',

frontend/src/i18n/locales/zh.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,11 +723,14 @@ export default {
723723
exporting: '导出中...',
724724
preparingExport: '正在准备导出...',
725725
model: '模型',
726+
requestedModel: '请求',
727+
upstreamModel: '上游',
726728
reasoningEffort: '推理强度',
727729
endpoint: '端点',
728730
endpointDistribution: '端点分布',
729731
inbound: '入站',
730732
upstream: '上游',
733+
mapping: '映射',
731734
path: '路径',
732735
inboundEndpoint: '入站端点',
733736
upstreamEndpoint: '上游端点',

frontend/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,7 @@ export interface UsageLog {
975975
account_id: number | null
976976
request_id: string
977977
model: string
978+
upstream_model?: string | null
978979
service_tier?: string | null
979980
reasoning_effort?: string | null
980981
inbound_endpoint?: string | null

frontend/src/views/admin/UsageView.vue

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@
2424
</div>
2525
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
2626
<ModelDistributionChart
27+
v-model:source="modelDistributionSource"
2728
v-model:metric="modelDistributionMetric"
28-
:model-stats="modelStats"
29-
:loading="chartsLoading"
29+
:model-stats="requestedModelStats"
30+
:upstream-model-stats="upstreamModelStats"
31+
:mapping-model-stats="mappingModelStats"
32+
:loading="modelStatsLoading"
33+
:show-source-toggle="true"
3034
:show-metric-toggle="true"
3135
:start-date="startDate"
3236
:end-date="endDate"
@@ -115,7 +119,7 @@
115119
</template>
116120

117121
<script setup lang="ts">
118-
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
122+
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
119123
import { useI18n } from 'vue-i18n'
120124
import { saveAs } from 'file-saver'
121125
import { useRoute } from 'vue-router'
@@ -136,10 +140,17 @@ const { t } = useI18n()
136140
const appStore = useAppStore()
137141
type DistributionMetric = 'tokens' | 'actual_cost'
138142
type EndpointSource = 'inbound' | 'upstream' | 'path'
143+
type ModelDistributionSource = 'requested' | 'upstream' | 'mapping'
139144
const route = useRoute()
140145
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
141-
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour')
146+
const trendData = ref<TrendDataPoint[]>([]); const requestedModelStats = ref<ModelStat[]>([]); const upstreamModelStats = ref<ModelStat[]>([]); const mappingModelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const modelStatsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour')
142147
const modelDistributionMetric = ref<DistributionMetric>('tokens')
148+
const modelDistributionSource = ref<ModelDistributionSource>('requested')
149+
const loadedModelSources = reactive<Record<ModelDistributionSource, boolean>>({
150+
requested: false,
151+
upstream: false,
152+
mapping: false,
153+
})
143154
const groupDistributionMetric = ref<DistributionMetric>('tokens')
144155
const endpointDistributionMetric = ref<DistributionMetric>('tokens')
145156
const endpointDistributionSource = ref<EndpointSource>('inbound')
@@ -150,6 +161,7 @@ const endpointStatsLoading = ref(false)
150161
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
151162
let chartReqSeq = 0
152163
let statsReqSeq = 0
164+
let modelStatsReqSeq = 0
153165
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
154166
const cleanupDialogVisible = ref(false)
155167
// Balance history modal state
@@ -269,6 +281,68 @@ const loadStats = async () => {
269281
if (seq === statsReqSeq) endpointStatsLoading.value = false
270282
}
271283
}
284+
285+
const resetModelStatsCache = () => {
286+
requestedModelStats.value = []
287+
upstreamModelStats.value = []
288+
mappingModelStats.value = []
289+
loadedModelSources.requested = false
290+
loadedModelSources.upstream = false
291+
loadedModelSources.mapping = false
292+
}
293+
294+
const loadModelStats = async (source: ModelDistributionSource, force = false) => {
295+
if (!force && loadedModelSources[source]) {
296+
return
297+
}
298+
299+
const seq = ++modelStatsReqSeq
300+
modelStatsLoading.value = true
301+
try {
302+
const requestType = filters.value.request_type
303+
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
304+
const baseParams = {
305+
start_date: filters.value.start_date || startDate.value,
306+
end_date: filters.value.end_date || endDate.value,
307+
user_id: filters.value.user_id,
308+
model: filters.value.model,
309+
api_key_id: filters.value.api_key_id,
310+
account_id: filters.value.account_id,
311+
group_id: filters.value.group_id,
312+
request_type: requestType,
313+
stream: legacyStream === null ? undefined : legacyStream,
314+
billing_type: filters.value.billing_type,
315+
}
316+
317+
const response = await adminAPI.dashboard.getModelStats({ ...baseParams, model_source: source })
318+
319+
if (seq !== modelStatsReqSeq) return
320+
321+
const models = response.models || []
322+
if (source === 'requested') {
323+
requestedModelStats.value = models
324+
} else if (source === 'upstream') {
325+
upstreamModelStats.value = models
326+
} else {
327+
mappingModelStats.value = models
328+
}
329+
loadedModelSources[source] = true
330+
} catch (error) {
331+
if (seq !== modelStatsReqSeq) return
332+
console.error('Failed to load model stats:', error)
333+
if (source === 'requested') {
334+
requestedModelStats.value = []
335+
} else if (source === 'upstream') {
336+
upstreamModelStats.value = []
337+
} else {
338+
mappingModelStats.value = []
339+
}
340+
loadedModelSources[source] = false
341+
} finally {
342+
if (seq === modelStatsReqSeq) modelStatsLoading.value = false
343+
}
344+
}
345+
272346
const loadChartData = async () => {
273347
const seq = ++chartReqSeq
274348
chartsLoading.value = true
@@ -289,18 +363,30 @@ const loadChartData = async () => {
289363
billing_type: filters.value.billing_type,
290364
include_stats: false,
291365
include_trend: true,
292-
include_model_stats: true,
366+
include_model_stats: false,
293367
include_group_stats: true,
294368
include_users_trend: false
295369
})
296370
if (seq !== chartReqSeq) return
297371
trendData.value = snapshot.trend || []
298-
modelStats.value = snapshot.models || []
299372
groupStats.value = snapshot.groups || []
300373
} catch (error) { console.error('Failed to load chart data:', error) } finally { if (seq === chartReqSeq) chartsLoading.value = false }
301374
}
302-
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
303-
const refreshData = () => { loadLogs(); loadStats(); loadChartData() }
375+
const applyFilters = () => {
376+
pagination.page = 1
377+
resetModelStatsCache()
378+
loadLogs()
379+
loadStats()
380+
loadModelStats(modelDistributionSource.value, true)
381+
loadChartData()
382+
}
383+
const refreshData = () => {
384+
resetModelStatsCache()
385+
loadLogs()
386+
loadStats()
387+
loadModelStats(modelDistributionSource.value, true)
388+
loadChartData()
389+
}
304390
const resetFilters = () => {
305391
const range = getLast24HoursRangeDates()
306392
startDate.value = range.start
@@ -329,7 +415,7 @@ const exportToExcel = async () => {
329415
const XLSX = await import('xlsx')
330416
const headers = [
331417
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
332-
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'),
418+
t('admin.usage.account'), t('usage.model'), t('usage.upstreamModel'), t('usage.reasoningEffort'), t('admin.usage.group'),
333419
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
334420
t('usage.type'),
335421
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
@@ -348,7 +434,7 @@ const exportToExcel = async () => {
348434
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
349435
const rows = (res.items || []).map((log: AdminUsageLog) => [
350436
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
351-
formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
437+
log.upstream_model || '', formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
352438
log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log),
353439
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
354440
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
@@ -458,11 +544,16 @@ onMounted(() => {
458544
applyRouteQueryFilters()
459545
loadLogs()
460546
loadStats()
547+
loadModelStats(modelDistributionSource.value, true)
461548
window.setTimeout(() => {
462549
void loadChartData()
463550
}, 120)
464551
loadSavedColumns()
465552
document.addEventListener('click', handleColumnClickOutside)
466553
})
467554
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
555+
556+
watch(modelDistributionSource, (source) => {
557+
void loadModelStats(source)
558+
})
468559
</script>

0 commit comments

Comments
 (0)