mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// history.spec.ts covers useHistoryStore's model-ranking behaviour: the
|
||||
// Models tab reads a different endpoint from the other three tabs, and the
|
||||
// scope picker's model list is refreshed alongside whichever of the two is
|
||||
// active. Both must degrade independently — a failing picker list must
|
||||
// never blank a rendered chart or surface an error over it, which is the
|
||||
// asymmetry these tests pin.
|
||||
//
|
||||
// Same harness as dashboard.spec.ts: a Pinia store is plain reactive state
|
||||
// in the 'node' environment, with adminFetch (lib/api.ts) fully mocked, so
|
||||
// no real network or sessionStorage access happens.
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api')
|
||||
return { ...actual, adminFetch: vi.fn() }
|
||||
})
|
||||
|
||||
import { adminFetch } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { metricsForTab, useHistoryStore } from '@/stores/history'
|
||||
import type { AdminUsageModelsResponse, UsageHistoryResponse } from '@/types/api'
|
||||
|
||||
const mockedAdminFetch = vi.mocked(adminFetch)
|
||||
|
||||
function modelsResponse(models: AdminUsageModelsResponse['models']): AdminUsageModelsResponse {
|
||||
return { metric: 'cost', window: 'hour', models }
|
||||
}
|
||||
function seriesResponse(): UsageHistoryResponse {
|
||||
return { scope: 'total', metric: 'req', window: 'hour', points: [{ bucket: '2026082012', value: 5 }] }
|
||||
}
|
||||
|
||||
/** Routes a mocked adminFetch call by URL, so tests assert on behaviour rather than call order. */
|
||||
function routeFetch(handlers: { models?: () => unknown; series?: () => unknown }): void {
|
||||
mockedAdminFetch.mockImplementation((path: string) => {
|
||||
if (path.startsWith('/admin/api/usage/models')) {
|
||||
return Promise.resolve(handlers.models ? handlers.models() : modelsResponse([]))
|
||||
}
|
||||
return Promise.resolve(handlers.series ? handlers.series() : seriesResponse())
|
||||
})
|
||||
}
|
||||
|
||||
describe('useHistoryStore model ranking', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockedAdminFetch.mockReset()
|
||||
useAuthStore().submit('test-admin-key')
|
||||
})
|
||||
|
||||
it('asks the history endpoint for no metric on the Models tab', () => {
|
||||
// The Models tab reads the ranking endpoint instead, so requesting a
|
||||
// history metric for it would be a wasted round trip per refresh.
|
||||
expect(metricsForTab('models')).toEqual([])
|
||||
expect(metricsForTab('tokens')).toEqual(['tokin', 'tokout'])
|
||||
})
|
||||
|
||||
it('fetches the ranking, not a series, when the Models tab is active', async () => {
|
||||
routeFetch({ models: () => modelsResponse([{ id: 'uni/qwen3.8-flash-next', value: 4_100_000 }]) })
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
|
||||
await history.refresh()
|
||||
|
||||
expect(history.modelRanking).toEqual([{ id: 'uni/qwen3.8-flash-next', value: 4_100_000 }])
|
||||
const paths = mockedAdminFetch.mock.calls.map((c) => String(c[0]))
|
||||
expect(paths.some((p) => p.startsWith('/admin/api/usage/history'))).toBe(false)
|
||||
})
|
||||
|
||||
it('ranks by the selected metric, independently of the tab', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
|
||||
history.setModelMetric('tokin')
|
||||
await history.refresh()
|
||||
|
||||
const rankingCall = mockedAdminFetch.mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.find((p) => p.startsWith('/admin/api/usage/models') && p.includes('metric=tokin'))
|
||||
expect(rankingCall).toBeDefined()
|
||||
})
|
||||
|
||||
it('refreshes the picker list alongside a time series', async () => {
|
||||
routeFetch({ models: () => modelsResponse([{ id: 'alpha/a-model-1', value: 3 }]) })
|
||||
const history = useHistoryStore()
|
||||
|
||||
await history.refresh()
|
||||
|
||||
// A model that has just started receiving traffic becomes selectable
|
||||
// without a reload, even while a non-Models tab is on screen.
|
||||
expect(history.modelOptions).toEqual([{ id: 'alpha/a-model-1', value: 3 }])
|
||||
expect(history.seriesByMetric.req).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('asks the picker list for requests, so a free model is still selectable', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
|
||||
await history.fetchModelOptions()
|
||||
|
||||
const optionsCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.includes('/usage/models'))
|
||||
expect(optionsCall).toContain('metric=req')
|
||||
})
|
||||
|
||||
it('swallows a picker-list failure without touching the chart error', async () => {
|
||||
// Asserted directly on fetchModelOptions rather than through refresh:
|
||||
// inside refresh a succeeding series fetch clears `error` afterwards
|
||||
// anyway, which would let this pass even if the swallow were removed.
|
||||
routeFetch({
|
||||
models: () => {
|
||||
throw new Error('models endpoint down')
|
||||
},
|
||||
})
|
||||
const history = useHistoryStore()
|
||||
history.error = 'pre-existing chart error'
|
||||
|
||||
await history.fetchModelOptions()
|
||||
|
||||
expect(history.error).toBe('pre-existing chart error')
|
||||
expect(history.modelOptions).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a rendered chart intact when the picker list fails', async () => {
|
||||
routeFetch({
|
||||
models: () => {
|
||||
throw new Error('models endpoint down')
|
||||
},
|
||||
})
|
||||
const history = useHistoryStore()
|
||||
|
||||
await history.refresh()
|
||||
|
||||
expect(history.seriesByMetric.req).toHaveLength(1)
|
||||
expect(history.error).toBe('')
|
||||
})
|
||||
|
||||
it('surfaces a failing ranking fetch, unlike the picker list', async () => {
|
||||
mockedAdminFetch.mockImplementation((path: string) => {
|
||||
if (path.includes('limit=20')) return Promise.reject(new Error('ranking unavailable'))
|
||||
return Promise.resolve(modelsResponse([]))
|
||||
})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
|
||||
await history.refresh()
|
||||
|
||||
expect(history.error).toBe('ranking unavailable')
|
||||
})
|
||||
})
|
||||
+122
-7
@@ -2,10 +2,61 @@ import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint, UsageHistoryResponse } from '@/types/api'
|
||||
import type {
|
||||
AdminUsageModelEntry,
|
||||
AdminUsageModelsResponse,
|
||||
HistoryMetric,
|
||||
HistoryWindow,
|
||||
UsageHistoryPoint,
|
||||
UsageHistoryResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
/** The three chart tabs the Charts view offers — see metricsForTab below. */
|
||||
export type ChartTab = 'requests' | 'tokens' | 'cost'
|
||||
/**
|
||||
* The chart tabs the Charts view offers. The first three are metrics over
|
||||
* time (see metricsForTab below); "models" is the odd one out — a ranking
|
||||
* across models for a single point in time, with its own metric picked
|
||||
* separately (modelMetric below), because "which model" is the dimension
|
||||
* that tab varies rather than the metric.
|
||||
*/
|
||||
export type ChartTab = 'requests' | 'tokens' | 'cost' | 'models'
|
||||
|
||||
/**
|
||||
* The tabs that render a time series — every ChartTab except the ranking.
|
||||
* UsageChart is typed on this rather than on ChartTab so that adding
|
||||
* another ranking-style tab is a compile error there, not a chart with a
|
||||
* silently missing dataset spec.
|
||||
*/
|
||||
export type TimeSeriesTab = Exclude<ChartTab, 'models'>
|
||||
|
||||
/**
|
||||
* The metrics the Models ranking can rank by. Deliberately a single
|
||||
* metric, unlike the stacked "tokens" time-series tab: a bar's LENGTH in a
|
||||
* ranking is the comparison being made, so splitting it across two stacked
|
||||
* series would make two models with different in/out mixes visually
|
||||
* incomparable.
|
||||
*/
|
||||
export type ModelMetric = 'req' | 'tokin' | 'tokout' | 'cost'
|
||||
|
||||
/** MODEL_METRIC_LABEL is the ranking metric picker's display text. */
|
||||
export const MODEL_METRIC_LABEL: Record<ModelMetric, string> = {
|
||||
cost: 'Cost',
|
||||
req: 'Requests',
|
||||
tokin: 'Tokens in',
|
||||
tokout: 'Tokens out',
|
||||
}
|
||||
|
||||
/**
|
||||
* How many ranked models the Models tab requests. Matches the server's own
|
||||
* default (admin.go: usageModelsDefaultLimit) and stays well under its max.
|
||||
*/
|
||||
const MODEL_RANKING_LIMIT = 20
|
||||
|
||||
/**
|
||||
* How many models the SCOPE PICKER offers. Higher than the ranking's own
|
||||
* limit — the picker is a lookup, not a top-N — and capped at the server's
|
||||
* maximum (admin.go: usageModelsMaxLimit).
|
||||
*/
|
||||
const MODEL_OPTIONS_LIMIT = 100
|
||||
|
||||
/**
|
||||
* How often the current selection refetches while the Charts view is open.
|
||||
@@ -47,6 +98,10 @@ export function metricsForTab(tab: ChartTab): HistoryMetric[] {
|
||||
return ['tokin', 'tokout']
|
||||
case 'cost':
|
||||
return ['cost']
|
||||
case 'models':
|
||||
// The Models tab reads the ranking endpoint, not the history one —
|
||||
// it needs no time series, so it asks for no metric here.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +118,13 @@ export const useHistoryStore = defineStore('history', {
|
||||
scope: 'total',
|
||||
window: 'hour' as HistoryWindow,
|
||||
tab: 'requests' as ChartTab,
|
||||
/** Which metric the Models ranking ranks by — independent of `tab`. */
|
||||
modelMetric: 'cost' as ModelMetric,
|
||||
seriesByMetric: {} as Partial<Record<HistoryMetric, UsageHistoryPoint[]>>,
|
||||
/** The current Models-tab ranking. Empty means "nothing used in this window". */
|
||||
modelRanking: [] as AdminUsageModelEntry[],
|
||||
/** Models with non-zero traffic, for the scope picker's model entries. */
|
||||
modelOptions: [] as AdminUsageModelEntry[],
|
||||
loading: false,
|
||||
error: '',
|
||||
timer: undefined as ReturnType<typeof setInterval> | undefined,
|
||||
@@ -72,17 +133,31 @@ export const useHistoryStore = defineStore('history', {
|
||||
setScope(scope: string): void {
|
||||
if (scope === this.scope) return
|
||||
this.scope = scope
|
||||
void this.fetchSeries()
|
||||
void this.refresh()
|
||||
},
|
||||
setWindow(window: HistoryWindow): void {
|
||||
if (window === this.window) return
|
||||
this.window = window
|
||||
void this.fetchSeries()
|
||||
void this.refresh()
|
||||
},
|
||||
setTab(tab: ChartTab): void {
|
||||
if (tab === this.tab) return
|
||||
this.tab = tab
|
||||
void this.fetchSeries()
|
||||
void this.refresh()
|
||||
},
|
||||
setModelMetric(metric: ModelMetric): void {
|
||||
if (metric === this.modelMetric) return
|
||||
this.modelMetric = metric
|
||||
void this.refresh()
|
||||
},
|
||||
/**
|
||||
* refresh fetches whatever the CURRENT selection needs: the ranking on
|
||||
* the Models tab, a time series otherwise. The scope picker's model
|
||||
* list is refreshed alongside either, so a model that has just started
|
||||
* receiving traffic becomes selectable without a page reload.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
await Promise.all([this.tab === 'models' ? this.fetchModelRanking() : this.fetchSeries(), this.fetchModelOptions()])
|
||||
},
|
||||
async fetchSeries(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
@@ -113,9 +188,49 @@ export const useHistoryStore = defineStore('history', {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async fetchModelRanking(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await adminFetch<AdminUsageModelsResponse>(
|
||||
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}`,
|
||||
)
|
||||
this.modelRanking = res.models
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
|
||||
return
|
||||
}
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* fetchModelOptions loads the picker's model list: models with at least
|
||||
* one REQUEST in the current window. Requests, not the ranking's own
|
||||
* metric, deliberately — a model can serve traffic while reporting no
|
||||
* tokens and costing nothing, and such a model must still be
|
||||
* selectable. A failure here is swallowed rather than surfaced: it
|
||||
* degrades the picker, and must not replace a rendered chart's own
|
||||
* error (or clear it) on a background refresh.
|
||||
*/
|
||||
async fetchModelOptions(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
try {
|
||||
const res = await adminFetch<AdminUsageModelsResponse>(
|
||||
`/admin/api/usage/models?metric=req&window=${this.window}&limit=${MODEL_OPTIONS_LIMIT}`,
|
||||
)
|
||||
this.modelOptions = res.models
|
||||
} catch {
|
||||
// Intentionally ignored — see the doc comment above.
|
||||
}
|
||||
},
|
||||
startAutoRefresh(): void {
|
||||
if (this.timer !== undefined) return
|
||||
this.timer = setInterval(() => void this.fetchSeries(), REFRESH_MS)
|
||||
this.timer = setInterval(() => void this.refresh(), REFRESH_MS)
|
||||
},
|
||||
stopAutoRefresh(): void {
|
||||
if (this.timer === undefined) return
|
||||
|
||||
@@ -281,6 +281,34 @@ export interface UsageHistoryResponse {
|
||||
points: UsageHistoryPoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One ranked model in GET /admin/api/usage/models (admin.go:
|
||||
* adminUsageModelEntryView). `id` is the canonical "provider/model" — the
|
||||
* provider that actually SERVED the traffic, which is why a failover
|
||||
* target and its primary appear as two separate rows.
|
||||
*/
|
||||
export interface AdminUsageModelEntry {
|
||||
id: string
|
||||
value: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/api/usage/models (admin.go: adminUsageModelsResponse) — the
|
||||
* model ranking behind the Charts view's "Models" tab, and the source of
|
||||
* the scope picker's model list.
|
||||
*
|
||||
* ONLY MODELS WITH NON-ZERO USAGE ARE RETURNED (server-side, by operator
|
||||
* requirement): the catalog runs to hundreds of models, so neither the
|
||||
* ranking nor the picker may pad itself with idle ones. An empty `models`
|
||||
* array therefore means "nothing used in this window", never "no models
|
||||
* configured" — and a failed store read is a 503, never an empty array.
|
||||
*/
|
||||
export interface AdminUsageModelsResponse {
|
||||
metric: string
|
||||
window: string
|
||||
models: AdminUsageModelEntry[]
|
||||
}
|
||||
|
||||
/** One MCP-server or agent target's current-window request counters (admin.go: adminTargetCountersView) — requests only, no tokens or cost. */
|
||||
export interface AdminTargetCountersView {
|
||||
requestsPerMinute: number
|
||||
|
||||
Reference in New Issue
Block a user