mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
Merge pull request #1097 from Ethan0x0000/pr/upstream-model-tracking
feat(usage): 新增 upstream_model 追踪,支持按模型来源统计与展示
This commit is contained in:
@@ -81,6 +81,7 @@ export interface ModelStatsParams {
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
model?: string
|
||||
model_source?: 'requested' | 'upstream' | 'mapping'
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
@@ -162,6 +163,7 @@ export interface UserBreakdownParams {
|
||||
end_date?: string
|
||||
group_id?: number
|
||||
model?: string
|
||||
model_source?: 'requested' | 'upstream' | 'mapping'
|
||||
endpoint?: string
|
||||
endpoint_type?: 'inbound' | 'upstream' | 'path'
|
||||
limit?: number
|
||||
|
||||
@@ -25,8 +25,16 @@
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-model="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<template #cell-model="{ row }">
|
||||
<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 #cell-reasoning_effort="{ row }">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<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">
|
||||
{{ title || t('usage.endpointDistribution') }}
|
||||
</h3>
|
||||
<div class="flex flex-col items-end 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"
|
||||
|
||||
@@ -6,7 +6,42 @@
|
||||
? t('admin.dashboard.modelDistribution')
|
||||
: t('admin.dashboard.spendingRankingTitle') }}
|
||||
</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
|
||||
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"
|
||||
@@ -215,9 +250,13 @@ ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
const { t } = useI18n()
|
||||
|
||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||
type ModelSource = 'requested' | 'upstream' | 'mapping'
|
||||
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
|
||||
const props = withDefaults(defineProps<{
|
||||
modelStats: ModelStat[]
|
||||
upstreamModelStats?: ModelStat[]
|
||||
mappingModelStats?: ModelStat[]
|
||||
source?: ModelSource
|
||||
enableRankingView?: boolean
|
||||
rankingItems?: UserSpendingRankingItem[]
|
||||
rankingTotalActualCost?: number
|
||||
@@ -225,12 +264,16 @@ const props = withDefaults(defineProps<{
|
||||
rankingTotalTokens?: number
|
||||
loading?: boolean
|
||||
metric?: DistributionMetric
|
||||
showSourceToggle?: boolean
|
||||
showMetricToggle?: boolean
|
||||
rankingLoading?: boolean
|
||||
rankingError?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}>(), {
|
||||
upstreamModelStats: () => [],
|
||||
mappingModelStats: () => [],
|
||||
source: 'requested',
|
||||
enableRankingView: false,
|
||||
rankingItems: () => [],
|
||||
rankingTotalActualCost: 0,
|
||||
@@ -238,6 +281,7 @@ const props = withDefaults(defineProps<{
|
||||
rankingTotalTokens: 0,
|
||||
loading: false,
|
||||
metric: 'tokens',
|
||||
showSourceToggle: false,
|
||||
showMetricToggle: false,
|
||||
rankingLoading: false,
|
||||
rankingError: false
|
||||
@@ -261,6 +305,7 @@ const toggleBreakdown = async (type: string, id: string) => {
|
||||
start_date: props.startDate,
|
||||
end_date: props.endDate,
|
||||
model: id,
|
||||
model_source: props.source,
|
||||
})
|
||||
breakdownItems.value = res.users || []
|
||||
} catch {
|
||||
@@ -272,6 +317,7 @@ const toggleBreakdown = async (type: string, id: string) => {
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:metric': [value: DistributionMetric]
|
||||
'update:source': [value: ModelSource]
|
||||
'ranking-click': [item: UserSpendingRankingItem]
|
||||
}>()
|
||||
|
||||
@@ -294,14 +340,19 @@ const chartColors = [
|
||||
]
|
||||
|
||||
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'
|
||||
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey])
|
||||
return [...sourceStats].sort((a, b) => b[metricKey] - a[metricKey])
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.modelStats?.length) return null
|
||||
if (!displayModelStats.value.length) return null
|
||||
|
||||
return {
|
||||
labels: displayModelStats.value.map((m) => m.model),
|
||||
|
||||
@@ -718,11 +718,14 @@ export default {
|
||||
exporting: 'Exporting...',
|
||||
preparingExport: 'Preparing export...',
|
||||
model: 'Model',
|
||||
requestedModel: 'Requested',
|
||||
upstreamModel: 'Upstream',
|
||||
reasoningEffort: 'Reasoning Effort',
|
||||
endpoint: 'Endpoint',
|
||||
endpointDistribution: 'Endpoint Distribution',
|
||||
inbound: 'Inbound',
|
||||
upstream: 'Upstream',
|
||||
mapping: 'Mapping',
|
||||
path: 'Path',
|
||||
inboundEndpoint: 'Inbound Endpoint',
|
||||
upstreamEndpoint: 'Upstream Endpoint',
|
||||
|
||||
@@ -723,11 +723,14 @@ export default {
|
||||
exporting: '导出中...',
|
||||
preparingExport: '正在准备导出...',
|
||||
model: '模型',
|
||||
requestedModel: '请求',
|
||||
upstreamModel: '上游',
|
||||
reasoningEffort: '推理强度',
|
||||
endpoint: '端点',
|
||||
endpointDistribution: '端点分布',
|
||||
inbound: '入站',
|
||||
upstream: '上游',
|
||||
mapping: '映射',
|
||||
path: '路径',
|
||||
inboundEndpoint: '入站端点',
|
||||
upstreamEndpoint: '上游端点',
|
||||
|
||||
@@ -977,6 +977,7 @@ export interface UsageLog {
|
||||
account_id: number | null
|
||||
request_id: string
|
||||
model: string
|
||||
upstream_model?: string | null
|
||||
service_tier?: string | null
|
||||
reasoning_effort?: string | null
|
||||
inbound_endpoint?: string | null
|
||||
|
||||
@@ -24,9 +24,13 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<ModelDistributionChart
|
||||
v-model:source="modelDistributionSource"
|
||||
v-model:metric="modelDistributionMetric"
|
||||
:model-stats="modelStats"
|
||||
:loading="chartsLoading"
|
||||
:model-stats="requestedModelStats"
|
||||
:upstream-model-stats="upstreamModelStats"
|
||||
:mapping-model-stats="mappingModelStats"
|
||||
:loading="modelStatsLoading"
|
||||
:show-source-toggle="true"
|
||||
:show-metric-toggle="true"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
@@ -115,7 +119,7 @@
|
||||
</template>
|
||||
|
||||
<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 { saveAs } from 'file-saver'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -136,10 +140,17 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||
type EndpointSource = 'inbound' | 'upstream' | 'path'
|
||||
type ModelDistributionSource = 'requested' | 'upstream' | 'mapping'
|
||||
const route = useRoute()
|
||||
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 modelDistributionSource = ref<ModelDistributionSource>('requested')
|
||||
const loadedModelSources = reactive<Record<ModelDistributionSource, boolean>>({
|
||||
requested: false,
|
||||
upstream: false,
|
||||
mapping: false,
|
||||
})
|
||||
const groupDistributionMetric = ref<DistributionMetric>('tokens')
|
||||
const endpointDistributionMetric = ref<DistributionMetric>('tokens')
|
||||
const endpointDistributionSource = ref<EndpointSource>('inbound')
|
||||
@@ -150,6 +161,7 @@ const endpointStatsLoading = ref(false)
|
||||
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
|
||||
let chartReqSeq = 0
|
||||
let statsReqSeq = 0
|
||||
let modelStatsReqSeq = 0
|
||||
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
|
||||
const cleanupDialogVisible = ref(false)
|
||||
// Balance history modal state
|
||||
@@ -269,6 +281,68 @@ const loadStats = async () => {
|
||||
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 seq = ++chartReqSeq
|
||||
chartsLoading.value = true
|
||||
@@ -289,18 +363,30 @@ const loadChartData = async () => {
|
||||
billing_type: filters.value.billing_type,
|
||||
include_stats: false,
|
||||
include_trend: true,
|
||||
include_model_stats: true,
|
||||
include_model_stats: false,
|
||||
include_group_stats: true,
|
||||
include_users_trend: false
|
||||
})
|
||||
if (seq !== chartReqSeq) return
|
||||
trendData.value = snapshot.trend || []
|
||||
modelStats.value = snapshot.models || []
|
||||
groupStats.value = snapshot.groups || []
|
||||
} 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 refreshData = () => { loadLogs(); loadStats(); loadChartData() }
|
||||
const applyFilters = () => {
|
||||
pagination.page = 1
|
||||
resetModelStatsCache()
|
||||
loadLogs()
|
||||
loadStats()
|
||||
loadModelStats(modelDistributionSource.value, true)
|
||||
loadChartData()
|
||||
}
|
||||
const refreshData = () => {
|
||||
resetModelStatsCache()
|
||||
loadLogs()
|
||||
loadStats()
|
||||
loadModelStats(modelDistributionSource.value, true)
|
||||
loadChartData()
|
||||
}
|
||||
const resetFilters = () => {
|
||||
const range = getLast24HoursRangeDates()
|
||||
startDate.value = range.start
|
||||
@@ -329,7 +415,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.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.type'),
|
||||
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
|
||||
@@ -348,7 +434,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,
|
||||
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.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',
|
||||
@@ -458,6 +544,7 @@ onMounted(() => {
|
||||
applyRouteQueryFilters()
|
||||
loadLogs()
|
||||
loadStats()
|
||||
loadModelStats(modelDistributionSource.value, true)
|
||||
window.setTimeout(() => {
|
||||
void loadChartData()
|
||||
}, 120)
|
||||
@@ -465,4 +552,8 @@ onMounted(() => {
|
||||
document.addEventListener('click', handleColumnClickOutside)
|
||||
})
|
||||
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
|
||||
|
||||
watch(modelDistributionSource, (source) => {
|
||||
void loadModelStats(source)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user