feat(webui): list every model with full usage on the Models tab

The Models tab ranks by requests by default and asks the server for
per-model detail. A table under the chart lists requests, tokens in,
tokens out and cost for each model. A model with no recorded cost that
is marked free shows a "free" badge instead of $0. The CSV export
includes every column and the free flag. admin_assets_gen.go is
regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 13:40:08 +01:00
parent 3e05a79c0b
commit 782ea43b02
12 changed files with 471 additions and 35 deletions
+6 -6
View File
File diff suppressed because one or more lines are too long
+35 -15
View File
@@ -14,11 +14,13 @@ import {
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import CsvExportButton from '@/components/CsvExportButton.vue'
import DataTable from '@/components/DataTable.vue'
import ModelUsageChart from '@/components/ModelUsageChart.vue'
import UsageChart from '@/components/UsageChart.vue'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { filterModelsByPrefix } from '@/lib/model-filter'
import { modelRankingCsv } from '@/lib/usage-csv'
import { modelTableColumns } from '@/lib/model-table-columns'
import { modelDetailCsv } from '@/lib/usage-csv'
import { groupMatches, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard'
import {
@@ -87,6 +89,15 @@ const scopeOptions = computed(() => {
const windows: HistoryWindow[] = ['hour', 'day', 'month']
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
/**
* modelColumns is a stateless ColumnDef set (lib/model-table-columns.ts),
* built once rather than per-render — the same convention
* TargetsView.vue's own `columns` constant follows for its identical
* reason (no per-row closures capture component state, so there is
* nothing to rebuild).
*/
const modelColumns = modelTableColumns()
// --- Models-tab prefix filter (F6, dashboard-plan.md) ---
//
// history.modelFilter is set by ProvidersView.vue's provider-header link
@@ -101,22 +112,15 @@ const filteredModelRanking = computed(() => filterModelsByPrefix(history.modelRa
/**
* Exports exactly the ranking rows on screen (filter applied, server order
* kept). P6 review fix: passes the resolved DISPLAY label constants
* (MODEL_METRIC_LABEL/WINDOW_LABEL), not the raw enum values
* ("cost"/"hour") modelRankingCsv's own doc comment always said the caller
* should — the header used to read the literal "cost (hour, span 24)"
* with no stated unit. `isCostMetric` states whether the CURRENTLY ranked
* metric is cost, so modelRankingCsv can convert the raw micro-USD `value`
* to a plain decimal USD number and name the unit in the header.
* kept). free-models-plan.md: every ranking fetch now requests detail=1
* (stores/history.ts's fetchModelRanking), so the export carries every
* per-row figure (requests/tokens in/tokens out/cost/free) via
* modelDetailCsv, not just the single metric currently being ranked by —
* matching the on-screen table (lib/model-table-columns.ts) exactly,
* regardless of which metric history.modelMetric happens to be.
*/
function modelsCsv(): string {
return modelRankingCsv(
filteredModelRanking.value,
MODEL_METRIC_LABEL[history.modelMetric],
WINDOW_LABEL[history.window],
WINDOW_SPAN[history.window],
history.modelMetric === 'cost',
)
return modelDetailCsv(filteredModelRanking.value, WINDOW_LABEL[history.window], WINDOW_SPAN[history.window])
}
function clearModelFilter(): void {
@@ -251,6 +255,22 @@ watch(scopeOptions, (options) => {
Top {{ MODEL_RANKING_LIMIT }} models
</p>
<ModelUsageChart :metric="history.modelMetric" :models="filteredModelRanking" />
<!--
free-models-plan.md: the per-model detail table beneath the
chart, with a per-row Requests/Tokens in/Tokens out/Cost
breakdown (a free model shows a "free" badge instead of $0 —
lib/model-table-columns.ts). Guarded by the same
filteredModelRanking.length check ModelUsageChart's own empty
state already uses above, so an empty window shows exactly ONE
"no model recorded any usage" message, not a second, empty
table stacked under it.
-->
<DataTable
v-if="filteredModelRanking.length"
:columns="modelColumns"
:data="filteredModelRanking"
empty-message="No model recorded any usage in this window."
/>
<div class="flex justify-end">
<CsvExportButton :filename="`usage-models-${history.window}.csv`" :build="modelsCsv" />
</div>
+1 -1
View File
@@ -9,7 +9,7 @@ import { downloadText, type DownloadOutcome } from '@/lib/download'
* CsvExportButton is the one "Export CSV" control every table export in
* this panel uses — F8. Owned by WP-B1 (usage-csv.ts, this component) but
* consumed unmodified by WP-B2's Models tab, which builds its own CSV text
* via lib/usage-csv.ts's modelRankingCsv and passes it through the same
* via lib/usage-csv.ts's modelDetailCsv and passes it through the same
* `build` prop — one export affordance, not a copy per tab (vue.md: "if
* you've written it twice, you owe an abstraction").
*
+9 -1
View File
@@ -16,7 +16,15 @@ import type { HistoryWindow } from '@/types/api'
/** The five defaults every per-tab param omits from the built hash when the field is already at its default value (buildHash's own "caller decides what's default" contract, lib/hash-state.ts). */
const DEFAULT_WINDOW: HistoryWindow = 'hour'
const DEFAULT_CHART_TAB: ChartTab = 'requests'
const DEFAULT_METRIC: ModelMetric = 'cost'
/**
* DEFAULT_METRIC mirrors stores/history.ts's own initial `modelMetric`
* state (free-models-plan.md: 'req', not 'cost' — a cost-default ranking
* hides every free model). An omitted `metric` hash param, or one this
* build does not recognize (parseChartsParams' own independent-field
* validation, lib/hash-state.ts), falls back to this value here; an
* explicit `metric=cost` in the hash still parses and applies normally.
*/
const DEFAULT_METRIC: ModelMetric = 'req'
const DEFAULT_SCOPE = 'total'
const DEFAULT_MODEL_FILTER = ''
+1 -1
View File
@@ -1,5 +1,5 @@
// F8 (CSV export): the one RFC 4180 encoder every CSV export in this panel
// builds on (usage-csv.ts's usageCsv/modelRankingCsv, WP-B2's Models tab
// builds on (usage-csv.ts's usageCsv/modelDetailCsv, the Models tab's
// export) — one implementation, not a copy per export (vue.md: "if you've
// written it twice, you owe an abstraction").
+139
View File
@@ -0,0 +1,139 @@
import type { VNode } from 'vue'
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import ModelChip from '@/components/ModelChip.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import { Badge } from '@/components/ui/badge'
import { modelTableColumns } from './model-table-columns'
import type { AdminUsageModelEntry } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageModelEntry> = {}): AdminUsageModelEntry {
return {
id: 'openai/gpt-5',
value: 42,
requests: 10,
tokensIn: 100,
tokensOut: 200,
costMicroUsd: 1_234_500,
free: false,
...overrides,
}
}
function accessorFnOf(col: unknown): (row: AdminUsageModelEntry, index: number) => unknown {
return (col as { accessorFn: (row: AdminUsageModelEntry, index: number) => unknown }).accessorFn
}
function cellOf(col: unknown): (ctx: { row: { original: AdminUsageModelEntry } }) => unknown {
return (col as { cell: (ctx: { row: { original: AdminUsageModelEntry } }) => unknown }).cell
}
describe('modelTableColumns: Model column', () => {
const columns = modelTableColumns()
const col = columns.find((c) => c.id === 'model')
it('accessorFn reads the raw canonical id (no routableModelId join — id is already provider/model)', () => {
expect(accessorFnOf(col)(testEntry({ id: 'anthropic/claude' }), 0)).toBe('anthropic/claude')
})
it('cell renders a ModelChip with the id, no context/cost props (this table has no resolved metadata to pass)', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'anthropic/claude' }) } }) as VNode
const children = rendered.children as VNode[]
const chip = children.find((c) => c.type === ModelChip)
expect(chip).toBeDefined()
expect(chip?.props?.id).toBe('anthropic/claude')
})
it('cell renders a ScopeLink pointed at "model:{id}"', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'anthropic/claude' }) } }) as VNode
const children = rendered.children as VNode[]
const link = children.find((c) => c.type === ScopeLink)
expect(link).toBeDefined()
expect(link?.props?.scope).toBe('model:anthropic/claude')
expect(link?.props?.label).toBe('anthropic/claude')
})
})
describe('modelTableColumns: detail count columns sort on raw numbers, render CompactNumber', () => {
const columns = modelTableColumns()
it.each([
['requests', (e: AdminUsageModelEntry) => e.requests],
['tokensIn', (e: AdminUsageModelEntry) => e.tokensIn],
['tokensOut', (e: AdminUsageModelEntry) => e.tokensOut],
] as const)('%s accessorFn returns the raw detail number, not a compact string', (id, read) => {
const col = columns.find((c) => c.id === id)
const entry = testEntry({ requests: 15_000_000, tokensIn: 999, tokensOut: 12_000 })
expect(accessorFnOf(col)(entry, 0)).toBe(read(entry))
})
it.each(['requests', 'tokensIn', 'tokensOut'] as const)('%s cell renders a CompactNumber vnode', (id) => {
const col = columns.find((c) => c.id === id)
const rendered = cellOf(col)({ row: { original: testEntry() } })
expect(isVNode(rendered)).toBe(true)
})
it.each(['requests', 'tokensIn', 'tokensOut'] as const)(
'%s falls back to 0 (never undefined) when detail was not requested, defensively',
(id) => {
const col = columns.find((c) => c.id === id)
const entry = testEntry({ requests: undefined, tokensIn: undefined, tokensOut: undefined })
expect(accessorFnOf(col)(entry, 0)).toBe(0)
},
)
it('sorting by requests orders numerically, not lexicographically', () => {
const col = columns.find((c) => c.id === 'requests')
const accessor = accessorFnOf(col)
const entries = [
testEntry({ id: 'a', requests: 15_000_000 }),
testEntry({ id: 'b', requests: 999 }),
testEntry({ id: 'c', requests: 12_000 }),
]
const sorted = [...entries].sort((x, y) => (accessor(x, 0) as number) - (accessor(y, 0) as number))
expect(sorted.map((e) => e.id)).toEqual(['b', 'c', 'a'])
})
})
describe('modelTableColumns: Cost column', () => {
const columns = modelTableColumns()
const col = columns.find((c) => c.id === 'cost')
it('accessorFn reads the raw micro-USD cost, even for a free model (real 0, not omitted)', () => {
expect(accessorFnOf(col)(testEntry({ costMicroUsd: 1_234_500, free: false }), 0)).toBe(1_234_500)
expect(accessorFnOf(col)(testEntry({ costMicroUsd: 0, free: true }), 0)).toBe(0)
})
it('renders plain formatCost text for a priced (non-free) entry', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ costMicroUsd: 1_234_500, free: false }) } })
expect(isVNode(rendered)).toBe(false)
expect(rendered).toBe('$1.2345')
})
it('shows the real recorded cost, not the badge, for a model marked free after it accrued cost', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ costMicroUsd: 1_234_500, free: true }) } })
expect(isVNode(rendered)).toBe(false)
expect(rendered).toBe('$1.2345')
})
it('renders a Badge with the accessible text "free" for a free entry, instead of $0', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ costMicroUsd: 0, free: true }) } }) as VNode
expect(rendered.type).toBe(Badge)
expect(typeof rendered.children).toBe('object')
const slotDefault = (rendered.children as { default: () => unknown }).default
expect(slotDefault()).toBe('free')
})
it('a free entry still sorts by its real (0) cost, not excluded or reordered by the badge', () => {
const entries = [testEntry({ id: 'paid', costMicroUsd: 5_000_000, free: false }), testEntry({ id: 'free', costMicroUsd: 0, free: true })]
const accessor = accessorFnOf(col)
const sorted = [...entries].sort((x, y) => (accessor(y, 0) as number) - (accessor(x, 0) as number)) // desc
expect(sorted.map((e) => e.id)).toEqual(['paid', 'free'])
})
it('falls back to formatCost($0) when free is false/undefined and costMicroUsd is undefined (detail not requested)', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ costMicroUsd: undefined, free: undefined }) } })
expect(rendered).toBe('$0.0000')
})
})
+110
View File
@@ -0,0 +1,110 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import ModelChip from '@/components/ModelChip.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import { Badge } from '@/components/ui/badge'
import { formatCost } from '@/lib/format'
import type { AdminUsageModelEntry } from '@/types/api'
/**
* modelColumn renders a ranked row's id as a ModelChip (the same copyable
* chip ProvidersView.vue's own per-model list renders) beside a ScopeLink
* that jumps to Charts scoped to this exact model — the identical pair
* ProvidersView.vue already builds from `routableModelId(provider, model)`,
* reused here rather than a third hand-rolled copy (vue.md: "if you've
* written it twice, you owe an abstraction"). No routableModelId join is
* needed: `id` is already the canonical "provider/model" form
* (AdminUsageModelEntry's own doc comment, types/api.ts).
*/
function modelColumn(): ColumnDef<AdminUsageModelEntry, unknown> {
return {
id: 'model',
header: 'Model',
accessorFn: (entry) => entry.id,
cell: ({ row }) =>
h('span', { class: 'inline-flex flex-wrap items-center gap-1.5' }, [
h(ModelChip, { id: row.original.id }),
h(ScopeLink, { label: row.original.id, scope: `model:${row.original.id}` }),
]),
}
}
/**
* numericDetailColumn builds one right-aligned, sortable count column
* reading a `detail=1`-only field (requests/tokensIn/tokensOut —
* AdminUsageModelEntry's own doc comment): undefined only when the caller
* fetched without `detail=1`, which stores/history.ts's fetchModelRanking
* never does any more (free-models-plan.md) — `?? 0` covers that
* theoretical case defensively, the same fallback numericColumn
* (usage-columns.ts) applies for its own optional fields, never a silent
* mis-sort of a genuine 0 count. Rendered via CompactNumber, mirroring
* every other count column in this panel (usage-columns.ts,
* target-columns.ts).
*/
function numericDetailColumn(
id: string,
header: string,
read: (entry: AdminUsageModelEntry) => number | undefined,
): ColumnDef<AdminUsageModelEntry, unknown> {
return {
id,
header,
accessorFn: (entry) => read(entry) ?? 0,
meta: { align: 'right' },
cell: ({ row }) => h(CompactNumber, { value: read(row.original) ?? 0 }),
}
}
/**
* costColumn renders a "free" Badge (accessible text "free" —
* free-models-plan.md's own explicit requirement) in place of the dollar
* figure whenever `entry.free` is true and no cost was recorded, plain
* formatCost otherwise.
* Sorting is unaffected by the badge: the accessorFn always reads the raw
* costMicroUsd number (0 for a free model, same as any other zero cost),
* so a cost-descending sort still puts every free model at the bottom
* exactly where a real $0 belongs — only the DISPLAYED cell changes.
*/
function costColumn(): ColumnDef<AdminUsageModelEntry, unknown> {
return {
id: 'cost',
header: 'Cost',
accessorFn: (entry) => entry.costMicroUsd ?? 0,
meta: { align: 'right' },
cell: ({ row }) => {
const entry = row.original
// A model marked free now may still carry cost recorded before it was
// marked free: show the real dollars then, not the badge.
if (entry.free && (entry.costMicroUsd ?? 0) === 0) {
return h(Badge, { as: 'span', variant: 'secondary', class: 'font-normal', title: 'priced at $0 per token' }, () => 'free')
}
return formatCost(entry.costMicroUsd ?? 0)
},
}
}
/**
* modelTableColumns builds the Models tab's ranking table (free-models-
* plan.md, UI section): Model, Requests, Tokens in, Tokens out, Cost. Every
* numeric column reads its OWN `detail=1` field independently of whichever
* metric the ranking is currently sorted by (stores/history.ts's
* modelMetric) — the table always shows all four figures per row, so a
* reader ranking by cost can still see a model's request count (and vice
* versa) without switching the ranking metric. The server already returns
* the ranking sorted by the ranked metric descending (ModelUsageChart.vue's
* own doc comment), and DataTable.vue's uncontrolled sort state starts
* empty, so that server order — "current metric desc" — is exactly what
* renders before any header is clicked; no explicit initial `sorting` prop
* is needed here.
*/
export function modelTableColumns(): ColumnDef<AdminUsageModelEntry, unknown>[] {
return [
modelColumn(),
numericDetailColumn('requests', 'Requests', (e) => e.requests),
numericDetailColumn('tokensIn', 'Tokens in', (e) => e.tokensIn),
numericDetailColumn('tokensOut', 'Tokens out', (e) => e.tokensOut),
costColumn(),
]
}
+38 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { modelRankingCsv, usageCsv } from './usage-csv'
import { modelDetailCsv, modelRankingCsv, usageCsv } from './usage-csv'
import type { AdminUsageEntryView, AdminUsageModelEntry } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
@@ -118,3 +118,40 @@ describe('modelRankingCsv', () => {
expect(lines[1]).toBe('openai/gpt-4,1.234500')
})
})
// free-models-plan.md: the Models tab's CSV export now includes every
// detail=1 figure per row (Requests/Tokens in/Tokens out/Cost), plus a
// separate Free flag column — not just the single ranked metric
// modelRankingCsv above exports.
describe('modelDetailCsv', () => {
it('renders a header naming every detail column, with window/span folded into Requests only', () => {
const csv = modelDetailCsv([], '24h', 24)
expect(csv).toBe('Model,"Requests (24h, span 24)",Tokens in,Tokens out,Cost (USD),Free\r\n')
})
it('renders one row per model with every detail figure as a plain machine-readable number', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'openai/gpt-4', value: 100, requests: 10, tokensIn: 1000, tokensOut: 2000, costMicroUsd: 1_234_500, free: false },
]
const csv = modelDetailCsv(models, '24h', 24)
expect(csv.split('\r\n')[1]).toBe('openai/gpt-4,10,1000,2000,1.234500,false')
})
it('exports the real 0 cost (never the string "free") in the Cost column, with Free as its own "true" column', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'uni/free-model', value: 0, requests: 5, tokensIn: 50, tokensOut: 75, costMicroUsd: 0, free: true },
]
const csv = modelDetailCsv(models, '24h', 24)
expect(csv.split('\r\n')[1]).toBe('uni/free-model,5,50,75,0.000000,true')
})
it('falls back every missing detail field to 0/false when detail was not actually requested (defensive)', () => {
const models: AdminUsageModelEntry[] = [{ id: 'openai/gpt-4', value: 100 }]
const csv = modelDetailCsv(models, '24h', 24)
expect(csv.split('\r\n')[1]).toBe('openai/gpt-4,0,0,0,0.000000,false')
})
it('renders just the header for an empty ranking', () => {
expect(modelDetailCsv([], '30d', 30)).toBe('Model,"Requests (30d, span 30)",Tokens in,Tokens out,Cost (USD),Free\r\n')
})
})
+48 -4
View File
@@ -1,8 +1,12 @@
// F8 (CSV export): builds the actual CSV text for the Usage tab's Users/
// Groups exports (usageCsv) and, imported unmodified by WP-B2's Models
// tab, the model-ranking export (modelRankingCsv). Both funnel through
// lib/csv.ts's toCsv, so quoting/line-ending behavior is defined in
// exactly one place (that module's own doc comment).
// Groups exports (usageCsv) and the Models tab's ranking export. The
// Models tab's own ChartsView.vue calls modelDetailCsv (free-models-
// plan.md's per-row requests/tokens/cost/free breakdown) — modelRankingCsv
// stays exported and tested as a general single-metric exporter, kept for
// any future caller that only wants the ranked value column. Every export
// funnels through lib/csv.ts's toCsv, so quoting/line-ending (and CSV-
// formula-injection neutralisation) behavior is defined in exactly one
// place (that module's own doc comment).
import { toCsv } from '@/lib/csv'
import { monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatLimits } from '@/lib/format'
@@ -143,3 +147,43 @@ export function modelRankingCsv(
const rows = models.map((m) => [m.id, isCostMetric ? csvCostUsd(m.value) : m.value])
return toCsv(headers, rows)
}
/**
* modelDetailCsv renders the Models tab's current ranking with every
* `detail=1` figure per row (free-models-plan.md, UI section), not just
* the single ranked metric modelRankingCsv above exports: Model, Requests,
* Tokens in, Tokens out, Cost (USD), Free. stores/history.ts's
* fetchModelRanking always requests detail=1 now, so every entry passed
* here is expected to carry every detail field; `?? 0` covers the same
* theoretical-undefined case lib/model-table-columns.ts's
* numericDetailColumn already guards against, never a silent mis-export of
* a genuine 0.
*
* Cost is the SAME plain, machine-readable decimal-USD convention
* usageCsv/modelRankingCsv above already use (csvCostUsd) — a free model's
* own costMicroUsd is a real 0, exported as "0.000000" like any other zero
* cost, never overwritten by the text "free"; `free` is its own separate
* boolean column instead ("true"/"false"), so a spreadsheet reader can
* filter/sum the numeric Cost column without a stray non-numeric value
* breaking that — mirrors the on-screen table's own "the badge only
* changes what's displayed, never the sort key" rule
* (model-table-columns.ts's costColumn doc comment).
*
* `windowLabel`/`span` are the caller's own already-resolved DISPLAY
* strings/number (mirroring modelRankingCsv's identical parameters),
* folded into the Requests column's header only — every detail figure
* shares the identical span, so naming it once keeps the file
* self-describing without repeating it four times.
*/
export function modelDetailCsv(models: AdminUsageModelEntry[], windowLabel: string, span: number): string {
const headers = ['Model', `Requests (${windowLabel}, span ${span})`, 'Tokens in', 'Tokens out', 'Cost (USD)', 'Free']
const rows = models.map((m) => [
m.id,
m.requests ?? 0,
m.tokensIn ?? 0,
m.tokensOut ?? 0,
csvCostUsd(m.costMicroUsd ?? 0),
m.free ? 'true' : 'false',
])
return toCsv(headers, rows)
}
+41 -2
View File
@@ -81,6 +81,42 @@ describe('useHistoryStore model ranking', () => {
expect(paths.some((p) => p.startsWith('/admin/api/usage/history'))).toBe(false)
})
// free-models-plan.md: a cost-default ranking hides every free model
// (its cost total is always 0) — the store now defaults to ranking by
// request count instead.
it('defaults modelMetric to "req", not "cost"', () => {
expect(useHistoryStore().modelMetric).toBe('req')
})
// free-models-plan.md: the ranking fetch always requests the per-model
// detail breakdown (requests/tokensIn/tokensOut/costMicroUsd/free), not
// just the single ranked `value` — lib/model-table-columns.ts's table
// renders every figure regardless of which metric is currently ranking.
it('always sends detail=1 on the ranking fetch, regardless of metric or prefix', async () => {
routeFetch({})
const history = useHistoryStore()
history.tab = 'models'
history.modelFilter = 'openai/'
await history.fetchModelRanking()
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
expect(rankingCall).toContain('detail=1')
})
// The scope picker's model list (fetchModelOptions) only ever needs ids
// — it must NOT pay for the detail breakdown the ranking fetch above
// does.
it('never sends detail=1 on the picker-list fetch', 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).not.toContain('detail=1')
})
it('ranks by the selected metric, independently of the tab', async () => {
routeFetch({})
const history = useHistoryStore()
@@ -410,7 +446,10 @@ describe('useHistoryStore: latest-request-wins', () => {
history.tab = 'models'
history.modelRanking = [{ id: 'alpha/a-model-1', value: 3 }]
history.setModelMetric('req')
// 'cost', not 'req' — the store now DEFAULTS to 'req' (free-models-
// plan.md), so setModelMetric('req') here would be a no-op early
// return rather than the actual change this test means to exercise.
history.setModelMetric('cost')
expect(history.modelRanking).toEqual([])
})
@@ -523,7 +562,7 @@ describe('useHistoryStore.setSelection (P12: batched, one fetch)', () => {
const history = useHistoryStore()
const callsBefore = mockedAdminFetch.mock.calls.length
history.setSelection({ scope: 'total', window: 'hour', tab: 'requests', modelMetric: 'cost' }) // all defaults
history.setSelection({ scope: 'total', window: 'hour', tab: 'requests', modelMetric: 'req' }) // all defaults
await flush()
expect(mockedAdminFetch.mock.calls.length).toBe(callsBefore)
+16 -3
View File
@@ -132,8 +132,14 @@ 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,
/**
* Which metric the Models ranking ranks by — independent of `tab`.
* Defaults to 'req' (free-models-plan.md), not 'cost': a cost-default
* ranking silently hides every free model (its cost total is always
* 0), and the picker/table both need free models to be reachable
* without the reader first knowing to switch metrics.
*/
modelMetric: 'req' as ModelMetric,
seriesByMetric: {} as Partial<Record<HistoryMetric, UsageHistoryPoint[]>>,
/** The current Models-tab ranking. Empty means "nothing used in this window". */
modelRanking: [] as AdminUsageModelEntry[],
@@ -391,8 +397,15 @@ export const useHistoryStore = defineStore('history', {
// provider's models entirely, or return only a partial view of them
// (the CONFIRMED problem this fixes).
const prefixParam = this.modelFilter ? `&prefix=${encodeURIComponent(this.modelFilter)}` : ''
// detail=1 (free-models-plan.md): the Models tab's own ranking
// fetch always asks for the per-entry requests/tokensIn/tokensOut/
// costMicroUsd/free breakdown, not just the single ranked `value` —
// lib/model-table-columns.ts's table renders all four figures
// (plus the free badge) beside every row regardless of which
// metric is currently ranking. fetchModelOptions below stays
// detail-free: the scope picker only ever needs an id list.
const res = await adminFetch<AdminUsageModelsResponse>(
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}&span=${span}${prefixParam}`,
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}&span=${span}${prefixParam}&detail=1`,
)
if (requestId !== this.seriesReqId) return
this.modelRanking = res.models
+27 -1
View File
@@ -327,11 +327,30 @@ export interface UsageHistoryResponse {
* 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.
* target and its primary appear as two separate rows. `value` is the total
* for whichever metric the request asked to rank by — unchanged meaning
* whether or not `detail` was requested.
*
* free-models-plan.md (per-model stats for free models): requests/tokensIn/
* tokensOut/costMicroUsd/free are `detail=1`-only — present (each field
* independently, possibly 0) only when the request set `detail=1`
* (AdminUsageModelsResponse.detail), undefined otherwise. An id is included
* by the server whenever ANY of the four totals is > 0, so a free model
* with real request traffic but zero cost is still listed even when
* ranking by cost, with `value` (and `costMicroUsd`) genuinely 0 — `free`
* is how a reader tells that apart from "this model simply has no cost
* data yet" (mirrors AdminModelMetaView's own "undefined means unknown,
* present 0 means known-free" convention, never infer either from a value
* being falsy).
*/
export interface AdminUsageModelEntry {
id: string
value: number
requests?: number
tokensIn?: number
tokensOut?: number
costMicroUsd?: number
free?: boolean
}
/**
@@ -357,6 +376,13 @@ export interface AdminUsageModelsResponse {
* string.
*/
span: number
/**
* detail echoes whether the request set `detail=1` (free-models-plan.md)
* — true means every entry in `models` additionally carries its own
* requests/tokensIn/tokensOut/costMicroUsd/free. Omitted (falsy) for the
* ordinary, byte-identical-with-before response.
*/
detail?: boolean
models: AdminUsageModelEntry[]
}