feat(admin): add per-model usage statistics to the charts section

The Charts view could break usage down by user, group or total, but not
by model. Per-model data existed only as day-window attempt and failure
counts, which measure availability rather than delivered usage.

A served model now accumulates the same four metrics a user or group
does. The scope kind is "model" and its id is the canonical
"provider/model", so a request that fails over counts against the
provider that answered it, and one bare id served by two providers ranks
as two rows.

The request counter is written after the response, in the batch that
already writes tokens and cost. That is the first point at which the
serving model is known, so no extra round trip is needed and admission
never double counts.

GET /admin/api/usage/models ranks models for one metric and window. It
returns only models with non-zero usage, by operator requirement: the
catalog runs to hundreds of models and a padded ranking is unreadable.
The read stays one storeGetMulti over the catalog.

The Charts view gains a Models tab with its own metric picker, and the
scope picker gains the models that have traffic.
This commit is contained in:
2026-08-27 23:30:22 +01:00
parent bcd7c32c3d
commit 4bb49b4fe9
15 changed files with 1236 additions and 53 deletions
+72 -6
View File
@@ -11,11 +11,19 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import ModelUsageChart from '@/components/ModelUsageChart.vue'
import UsageChart from '@/components/UsageChart.vue'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { groupMatches, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard'
import { type ChartTab, useHistoryStore, WINDOW_LABEL } from '@/stores/history'
import {
type ChartTab,
MODEL_METRIC_LABEL,
type ModelMetric,
type TimeSeriesTab,
useHistoryStore,
WINDOW_LABEL,
} from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
const dashboard = useDashboardStore()
@@ -44,10 +52,41 @@ const scopeOptions = computed(() => {
!hasScopeQuery.value || groupMatches(g, allUsers, normalizedScopeQuery.value) || `group:${g.id}` === history.scope,
)
.map((g) => ({ value: `group:${g.id}`, label: g.id }))
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users]
// Models come from the ranking endpoint, which returns only models with
// non-zero traffic (operator requirement: the catalog is hundreds of
// models, so the picker must never list idle ones). Matched on the plain
// id rather than through lib/usage-search's helpers — those match a
// user's or group's own shape, and a model entry has neither.
const models = (history.modelOptions ?? [])
.filter(
(m) =>
!hasScopeQuery.value ||
m.id.toLowerCase().includes(normalizedScopeQuery.value) ||
`model:${m.id}` === history.scope,
)
.map((m) => ({ value: `model:${m.id}`, label: m.id }))
// A model already being charted stays in the list even once it drops out
// of modelOptions entirely — which happens the moment it goes idle for
// the selected window, since that list is non-zero-only. Without this,
// the watch below would read it as a vanished scope and force-switch the
// reader to "total" mid-look, for the ordinary reason that a model
// simply stopped receiving traffic.
if (history.scope.startsWith('model:') && !models.some((m) => m.value === history.scope)) {
models.push({ value: history.scope, label: history.scope.slice('model:'.length) })
}
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users, ...models]
})
const windows: HistoryWindow[] = ['hour', 'day', 'month']
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
/**
* UsageChart only ever renders on a time-series tab (the template's v-else
* below), but a `v-else` narrows nothing for the type checker — this does.
* The 'requests' stand-in is never displayed: it is the value handed over
* on the one tab where the component is not rendered at all.
*/
const timeSeriesTab = computed<TimeSeriesTab>(() => (history.tab === 'models' ? 'requests' : history.tab))
function onTabChange(value: string | number): void {
history.setTab(value as ChartTab)
@@ -55,9 +94,12 @@ function onTabChange(value: string | number): void {
function onScopeChange(value: unknown): void {
if (typeof value === 'string') history.setScope(value)
}
function onModelMetricChange(value: unknown): void {
if (typeof value === 'string') history.setModelMetric(value as ModelMetric)
}
onMounted(() => {
void history.fetchSeries()
void history.refresh()
history.startAutoRefresh()
})
onUnmounted(() => {
@@ -79,9 +121,24 @@ watch(scopeOptions, (options) => {
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Usage charts</CardTitle>
<div class="flex flex-wrap items-center gap-3">
<SearchInput v-model="scopeQuery" placeholder="Filter users or groups" class="w-56" />
<SearchInput v-model="scopeQuery" placeholder="Filter users, groups or models" class="w-56" />
<Select :model-value="history.scope" @update:model-value="onScopeChange">
<Select
v-if="history.tab === 'models'"
:model-value="history.modelMetric"
@update:model-value="onModelMetricChange"
>
<SelectTrigger class="w-40" aria-label="Rank models by">
<SelectValue placeholder="Rank by" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in modelMetrics" :key="m" :value="m">
{{ MODEL_METRIC_LABEL[m] }}
</SelectItem>
</SelectContent>
</Select>
<Select v-else :model-value="history.scope" @update:model-value="onScopeChange">
<SelectTrigger class="w-56">
<SelectValue placeholder="Scope" />
</SelectTrigger>
@@ -105,11 +162,20 @@ watch(scopeOptions, (options) => {
<TabsTrigger value="requests">Requests</TabsTrigger>
<TabsTrigger value="tokens">Tokens</TabsTrigger>
<TabsTrigger value="cost">Cost</TabsTrigger>
<TabsTrigger value="models">Models</TabsTrigger>
</TabsList>
</Tabs>
<p v-if="history.error" class="text-sm text-destructive">{{ history.error }}</p>
<UsageChart :tab="history.tab" :window="history.window" :series-by-metric="history.seriesByMetric" />
<template v-if="history.tab === 'models'">
<p class="text-sm text-muted-foreground">
Models with usage in the selected window, ranked by
{{ MODEL_METRIC_LABEL[history.modelMetric].toLowerCase() }}. Each row is the provider that served the
traffic, so a model reached through more than one provider appears once per provider.
</p>
<ModelUsageChart :metric="history.modelMetric" :models="history.modelRanking" />
</template>
<UsageChart v-else :tab="timeSeriesTab" :window="history.window" :series-by-metric="history.seriesByMetric" />
</CardContent>
</Card>
</template>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatCompactCount, formatExactInt } from '@/lib/format'
import { MODEL_METRIC_LABEL, type ModelMetric } from '@/stores/history'
import type { AdminUsageModelEntry } from '@/types/api'
const props = defineProps<{
metric: ModelMetric
models: AdminUsageModelEntry[]
}>()
const { foreground, mutedForeground, border } = useThemeColors()
/**
* One token per rankable metric, reusing the SAME --chart-* custom
* properties the time-series chart uses (UsageChart.vue's DATASET_SPECS),
* so a model's bar is the colour that metric already has elsewhere in the
* view rather than a second, conflicting palette.
*/
const METRIC_COLOR: Record<ModelMetric, string> = {
req: '--chart-requests',
tokin: '--chart-tokens-in',
tokout: '--chart-tokens-out',
cost: '--chart-cost',
}
/** Cost is stored as micro-USD (limits.go's account/history convention); every other metric is already the display unit. */
function toDisplayValue(metric: ModelMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw
}
/** resolveToken reads a --chart-* custom property from :root, live — same approach (and same reason) as UsageChart.vue. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
/**
* The server already returns the ranking sorted, descending, and capped —
* this component neither re-sorts nor re-filters it. Chart.js draws the
* first category at the TOP of a horizontal bar chart, so the busiest
* model lands where a reader looks first, with no reversal needed.
*/
const chartData = computed<ChartData<'bar'>>(() => {
void foreground.value // re-run on a prefers-color-scheme flip (see UsageChart.vue)
return {
labels: props.models.map((m) => m.id),
datasets: [
{
label: MODEL_METRIC_LABEL[props.metric],
backgroundColor: resolveToken(METRIC_COLOR[props.metric]),
data: props.models.map((m) => toDisplayValue(props.metric, m.value)),
},
],
}
})
/**
* Height grows with the row count so bars keep a readable thickness
* instead of compressing into hairlines at 20 models, and so a two-model
* ranking does not stretch each bar across a third of the screen.
*/
const chartHeight = computed<string>(() => `${Math.max(180, props.models.length * 28 + 60)}px`)
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
// Zero-based, honest axis: bar length is the whole comparison here.
beginAtZero: true,
grid: { color: border.value },
ticks: {
color: mutedForeground.value,
callback: (value) => (props.metric === 'cost' ? `$${value}` : formatCompactCount(Number(value))),
},
},
y: {
grid: { display: false },
ticks: { color: mutedForeground.value, autoSkip: false },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
// Compact figure plus the exact one, matching UsageChart.vue's
// tooltip contract — a tooltip has room for both, and precision
// must never be lost to the axis's compact form.
label: (ctx) => {
const value = ctx.parsed.x ?? 0
if (props.metric === 'cost') return `${MODEL_METRIC_LABEL[props.metric]}: $${value.toFixed(4)}`
return `${MODEL_METRIC_LABEL[props.metric]}: ${formatCompactCount(value)} (${formatExactInt(value)})`
},
},
},
},
}))
</script>
<template>
<p v-if="models.length === 0" class="py-8 text-center text-sm text-muted-foreground">
No model recorded any usage in this window.
</p>
<div v-else class="w-full" :style="{ height: chartHeight }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+3 -3
View File
@@ -6,11 +6,11 @@ import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatBucketLabel, formatCompactCount, formatExactInt } from '@/lib/format'
import type { ChartTab } from '@/stores/history'
import type { TimeSeriesTab } from '@/stores/history'
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
const props = defineProps<{
tab: ChartTab
tab: TimeSeriesTab
window: HistoryWindow
seriesByMetric: Partial<Record<HistoryMetric, UsageHistoryPoint[]>>
}>()
@@ -18,7 +18,7 @@ const props = defineProps<{
const { foreground, mutedForeground, border } = useThemeColors()
/** One dataset spec per tab: which metric(s), its label, and its token color. */
const DATASET_SPECS: Record<ChartTab, { metric: HistoryMetric; label: string; color: string }[]> = {
const DATASET_SPECS: Record<TimeSeriesTab, { metric: HistoryMetric; label: string; color: string }[]> = {
requests: [{ metric: 'req', label: 'Requests', color: '--chart-requests' }],
tokens: [
{ metric: 'tokin', label: 'Tokens in', color: '--chart-tokens-in' },