Commit 62b40636 authored by Ethan0x0000's avatar Ethan0x0000
Browse files

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.
parent eeff451b
...@@ -81,6 +81,7 @@ export interface ModelStatsParams { ...@@ -81,6 +81,7 @@ export interface ModelStatsParams {
user_id?: number user_id?: number
api_key_id?: number api_key_id?: number
model?: string model?: string
model_source?: 'requested' | 'upstream' | 'mapping'
account_id?: number account_id?: number
group_id?: number group_id?: number
request_type?: UsageRequestType request_type?: UsageRequestType
...@@ -162,6 +163,7 @@ export interface UserBreakdownParams { ...@@ -162,6 +163,7 @@ export interface UserBreakdownParams {
end_date?: string end_date?: string
group_id?: number group_id?: number
model?: string model?: string
model_source?: 'requested' | 'upstream' | 'mapping'
endpoint?: string endpoint?: string
endpoint_type?: 'inbound' | 'upstream' | 'path' endpoint_type?: 'inbound' | 'upstream' | 'path'
limit?: number limit?: number
......
...@@ -25,8 +25,16 @@ ...@@ -25,8 +25,16 @@
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span> <span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
</template> </template>
<template #cell-model="{ value }"> <template #cell-model="{ row }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <div v-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }}
</div>
<div class="break-all text-gray-500 dark:text-gray-400">
<span class="mr-0.5"></span>{{ row.upstream_model }}
</div>
</div>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
</template> </template>
<template #cell-reasoning_effort="{ row }"> <template #cell-reasoning_effort="{ row }">
......
<template> <template>
<div class="card p-4"> <div class="card p-4">
<div class="mb-4 flex items-start justify-between gap-3"> <div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white"> <h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ title || t('usage.endpointDistribution') }} {{ title || t('usage.endpointDistribution') }}
</h3> </h3>
<div class="flex flex-col items-end gap-2"> <div class="flex flex-wrap items-center justify-end gap-2">
<div <div
v-if="showSourceToggle" v-if="showSourceToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800" class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
......
...@@ -6,7 +6,42 @@ ...@@ -6,7 +6,42 @@
? t('admin.dashboard.modelDistribution') ? t('admin.dashboard.modelDistribution')
: t('admin.dashboard.spendingRankingTitle') }} : t('admin.dashboard.spendingRankingTitle') }}
</h3> </h3>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center justify-end gap-2">
<div
v-if="showSourceToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'requested'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'requested')"
>
{{ t('usage.requestedModel') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'upstream'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'upstream')"
>
{{ t('usage.upstreamModel') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'mapping'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'mapping')"
>
{{ t('usage.mapping') }}
</button>
</div>
<div <div
v-if="showMetricToggle" v-if="showMetricToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800" class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
...@@ -215,9 +250,13 @@ ChartJS.register(ArcElement, Tooltip, Legend) ...@@ -215,9 +250,13 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n() const { t } = useI18n()
type DistributionMetric = 'tokens' | 'actual_cost' type DistributionMetric = 'tokens' | 'actual_cost'
type ModelSource = 'requested' | 'upstream' | 'mapping'
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean } type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
modelStats: ModelStat[] modelStats: ModelStat[]
upstreamModelStats?: ModelStat[]
mappingModelStats?: ModelStat[]
source?: ModelSource
enableRankingView?: boolean enableRankingView?: boolean
rankingItems?: UserSpendingRankingItem[] rankingItems?: UserSpendingRankingItem[]
rankingTotalActualCost?: number rankingTotalActualCost?: number
...@@ -225,12 +264,16 @@ const props = withDefaults(defineProps<{ ...@@ -225,12 +264,16 @@ const props = withDefaults(defineProps<{
rankingTotalTokens?: number rankingTotalTokens?: number
loading?: boolean loading?: boolean
metric?: DistributionMetric metric?: DistributionMetric
showSourceToggle?: boolean
showMetricToggle?: boolean showMetricToggle?: boolean
rankingLoading?: boolean rankingLoading?: boolean
rankingError?: boolean rankingError?: boolean
startDate?: string startDate?: string
endDate?: string endDate?: string
}>(), { }>(), {
upstreamModelStats: () => [],
mappingModelStats: () => [],
source: 'requested',
enableRankingView: false, enableRankingView: false,
rankingItems: () => [], rankingItems: () => [],
rankingTotalActualCost: 0, rankingTotalActualCost: 0,
...@@ -238,6 +281,7 @@ const props = withDefaults(defineProps<{ ...@@ -238,6 +281,7 @@ const props = withDefaults(defineProps<{
rankingTotalTokens: 0, rankingTotalTokens: 0,
loading: false, loading: false,
metric: 'tokens', metric: 'tokens',
showSourceToggle: false,
showMetricToggle: false, showMetricToggle: false,
rankingLoading: false, rankingLoading: false,
rankingError: false rankingError: false
...@@ -261,6 +305,7 @@ const toggleBreakdown = async (type: string, id: string) => { ...@@ -261,6 +305,7 @@ const toggleBreakdown = async (type: string, id: string) => {
start_date: props.startDate, start_date: props.startDate,
end_date: props.endDate, end_date: props.endDate,
model: id, model: id,
model_source: props.source,
}) })
breakdownItems.value = res.users || [] breakdownItems.value = res.users || []
} catch { } catch {
...@@ -272,6 +317,7 @@ const toggleBreakdown = async (type: string, id: string) => { ...@@ -272,6 +317,7 @@ const toggleBreakdown = async (type: string, id: string) => {
const emit = defineEmits<{ const emit = defineEmits<{
'update:metric': [value: DistributionMetric] 'update:metric': [value: DistributionMetric]
'update:source': [value: ModelSource]
'ranking-click': [item: UserSpendingRankingItem] 'ranking-click': [item: UserSpendingRankingItem]
}>() }>()
...@@ -294,14 +340,19 @@ const chartColors = [ ...@@ -294,14 +340,19 @@ const chartColors = [
] ]
const displayModelStats = computed(() => { const displayModelStats = computed(() => {
if (!props.modelStats?.length) return [] const sourceStats = props.source === 'upstream'
? props.upstreamModelStats
: props.source === 'mapping'
? props.mappingModelStats
: props.modelStats
if (!sourceStats?.length) return []
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens' const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey]) return [...sourceStats].sort((a, b) => b[metricKey] - a[metricKey])
}) })
const chartData = computed(() => { const chartData = computed(() => {
if (!props.modelStats?.length) return null if (!displayModelStats.value.length) return null
return { return {
labels: displayModelStats.value.map((m) => m.model), labels: displayModelStats.value.map((m) => m.model),
......
...@@ -718,11 +718,14 @@ export default { ...@@ -718,11 +718,14 @@ export default {
exporting: 'Exporting...', exporting: 'Exporting...',
preparingExport: 'Preparing export...', preparingExport: 'Preparing export...',
model: 'Model', model: 'Model',
requestedModel: 'Requested',
upstreamModel: 'Upstream',
reasoningEffort: 'Reasoning Effort', reasoningEffort: 'Reasoning Effort',
endpoint: 'Endpoint', endpoint: 'Endpoint',
endpointDistribution: 'Endpoint Distribution', endpointDistribution: 'Endpoint Distribution',
inbound: 'Inbound', inbound: 'Inbound',
upstream: 'Upstream', upstream: 'Upstream',
mapping: 'Mapping',
path: 'Path', path: 'Path',
inboundEndpoint: 'Inbound Endpoint', inboundEndpoint: 'Inbound Endpoint',
upstreamEndpoint: 'Upstream Endpoint', upstreamEndpoint: 'Upstream Endpoint',
......
...@@ -723,11 +723,14 @@ export default { ...@@ -723,11 +723,14 @@ export default {
exporting: '导出中...', exporting: '导出中...',
preparingExport: '正在准备导出...', preparingExport: '正在准备导出...',
model: '模型', model: '模型',
requestedModel: '请求',
upstreamModel: '上游',
reasoningEffort: '推理强度', reasoningEffort: '推理强度',
endpoint: '端点', endpoint: '端点',
endpointDistribution: '端点分布', endpointDistribution: '端点分布',
inbound: '入站', inbound: '入站',
upstream: '上游', upstream: '上游',
mapping: '映射',
path: '路径', path: '路径',
inboundEndpoint: '入站端点', inboundEndpoint: '入站端点',
upstreamEndpoint: '上游端点', upstreamEndpoint: '上游端点',
......
...@@ -975,6 +975,7 @@ export interface UsageLog { ...@@ -975,6 +975,7 @@ export interface UsageLog {
account_id: number | null account_id: number | null
request_id: string request_id: string
model: string model: string
upstream_model?: string | null
service_tier?: string | null service_tier?: string | null
reasoning_effort?: string | null reasoning_effort?: string | null
inbound_endpoint?: string | null inbound_endpoint?: string | null
......
...@@ -24,9 +24,13 @@ ...@@ -24,9 +24,13 @@
</div> </div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ModelDistributionChart <ModelDistributionChart
v-model:source="modelDistributionSource"
v-model:metric="modelDistributionMetric" v-model:metric="modelDistributionMetric"
:model-stats="modelStats" :model-stats="requestedModelStats"
:loading="chartsLoading" :upstream-model-stats="upstreamModelStats"
:mapping-model-stats="mappingModelStats"
:loading="modelStatsLoading"
:show-source-toggle="true"
:show-metric-toggle="true" :show-metric-toggle="true"
:start-date="startDate" :start-date="startDate"
:end-date="endDate" :end-date="endDate"
...@@ -115,7 +119,7 @@ ...@@ -115,7 +119,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
...@@ -136,10 +140,17 @@ const { t } = useI18n() ...@@ -136,10 +140,17 @@ const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
type DistributionMetric = 'tokens' | 'actual_cost' type DistributionMetric = 'tokens' | 'actual_cost'
type EndpointSource = 'inbound' | 'upstream' | 'path' type EndpointSource = 'inbound' | 'upstream' | 'path'
type ModelDistributionSource = 'requested' | 'upstream' | 'mapping'
const route = useRoute() const route = useRoute()
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false) const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour') 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')
const modelDistributionMetric = ref<DistributionMetric>('tokens') const modelDistributionMetric = ref<DistributionMetric>('tokens')
const modelDistributionSource = ref<ModelDistributionSource>('requested')
const loadedModelSources = reactive<Record<ModelDistributionSource, boolean>>({
requested: false,
upstream: false,
mapping: false,
})
const groupDistributionMetric = ref<DistributionMetric>('tokens') const groupDistributionMetric = ref<DistributionMetric>('tokens')
const endpointDistributionMetric = ref<DistributionMetric>('tokens') const endpointDistributionMetric = ref<DistributionMetric>('tokens')
const endpointDistributionSource = ref<EndpointSource>('inbound') const endpointDistributionSource = ref<EndpointSource>('inbound')
...@@ -150,6 +161,7 @@ const endpointStatsLoading = ref(false) ...@@ -150,6 +161,7 @@ const endpointStatsLoading = ref(false)
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
let chartReqSeq = 0 let chartReqSeq = 0
let statsReqSeq = 0 let statsReqSeq = 0
let modelStatsReqSeq = 0
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' }) const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
const cleanupDialogVisible = ref(false) const cleanupDialogVisible = ref(false)
// Balance history modal state // Balance history modal state
...@@ -269,6 +281,68 @@ const loadStats = async () => { ...@@ -269,6 +281,68 @@ const loadStats = async () => {
if (seq === statsReqSeq) endpointStatsLoading.value = false if (seq === statsReqSeq) endpointStatsLoading.value = false
} }
} }
const resetModelStatsCache = () => {
requestedModelStats.value = []
upstreamModelStats.value = []
mappingModelStats.value = []
loadedModelSources.requested = false
loadedModelSources.upstream = false
loadedModelSources.mapping = false
}
const loadModelStats = async (source: ModelDistributionSource, force = false) => {
if (!force && loadedModelSources[source]) {
return
}
const seq = ++modelStatsReqSeq
modelStatsLoading.value = true
try {
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const baseParams = {
start_date: filters.value.start_date || startDate.value,
end_date: filters.value.end_date || endDate.value,
user_id: filters.value.user_id,
model: filters.value.model,
api_key_id: filters.value.api_key_id,
account_id: filters.value.account_id,
group_id: filters.value.group_id,
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
}
const response = await adminAPI.dashboard.getModelStats({ ...baseParams, model_source: source })
if (seq !== modelStatsReqSeq) return
const models = response.models || []
if (source === 'requested') {
requestedModelStats.value = models
} else if (source === 'upstream') {
upstreamModelStats.value = models
} else {
mappingModelStats.value = models
}
loadedModelSources[source] = true
} catch (error) {
if (seq !== modelStatsReqSeq) return
console.error('Failed to load model stats:', error)
if (source === 'requested') {
requestedModelStats.value = []
} else if (source === 'upstream') {
upstreamModelStats.value = []
} else {
mappingModelStats.value = []
}
loadedModelSources[source] = false
} finally {
if (seq === modelStatsReqSeq) modelStatsLoading.value = false
}
}
const loadChartData = async () => { const loadChartData = async () => {
const seq = ++chartReqSeq const seq = ++chartReqSeq
chartsLoading.value = true chartsLoading.value = true
...@@ -289,18 +363,30 @@ const loadChartData = async () => { ...@@ -289,18 +363,30 @@ const loadChartData = async () => {
billing_type: filters.value.billing_type, billing_type: filters.value.billing_type,
include_stats: false, include_stats: false,
include_trend: true, include_trend: true,
include_model_stats: true, include_model_stats: false,
include_group_stats: true, include_group_stats: true,
include_users_trend: false include_users_trend: false
}) })
if (seq !== chartReqSeq) return if (seq !== chartReqSeq) return
trendData.value = snapshot.trend || [] trendData.value = snapshot.trend || []
modelStats.value = snapshot.models || []
groupStats.value = snapshot.groups || [] groupStats.value = snapshot.groups || []
} catch (error) { console.error('Failed to load chart data:', error) } finally { if (seq === chartReqSeq) chartsLoading.value = false } } catch (error) { console.error('Failed to load chart data:', error) } finally { if (seq === chartReqSeq) chartsLoading.value = false }
} }
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() } const applyFilters = () => {
const refreshData = () => { loadLogs(); loadStats(); loadChartData() } pagination.page = 1
resetModelStatsCache()
loadLogs()
loadStats()
loadModelStats(modelDistributionSource.value, true)
loadChartData()
}
const refreshData = () => {
resetModelStatsCache()
loadLogs()
loadStats()
loadModelStats(modelDistributionSource.value, true)
loadChartData()
}
const resetFilters = () => { const resetFilters = () => {
const range = getLast24HoursRangeDates() const range = getLast24HoursRangeDates()
startDate.value = range.start startDate.value = range.start
...@@ -329,7 +415,7 @@ const exportToExcel = async () => { ...@@ -329,7 +415,7 @@ const exportToExcel = async () => {
const XLSX = await import('xlsx') const XLSX = await import('xlsx')
const headers = [ const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'), t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'), t('admin.usage.account'), t('usage.model'), t('usage.upstreamModel'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'), t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
t('usage.type'), t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'), t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
...@@ -348,7 +434,7 @@ const exportToExcel = async () => { ...@@ -348,7 +434,7 @@ const exportToExcel = async () => {
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total } if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [ const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model, log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', log.upstream_model || '', formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log), log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log),
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens, 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', log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
...@@ -458,6 +544,7 @@ onMounted(() => { ...@@ -458,6 +544,7 @@ onMounted(() => {
applyRouteQueryFilters() applyRouteQueryFilters()
loadLogs() loadLogs()
loadStats() loadStats()
loadModelStats(modelDistributionSource.value, true)
window.setTimeout(() => { window.setTimeout(() => {
void loadChartData() void loadChartData()
}, 120) }, 120)
...@@ -465,4 +552,8 @@ onMounted(() => { ...@@ -465,4 +552,8 @@ onMounted(() => {
document.addEventListener('click', handleColumnClickOutside) document.addEventListener('click', handleColumnClickOutside)
}) })
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) }) onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
watch(modelDistributionSource, (source) => {
void loadModelStats(source)
})
</script> </script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment