mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +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:
+6
-6
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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").
|
||||
*
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user