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
+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").
*