mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
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:
@@ -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").
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
]
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user