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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user