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
+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