feat(webui): redesign the admin dashboard around admin tasks

A sidebar replaces the flat tabs with seven pages, and one filter bar
sets the time range, comparison period and scope for all of them.

- Home: spend against budget, forecast, request rate, error rate,
  open breakers, top users and models, recent events and warnings.
- Spend: spend over time with comparison, budget burn-down, an
  attribution drill-down from group to user to model, pricing health
  and cost avoided by free models.
- Consumers: a directory with last seen and limit bars, an access
  matrix, and a detail page per user.
- Models: the catalog with billing prices, fleet latency and error
  rates, and provider health.
- Reliability: error, timeout, failover, 429 and 402 trends, events,
  and latency per provider.
- Config: the redacted config and a change helper that emits YAML or
  users.json snippets to paste into git.
- MCP & Agents: callers per target.

Page state lives in the URL hash, and old tab links map to the new
pages. The replaced views are removed. admin_assets_gen.go is
regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 16:41:01 +01:00
parent e8abb91dc8
commit ee49467e64
127 changed files with 11226 additions and 3414 deletions
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { buildAccessMatrix, providerAccessCell, targetAccessCell } from './access-matrix'
import type { AdminConsumerGroup, AdminTargetView } from '@/types/api'
function group(overrides: Partial<AdminConsumerGroup> = {}): AdminConsumerGroup {
return {
name: 'friends',
allowedProviders: [],
modelAccess: {},
memberCount: 3,
...overrides,
}
}
function target(overrides: Partial<AdminTargetView> = {}): AdminTargetView {
return {
name: 'fetch',
url: 'http://mcp-fetch/mcp',
counters: { requestsPerMinute: 0, requestsPerDay: 0, requestsPerMonth: 0 },
health: { state: 'unknown', consecutiveFailures: 0 },
...overrides,
}
}
describe('providerAccessCell', () => {
it('not allowed when the provider is absent from allowedProviders', () => {
const g = group({ allowedProviders: ['minimax'] })
expect(providerAccessCell(g, 'uni')).toEqual({ allowed: false })
})
it('allowed with a "n/m models" detail when modelAccess has an entry', () => {
const g = group({ allowedProviders: ['minimax'], modelAccess: { minimax: { allowed: 2, total: 5 } } })
expect(providerAccessCell(g, 'minimax')).toEqual({ allowed: true, detail: '2/5 models' })
})
it('allowed with no detail when modelAccess has no entry for this provider', () => {
const g = group({ allowedProviders: ['minimax'], modelAccess: {} })
expect(providerAccessCell(g, 'minimax')).toEqual({ allowed: true, detail: undefined })
})
// P3 item 18: allowed but 0 of the provider's models are reachable
// (a `models` glob matching none of it) must NOT render identically to
// an ordinary, genuinely-unrestricted allowed cell.
it('sets zero: true when the provider is allowed but 0 of its models are reachable', () => {
const g = group({ allowedProviders: ['minimax'], modelAccess: { minimax: { allowed: 0, total: 5 } } })
expect(providerAccessCell(g, 'minimax')).toEqual({ allowed: true, detail: '0/5 models', zero: true })
})
it('does NOT set zero when the provider itself has 0 total models (unrelated to the grant)', () => {
const g = group({ allowedProviders: ['minimax'], modelAccess: { minimax: { allowed: 0, total: 0 } } })
expect(providerAccessCell(g, 'minimax')).toEqual({ allowed: true, detail: '0/0 models' })
})
})
describe('targetAccessCell', () => {
it('allowed for every group when access is undefined (unrestricted)', () => {
expect(targetAccessCell(group({ name: 'friends' }), target({ access: undefined }))).toEqual({ allowed: true })
})
it('allowed for every group when access is null (unrestricted)', () => {
expect(targetAccessCell(group({ name: 'friends' }), target({ access: null }))).toEqual({ allowed: true })
})
it('not allowed for any group when access is an empty array', () => {
expect(targetAccessCell(group({ name: 'friends' }), target({ access: [] }))).toEqual({ allowed: false })
})
it('allowed only for a named group', () => {
expect(targetAccessCell(group({ name: 'friends' }), target({ access: ['friends'] }))).toEqual({ allowed: true })
expect(targetAccessCell(group({ name: 'home' }), target({ access: ['friends'] }))).toEqual({ allowed: false })
})
})
describe('buildAccessMatrix', () => {
it('builds one row per group, keyed by column name', () => {
const groups = [
group({ name: 'home', allowedProviders: ['anthropic', 'openai'], modelAccess: { anthropic: { allowed: 5, total: 5 } } }),
group({ name: 'friends', allowedProviders: ['minimax'] }),
]
const mcp = [target({ name: 'fetch', access: undefined }), target({ name: 'weather', access: ['home'] })]
const agents = [target({ name: 'agentkit', access: [] })]
const rows = buildAccessMatrix(groups, ['anthropic', 'openai', 'minimax'], mcp, agents)
expect(rows).toHaveLength(2)
expect(rows[0].group).toBe('home')
expect(rows[0].providers.anthropic).toEqual({ allowed: true, detail: '5/5 models' })
expect(rows[0].providers.openai).toEqual({ allowed: true, detail: undefined })
expect(rows[0].providers.minimax).toEqual({ allowed: false })
expect(rows[0].mcpServers.fetch).toEqual({ allowed: true })
expect(rows[0].mcpServers.weather).toEqual({ allowed: true })
expect(rows[0].agents.agentkit).toEqual({ allowed: false })
expect(rows[1].group).toBe('friends')
expect(rows[1].mcpServers.weather).toEqual({ allowed: false })
})
it('returns an empty array for no groups', () => {
expect(buildAccessMatrix([], ['anthropic'], [], [])).toEqual([])
})
})
+94
View File
@@ -0,0 +1,94 @@
// AccessMatrix.vue (redesign-plan.md section 3.4): pure matrix-building
// logic — rows are groups, columns are providers (an allowed check plus
// "n/m models") and every configured MCP server/agent target. Kept out of
// the component the same way every other pure data-shaping helper in this
// panel is (lib/usage-bars.ts, lib/forecast.ts) — vitest's node-environment
// config exercises it directly, no component mount required.
import type { AdminConsumerGroup, AdminTargetView } from '@/types/api'
/**
* AccessCell is one matrix cell's reading: whether the group can reach
* this provider/target at all, plus an optional human-readable detail (a
* provider cell's "n/m models"; a target cell has none). `zero` (P3 item
* 18) marks a provider cell where the provider itself is allowed but the
* group's own model grant resolves to 0 of the provider's models — e.g.
* a `models` glob that happens to match nothing this provider currently
* serves — distinct from an ordinary allowed cell (0 usable models is
* not the same reading as "unrestricted access", even though both would
* otherwise render an identical green check).
*/
export interface AccessCell {
allowed: boolean
detail?: string
zero?: boolean
}
/**
* providerAccessCell reads a group's resolved provider access (admin.go:
* adminConsumerGroup.AllowedProviders — never nil, already glob-matched
* server-side, so this is a plain membership check, not a re-implementation
* of path.Match) plus its per-provider model count (modelAccess, "n/m
* models") when the provider is allowed at all. A provider absent from
* modelAccess (the group is allowed the provider but the server reported no
* model breakdown for it — should not happen in practice, but the type
* itself is a plain Record with no guaranteed key) renders `allowed: true`
* with no detail, rather than a fabricated "0/0 models". `zero` is set only
* when the provider genuinely serves models (`total > 0`) but the group's
* grant reaches none of them (`allowed === 0`) — a `total` of 0 (a
* provider with no discovered models at all) is a different, unrelated
* situation and does not trip it.
*/
export function providerAccessCell(group: AdminConsumerGroup, providerName: string): AccessCell {
if (!group.allowedProviders.includes(providerName)) return { allowed: false }
const access = group.modelAccess[providerName]
if (!access) return { allowed: true }
// `zero` follows this module's own "undefined means unset" convention
// (every other optional AccessCell/BudgetRatio-style field in this
// panel) — omitted (never a literal `false`) so the ordinary allowed
// case's shape stays exactly `{ allowed, detail }`.
const zero = access.allowed === 0 && access.total > 0 ? true : undefined
return { allowed: true, detail: `${access.allowed}/${access.total} models`, zero }
}
/**
* targetAccessCell reads a group's access to one MCP server/agent target
* from that target's OWN `access` field (admin.go: adminTargetView.Access —
* types/api.ts's own doc comment): `null`/`undefined` means every
* configured group can reach it, `[]` means none can, a non-empty list
* names the groups that can. No detail (unlike a provider cell) — a
* target has no "n/m" breakdown, only reachable or not.
*/
export function targetAccessCell(group: AdminConsumerGroup, target: Pick<AdminTargetView, 'name' | 'access'>): AccessCell {
if (target.access === undefined || target.access === null) return { allowed: true }
return { allowed: target.access.includes(group.name) }
}
/** AccessMatrixRow is one group's full row: its own provider/MCP-server/agent cells, each keyed by that column's own name — AccessMatrix.vue iterates `providerNames`/`mcpServers`/`agents` (the caller's own column order) and looks up each cell by name rather than relying on object key iteration order. */
export interface AccessMatrixRow {
group: string
providers: Record<string, AccessCell>
mcpServers: Record<string, AccessCell>
agents: Record<string, AccessCell>
}
/**
* buildAccessMatrix builds one row per group, over the given provider
* names and MCP-server/agent target lists — the caller (AccessMatrix.vue)
* supplies the column universe (every configured provider name from GET
* /admin/api/overview; every target from GET /admin/api/targets) so this
* function stays a pure join, with no fetching or ordering opinion of
* its own.
*/
export function buildAccessMatrix(
groups: AdminConsumerGroup[],
providerNames: string[],
mcpServers: AdminTargetView[],
agents: AdminTargetView[],
): AccessMatrixRow[] {
return groups.map((group) => ({
group: group.name,
providers: Object.fromEntries(providerNames.map((name) => [name, providerAccessCell(group, name)])),
mcpServers: Object.fromEntries(mcpServers.map((target) => [target.name, targetAccessCell(group, target)])),
agents: Object.fromEntries(agents.map((target) => [target.name, targetAccessCell(group, target)])),
}))
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { cumulative, currentMonthDaySpan, MIN_ELAPSED_DAYS, runOutDate } from './burndown'
describe('currentMonthDaySpan', () => {
it('returns the UTC day-of-month', () => {
expect(currentMonthDaySpan(new Date(Date.UTC(2026, 8, 23, 12, 0, 0)))).toBe(23)
})
it('returns 1 on the first day of the month', () => {
expect(currentMonthDaySpan(new Date(Date.UTC(2026, 8, 1, 0, 0, 0)))).toBe(1)
})
})
describe('cumulative', () => {
it('returns a running total, oldest first', () => {
expect(cumulative([1, 2, 3])).toEqual([1, 3, 6])
})
it('returns an empty array for empty input', () => {
expect(cumulative([])).toEqual([])
})
it('handles a single point', () => {
expect(cumulative([5])).toEqual([5])
})
})
describe('runOutDate', () => {
it('returns null when no budget is configured (limitMicros <= 0)', () => {
expect(runOutDate(1_000_000, 0, new Date(Date.UTC(2026, 3, 15)))).toBeNull()
expect(runOutDate(1_000_000, -10, new Date(Date.UTC(2026, 3, 15)))).toBeNull()
})
it('returns now itself once spend has already met or exceeded the limit', () => {
const now = new Date(Date.UTC(2026, 3, 15, 8, 0, 0))
expect(runOutDate(5_000_000, 5_000_000, now)).toEqual(now)
expect(runOutDate(6_000_000, 5_000_000, now)).toEqual(now)
})
it('returns null before MIN_ELAPSED_DAYS have elapsed in the month', () => {
// A few hours into day 1 — well under one full elapsed day.
const earlyMonth = new Date(Date.UTC(2026, 3, 1, 2, 0, 0))
expect(runOutDate(1000, 1_000_000, earlyMonth)).toBeNull()
})
it('returns null when the daily rate so far is 0 — spend has not started', () => {
const now = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
expect(runOutDate(0, 1_000_000, now)).toBeNull()
})
it('projects forward at the MTD average daily rate', () => {
// April 2026 (30 days): 15 full days elapsed, 15,000,000 micros spent
// -> 1,000,000 micros/day. A 20,000,000 limit has 5,000,000 remaining
// -> 5 more days to exhaust.
const now = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
const result = runOutDate(15_000_000, 20_000_000, now)
expect(result).not.toBeNull()
expect(result!.getTime()).toBe(now.getTime() + 5 * 24 * 60 * 60 * 1000)
})
it('exposes MIN_ELAPSED_DAYS as a stable, importable constant', () => {
expect(MIN_ELAPSED_DAYS).toBe(1)
})
})
+93
View File
@@ -0,0 +1,93 @@
// Pure math behind BurnDownChart.vue (redesign-plan.md section 3.4): a
// cumulative month-to-date spend line against a flat budget reference, and
// the date that spend would cross the budget at the current pace. Kept out
// of the component the same way lib/forecast.ts keeps CostForecastCard.vue's
// own month-end projection math out of it — every function here takes `now`
// as an explicit argument (never reads Date.now() itself), so vitest's
// node-environment config exercises the real calendar-boundary cases
// deterministically.
//
// All month-boundary math is UTC, mirroring lib/forecast.ts's own
// monthProgress (costPerMonthMicroUsd/AdminSeriesResponse's day buckets
// both accumulate against a UTC calendar month — limits.go's windowKey
// "200601"/"20060102" — so projecting against the LOCAL calendar month
// would silently misalign on the first/last day of a month in any
// non-UTC timezone).
import { monthProgress } from '@/lib/forecast'
const MS_PER_DAY = 24 * 60 * 60 * 1000
/**
* daysInMonthUTC returns how many days are in `now`'s own UTC calendar
* month (28-31) — correct for every month length, including a leap
* February, via the same Date.UTC month-rollover normalization
* monthProgress (lib/forecast.ts) already relies on, never a hardcoded
* days-per-month table.
*/
function daysInMonthUTC(now: Date): number {
const year = now.getUTCFullYear()
const month = now.getUTCMonth()
return (Date.UTC(year, month + 1, 1) - Date.UTC(year, month, 1)) / MS_PER_DAY
}
/**
* currentMonthDaySpan is how many day buckets (1..31) `now`'s own UTC
* calendar month has elapsed so far, INCLUDING today — the `span` the
* spend store requests from GET /admin/api/usage/series for the burn-down
* line (redesign-plan.md section 3.3: "burn-down current-month day series
* span=dayOfMonth"), so a day-3-of-the-month poll asks for exactly 3
* buckets, never the whole month's worth of not-yet-existing future ones.
*/
export function currentMonthDaySpan(now: Date): number {
return now.getUTCDate()
}
/**
* cumulative turns a bucket-per-day points array (day-window
* AdminSeriesResponse series, oldest first) into a running total —
* BurnDownChart.vue's own "spend so far" line, since the series endpoint
* itself reports each day's OWN cost, not a running sum.
*/
export function cumulative(points: number[]): number[] {
let sum = 0
return points.map((p) => (sum += p))
}
/**
* MIN_ELAPSED_DAYS is runOutDate's own extrapolation floor, mirroring
* lib/forecast.ts's MIN_PROJECTION_FRACTION for the identical reason: the
* first day of a month is too little data for a daily-average run rate to
* mean anything, so runOutDate reports "not enough data yet" (null) below
* it rather than a wildly overstated projection from a single day's spend.
*/
export const MIN_ELAPSED_DAYS = 1
/**
* runOutDate projects the date month-to-date spend would cross
* `limitMicros`, extrapolating from the MTD average daily rate
* (mtdMicros / elapsed days so far) — redesign-plan.md section 3.4:
* "run-out date lib/burndown.ts runOutDate(mtdMicros, limitMicros, now) at
* MTD average daily rate". Mirrors lib/forecast.ts's headroom() null
* conventions: null when there is no budget to compare against
* (`limitMicros <= 0`), and null when there is too little of the month
* elapsed to extrapolate from (MIN_ELAPSED_DAYS) or the rate so far is 0
* (spend has not started — "never" is not representable as a date, so
* this reports "no forecast" rather than a fabricated far-future one).
* Already-exceeded budgets return `now` itself (the run-out already
* happened, as of this read) rather than a nonsensical past-vs-future date
* from continuing the division.
*/
export function runOutDate(mtdMicros: number, limitMicros: number, now: Date): Date | null {
if (limitMicros <= 0) return null
if (mtdMicros >= limitMicros) return new Date(now.getTime())
const elapsedDays = monthProgress(now) * daysInMonthUTC(now)
if (elapsedDays < MIN_ELAPSED_DAYS) return null
const dailyRateMicros = mtdMicros / elapsedDays
if (dailyRateMicros <= 0) return null
const remainingMicros = limitMicros - mtdMicros
const daysToExhaust = remainingMicros / dailyRateMicros
return new Date(now.getTime() + daysToExhaust * MS_PER_DAY)
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { cacheSavingsStatus } from './cache-savings'
describe('cacheSavingsStatus (N2)', () => {
it('is unavailable for an hour window, with a reason naming the granularity, not a fabricated $0', () => {
const status = cacheSavingsStatus('hour')
expect(status.available).toBe(false)
expect(status.note).toMatch(/hour/i)
})
it('is available with no caveat for a day window (exact figure)', () => {
expect(cacheSavingsStatus('day')).toEqual({ available: true, note: null })
})
it('is available but notes the 35-day retention cap for a month window', () => {
const status = cacheSavingsStatus('month')
expect(status.available).toBe(true)
expect(status.note).toMatch(/35/)
})
})
+47
View File
@@ -0,0 +1,47 @@
import type { HistoryWindow } from '@/types/api'
/**
* CacheSavingsStatus is cacheSavingsStatus's own result: whether the
* requested bucket window can report a cache-savings figure at all, and
* any caveat text worth showing alongside a figure that IS available but
* incomplete.
*/
export interface CacheSavingsStatus {
available: boolean
note: string | null
}
/**
* cacheSavingsStatus (N2 fix, verify-redesign-final.md) reports whether
* GET /admin/api/usage/models' `detail=1` `cacheSavedMicroUsd` figure is
* available for `window`, and any caveat note CostAvoidedCard.vue should
* show alongside it — grounded in admin.go's own cache-savings read
* (serveAdminUsageModelsDetail, admin.go:~1826-1851):
* - `'hour'`: chit/csave are OMITTED entirely (both fields left
* undefined, never a fabricated `$0.00`) — recordCacheHit only ever
* writes them at DAY granularity (plan §1.2: "model day only"), so
* there is no per-hour figure to recover at all, exact or otherwise.
* - `'day'`: read directly at the exact requested day buckets — exact,
* no caveat needed.
* - `'month'`: read via monthRangeToDayBuckets' exact covering day
* buckets, but those are capped at the day counter's own 35-day
* retention (historyMaxSpan(windowDay), admin.go) — a 3mo/6mo/12mo
* range's figure only ever covers the most recent 35 days of it,
* never silently more, and never the whole selected range once that
* range exceeds 35 days.
*
* This replaces an earlier, stale note ("reflects the last N-bucket
* window, not the selected range") that described a DIFFERENT, already-
* fixed backend bug (P3 item 15: cache savings used to always sum DAY
* buckets regardless of the requested window) — the current backend
* behaviour above is what ships now, not that one.
*/
export function cacheSavingsStatus(window: HistoryWindow): CacheSavingsStatus {
if (window === 'hour') {
return { available: false, note: 'Cache savings are not tracked per hour — pick a day or month range instead.' }
}
if (window === 'month') {
return { available: true, note: 'Covers only the most recent 35 days (day-bucket retention), not the full selected range.' }
}
return { available: true, note: null }
}
+16 -13
View File
@@ -1,15 +1,18 @@
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LinearScale,
Tooltip,
} from 'chart.js'
import { CategoryScale, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, Tooltip } from 'chart.js'
// Registered once at module load (ES module caching makes this a true
// singleton regardless of how many components import it) — only the bar
// chart pieces this panel actually uses, not chart.js's full auto-registry,
// to keep the baked-in bundle lean.
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip)
// singleton regardless of how many components import it) — only the
// chart pieces this panel actually uses, not chart.js's full
// auto-registry, to keep the baked-in bundle lean. TimeSeriesChart.vue
// (redesign-plan.md section 3.4) is the only chart component in this
// panel, and it only ever imports `Line` from vue-chartjs (never `Bar`) —
// so this registers exactly Line's own pieces, plus `Filler`:
// TimeSeriesChart.vue sets `fill: props.stacked` on every stacked
// dataset, and without Filler registered chart.js silently ignores that
// option — a stacked area chart drew UNFILLED lines at cumulative
// heights, so a reader saw the top line at the height of the whole total
// rather than its own value (P2). A `BarController`/`BarElement`
// registration used to sit here too; it was dead — grep confirms no
// component imports vue-chartjs's `Bar`.
Chart.register(CategoryScale, LinearScale, Legend, Tooltip, LineController, LineElement, PointElement, Filler)
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import { costAvoidedMicros, freeRows, pickReferenceModel } from './cost-avoided'
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
describe('freeRows', () => {
it('keeps only entries with free === true, reading out their token counts', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'a/free-model:free', value: 0, free: true, tokensIn: 100, tokensOut: 50 },
{ id: 'b/paid-model', value: 10, free: false, tokensIn: 20, tokensOut: 10 },
]
expect(freeRows(models)).toEqual([{ tokensIn: 100, tokensOut: 50 }])
})
it('defaults missing token counts to 0', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/free', value: 0, free: true }]
expect(freeRows(models)).toEqual([{ tokensIn: 0, tokensOut: 0 }])
})
it('returns an empty array when nothing is free', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/paid', value: 5, free: false }]
expect(freeRows(models)).toEqual([])
})
})
describe('costAvoidedMicros', () => {
it('sums tokensIn*refIn + tokensOut*refOut across rows, already in micro-USD', () => {
// 1_000_000 tokensIn at $1/MTok = 1,000,000 micros ($1); 500_000
// tokensOut at $2/MTok = 1,000,000 micros ($1). Total 2,000,000.
const rows = [{ tokensIn: 1_000_000, tokensOut: 500_000 }]
expect(costAvoidedMicros(rows, 1, 2)).toBe(2_000_000)
})
it('sums across multiple rows', () => {
const rows = [
{ tokensIn: 1_000_000, tokensOut: 0 },
{ tokensIn: 0, tokensOut: 1_000_000 },
]
expect(costAvoidedMicros(rows, 0.5, 1.5)).toBe(500_000 + 1_500_000)
})
it('returns 0 for an empty rows array', () => {
expect(costAvoidedMicros([], 1, 1)).toBe(0)
})
it('rounds the summed total to the nearest whole micro-USD', () => {
const rows = [{ tokensIn: 1, tokensOut: 1 }]
expect(costAvoidedMicros(rows, 0.33, 0.33)).toBe(Math.round(0.33 + 0.33))
})
})
describe('pickReferenceModel', () => {
const catalog = new Map<string, AdminCatalogModel>([
['a/paid-high', { id: 'a/paid-high', model: 'paid-high', priceSource: 'builtin' }],
['a/paid-low', { id: 'a/paid-low', model: 'paid-low', priceSource: 'override' }],
['a/free', { id: 'a/free', model: 'free', priceSource: 'free' }],
['a/unpriced', { id: 'a/unpriced', model: 'unpriced', priceSource: 'unpriced' }],
])
it('picks the most-requested model among genuinely priced catalog entries', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'a/paid-low', value: 1, requests: 5 },
{ id: 'a/paid-high', value: 1, requests: 50 },
]
expect(pickReferenceModel(models, catalog)).toBe('a/paid-high')
})
it('skips free and unpriced catalog entries even if most-requested', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'a/free', value: 1, requests: 1000 },
{ id: 'a/paid-low', value: 1, requests: 1 },
]
expect(pickReferenceModel(models, catalog)).toBe('a/paid-low')
})
it('skips a served id missing from the catalog entirely', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/removed', value: 1, requests: 999 }]
expect(pickReferenceModel(models, catalog)).toBeNull()
})
it('skips a detail-less entry with no requests field', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/paid-high', value: 1 }]
expect(pickReferenceModel(models, catalog)).toBeNull()
})
it('returns null when nothing qualifies', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/free', value: 1, requests: 10 }]
expect(pickReferenceModel(models, catalog)).toBeNull()
})
it('returns null for an empty ranking', () => {
expect(pickReferenceModel([], catalog)).toBeNull()
})
})
/**
* N1 (verify-redesign-final.md): CostAvoidedCard.vue's own `avoidedMicros`
* computed is exactly `pickReferenceModel` feeding `costAvoidedMicros`
* with the picked model's `cat.inputPerMTokUsd`/`outputPerMTokUsd` — this
* end-to-end shape is what broke when the catalog reported `priceSource:
* 'override'` alongside undefined prices (admin_catalog.go's now-fixed
* bug). This exercises the same two-step flow directly against a fixed-
* shape catalog (billing price present for every priced source,
* including 'override' — the contract GET /admin/api/catalog now
* guarantees) so a regression back to undefined-but-priced catalog
* entries fails here, not just in a Go-only test.
*/
describe('pickReferenceModel + costAvoidedMicros end-to-end (N1 regression)', () => {
it('computes a non-null avoided-cost figure when the picked reference model is override-priced', () => {
const catalog = new Map<string, AdminCatalogModel>([
['a/override-priced', { id: 'a/override-priced', model: 'override-priced', priceSource: 'override', inputPerMTokUsd: 5, outputPerMTokUsd: 10 }],
])
const models: AdminUsageModelEntry[] = [
{ id: 'a/override-priced', value: 1, requests: 42, free: false },
{ id: 'a/free-model', value: 0, free: true, tokensIn: 1_000_000, tokensOut: 500_000 },
]
const picked = pickReferenceModel(models, catalog)
expect(picked).toBe('a/override-priced')
const cat = catalog.get(picked as string)
expect(cat?.inputPerMTokUsd).toBe(5)
expect(cat?.outputPerMTokUsd).toBe(10)
const avoided = costAvoidedMicros(freeRows(models), cat?.inputPerMTokUsd as number, cat?.outputPerMTokUsd as number)
expect(avoided).toBe(1_000_000 * 5 + 500_000 * 10)
})
})
+84
View File
@@ -0,0 +1,84 @@
// Pure math behind CostAvoidedCard.vue (redesign-plan.md section 3.4): how
// much a fleet's FREE-tier traffic would have cost against a real, priced
// reference model, plus which model to default that reference to. Kept out
// of the component the same way lib/forecast.ts/lib/burndown.ts keep their
// own math out of theirs.
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
/**
* FreeUsageRow is the subset of a GET /admin/api/usage/models `detail=1`
* entry costAvoidedMicros needs — deliberately narrower than
* AdminUsageModelEntry so the function is testable against plain literals
* without constructing a full entry.
*/
export interface FreeUsageRow {
tokensIn: number
tokensOut: number
}
/**
* freeRows filters a model ranking down to its FREE rows (detail=1's own
* `free` flag — a model billed as $0, whether a configured modelMeta.free
* override or a genuinely unpriced model, types/api.ts's PriceSource doc
* comment) and reads out just the token counts costAvoidedMicros needs.
* `tokensIn`/`tokensOut` default to 0 for a detail-less entry (should not
* happen once the caller has requested detail=1, but this keeps the
* function total rather than reading `undefined` into arithmetic).
*/
export function freeRows(models: AdminUsageModelEntry[]): FreeUsageRow[] {
return models.filter((m) => m.free).map((m) => ({ tokensIn: m.tokensIn ?? 0, tokensOut: m.tokensOut ?? 0 }))
}
/**
* costAvoidedMicros sums, over every free-tier row, what that traffic
* would have cost at a REAL reference model's own per-token-million rates
* — the fleet's "cost avoided by routing to free models" figure
* (redesign-plan.md section 3.4).
*
* Unit derivation (why no explicit /1e6 appears despite `refInputPerMTokUsd`
* being USD PER MILLION tokens, types/api.ts's AdminCatalogModel doc
* comment): cost_usd = tokensIn * (refInputPerMTokUsd / 1_000_000);
* cost_micros = cost_usd * 1_000_000 (MICROS_PER_USD, lib/usage-bars.ts) =
* tokensIn * refInputPerMTokUsd. The two factors of 1_000_000 cancel
* exactly, so multiplying token counts directly by the $-per-million-token
* rate already yields micro-USD — the same unit AdminUsageEntryView's own
* costPerDayMicroUsd/costPerMonthMicroUsd fields use, with no separate
* scaling step. Rounded once, over the summed total, not per-row, so a sum
* of many sub-micro rows never underflows to a visibly wrong 0 before
* accumulating.
*/
export function costAvoidedMicros(rows: FreeUsageRow[], refInputPerMTokUsd: number, refOutputPerMTokUsd: number): number {
let total = 0
for (const row of rows) total += row.tokensIn * refInputPerMTokUsd + row.tokensOut * refOutputPerMTokUsd
return Math.round(total)
}
/** REFERENCE_INELIGIBLE_SOURCES are the catalog priceSource values pickReferenceModel refuses to default to — a "what would this have cost at a real price" comparison is meaningless against another $0 model. */
const REFERENCE_INELIGIBLE_SOURCES = new Set(['free', 'unpriced'])
/**
* pickReferenceModel defaults the "compare free traffic against" model to
* the MOST-REQUESTED genuinely-priced model actually served in the current
* range (redesign-plan.md section 3.4: "reference model picker from priced
* catalog models, default most-requested paid model in range"). Ranked by
* `requests` specifically — NOT by whichever metric the Spend page's own
* breakdown happens to be sorted by — since a cost-ranked or token-ranked
* list would bias the default toward an expensive or verbose model rather
* than the one readers actually reach for most. A model missing from
* `catalog` (not currently in the live registry — a stale ranking entry
* for a since-removed model) or lacking a `requests` count (a detail-less
* entry) is skipped. Returns null when nothing in the range qualifies —
* every served model was itself free/unpriced, or usage/models was empty
* — the caller then falls back to an explicit picker with no preselection
* rather than a fabricated default.
*/
export function pickReferenceModel(models: AdminUsageModelEntry[], catalog: Map<string, AdminCatalogModel>): string | null {
let best: AdminUsageModelEntry | null = null
for (const m of models) {
if (m.requests === undefined) continue
const cat = catalog.get(m.id)
if (!cat || REFERENCE_INELIGIBLE_SOURCES.has(cat.priceSource)) continue
if (best === null || m.requests > (best.requests ?? 0)) best = m
}
return best?.id ?? null
}
+3 -3
View File
@@ -1,7 +1,7 @@
// F8 (CSV export): the one RFC 4180 encoder every CSV export in this panel
// 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").
// builds on (usage-csv.ts's usageCsv, PricingHealthTable.vue's own
// pricingHealthCsv) — one implementation, not a copy per export (vue.md:
// "if you've written it twice, you owe an abstraction").
/** CSV_LINE_ENDING is RFC 4180's own line terminator (CRLF), not the bare "\n" a JS template literal defaults to — Excel and most spreadsheet importers expect it, and RFC 4180 section 2.1 requires it. */
const CSV_LINE_ENDING = '\r\n'
+3 -2
View File
@@ -29,8 +29,9 @@ const REVOKE_DELAY_MS = 1000
* Guarded for a non-DOM environment (this module is never unit-tested —
* see WP-B1's own test plan, lib/download.ts has no .spec.ts — but a
* defensive `typeof document` check keeps it from throwing if it is ever
* imported somewhere `document` is not global, matching useTabHash.ts's
* own `hasWindow` convention). The clipboard fallback (copyText, Q8) runs
* imported somewhere `document` is not global, matching
* composables/useHashState.ts's own `hasWindow` convention). The
* clipboard fallback (copyText, Q8) runs
* ONLY when the Blob/URL/anchor machinery itself is unavailable or throws
* (a Blob/URL API genuinely unsupported, or the synthetic click rejected)
* — never merely because a download was silently blocked by the browser,
+1 -1
View File
@@ -64,7 +64,7 @@ export interface EventFilter {
* filterEvents narrows the (already newest-first) event list to the
* current kind/user selection. `filter.user` is matched pre-normalized —
* callers pair this with useSearchQuery()'s own `normalized` the same way
* every other search filter in this panel does (ProvidersView.vue's
* every other search filter in this panel does (ProviderHealthPanel.vue's
* modelMatches, lib/usage-search.ts) — this function itself does no
* trimming or casing of its own beyond a defensive `.trim().toLowerCase()`
* so it is still correct if a caller passes the raw query directly.
+2 -2
View File
@@ -98,8 +98,8 @@ describe('formatModelCostHover', () => {
// Discovery circuit breaker (feat/provider-health, round 3): openUntil's
// future-time counterpart to formatAgo's past-time "(Ns ago)". Only
// caller today is ProvidersView.vue's healthBadgeLabel, rendering the
// breaker's live "open (in Ns)" countdown.
// caller today is ProviderHealthPanel.vue's healthBadgeLabel, rendering
// the breaker's live "open (in Ns)" countdown.
describe('formatUntil', () => {
it('renders "" for the unset zero-time sentinel', () => {
expect(formatUntil(ZERO_TIME)).toBe('')
+70 -91
View File
@@ -1,153 +1,132 @@
import { describe, expect, it } from 'vitest'
import {
buildHash,
DEFAULT_TAB,
parseChartsParams,
parseEventsParams,
parseHashState,
parseUsageParams,
} from './hash-state'
import { PAGE_IDS } from './pages'
import { buildHash, parseGlobalParams, parseHashState, parsePageParams } from './hash-state'
describe('parseHashState', () => {
it('splits a tab and query string', () => {
const { tab, query } = parseHashState('#charts?window=day&scope=user%3Aalice')
expect(tab).toBe('charts')
expect(query.get('window')).toBe('day')
expect(query.get('scope')).toBe('user:alice')
it('splits a page and query string', () => {
const { page, query } = parseHashState('#spend?range=30d&by=model')
expect(page).toBe('spend')
expect(query.get('range')).toBe('30d')
expect(query.get('by')).toBe('model')
})
it('parses a bare tab with no query to an empty query', () => {
const { tab, query } = parseHashState('#usage')
expect(tab).toBe('usage')
it('parses a bare page with no query to an empty query', () => {
const { page, query } = parseHashState('#consumers')
expect(page).toBe('consumers')
expect(Array.from(query.keys())).toEqual([])
})
it('falls back to DEFAULT_TAB for an empty hash', () => {
expect(parseHashState('').tab).toBe(DEFAULT_TAB)
it('falls back to DEFAULT_PAGE ("home") for an empty hash', () => {
expect(parseHashState('').page).toBe('home')
})
it('falls back to DEFAULT_TAB for an unrecognized tab, and still parses its query', () => {
const { tab, query } = parseHashState('#bogus?window=day')
expect(tab).toBe(DEFAULT_TAB)
expect(query.get('window')).toBe('day')
it('falls back to DEFAULT_PAGE for an unrecognized, non-legacy page, and still parses its query', () => {
const { page, query } = parseHashState('#bogus?range=30d')
expect(page).toBe('home')
expect(query.get('range')).toBe('30d')
})
it('accepts a hash with or without the leading #', () => {
expect(parseHashState('targets').tab).toBe('targets')
expect(parseHashState('targets').page).toBe('targets')
})
it('every valid tab round-trips through parseHashState(buildHash(...))', () => {
for (const tab of ['providers', 'usage', 'charts', 'targets', 'events'] as const) {
expect(parseHashState(buildHash(tab, {})).tab).toBe(tab)
it('every current page id round-trips through parseHashState(buildHash(...))', () => {
for (const page of PAGE_IDS) {
expect(parseHashState(buildHash(page, {})).page).toBe(page)
}
})
describe('legacy tab-name mapping', () => {
it.each([
['providers', 'models'],
['usage', 'consumers'],
['charts', 'spend'],
['events', 'reliability'],
['targets', 'targets'],
] as const)('#%s maps to #%s', (legacy, mapped) => {
expect(parseHashState(`#${legacy}`).page).toBe(mapped)
})
it('drops the old hash\'s query params entirely, regardless of shape', () => {
const { page, query } = parseHashState('#charts?tab=models&metric=cost&scope=user%3Aalice')
expect(page).toBe('spend')
expect(Array.from(query.keys())).toEqual([])
})
})
})
describe('buildHash', () => {
it('omits the query suffix entirely when params is empty', () => {
expect(buildHash('providers', {})).toBe('#providers')
expect(buildHash('home', {})).toBe('#home')
})
it('appends a query string for non-empty params', () => {
expect(buildHash('charts', { window: 'day' })).toBe('#charts?window=day')
expect(buildHash('spend', { range: '30d' })).toBe('#spend?range=30d')
})
it('joins multiple params with &', () => {
const hash = buildHash('charts', { window: 'day', scope: 'user:alice' })
expect(hash).toBe('#charts?window=day&scope=user%3Aalice')
it('joins multiple params with &, in insertion order', () => {
const hash = buildHash('spend', { range: '30d', cmp: 'prev', scope: 'user:alice' })
expect(hash).toBe('#spend?range=30d&cmp=prev&scope=user%3Aalice')
})
it('drops a param whose value is an explicit empty string', () => {
expect(buildHash('usage', { q: '' })).toBe('#usage')
expect(buildHash('consumers', { q: '' })).toBe('#consumers')
})
})
describe('parseChartsParams', () => {
it('accepts every valid window/tab/metric value', () => {
const p = parseChartsParams(new URLSearchParams('window=month&tab=models&metric=tokin'))
expect(p).toEqual({ window: 'month', tab: 'models', metric: 'tokin' })
describe('parseGlobalParams', () => {
it('accepts every valid range/cmp/scope value', () => {
const p = parseGlobalParams(new URLSearchParams('range=30d&cmp=prev&scope=group:eng'))
expect(p).toEqual({ range: '30d', cmp: 'prev', scope: 'group:eng' })
})
it('omits window when it is not a recognized HistoryWindow', () => {
expect(parseChartsParams(new URLSearchParams('window=fortnight'))).toEqual({})
it('omits range when it is not a recognized RangeKey', () => {
expect(parseGlobalParams(new URLSearchParams('range=fortnight'))).toEqual({})
})
it('omits tab when it is not a recognized ChartTab', () => {
expect(parseChartsParams(new URLSearchParams('tab=bogus'))).toEqual({})
})
it('omits metric when it is not a recognized ModelMetric', () => {
expect(parseChartsParams(new URLSearchParams('metric=bogus'))).toEqual({})
})
it('reads filter when present (free text, not an enum — same convention as scope/q)', () => {
expect(parseChartsParams(new URLSearchParams('filter=openai%2F'))).toEqual({ filter: 'openai/' })
})
it('omits filter when absent', () => {
expect(parseChartsParams(new URLSearchParams())).toEqual({})
})
it('omits filter when it is an explicit empty string', () => {
expect(parseChartsParams(new URLSearchParams('filter='))).toEqual({})
it('omits cmp when it is not "none" or "prev"', () => {
expect(parseGlobalParams(new URLSearchParams('cmp=bogus'))).toEqual({})
})
it.each([
['total', true],
['all', true],
['group:eng', true],
['user:alice', true],
['group:ops', true],
['model:openai/gpt-5', true],
['user:', false], // empty id after the colon
['provider:openai', true],
['user:', false],
['bogus', false],
['', false],
] as const)('scope %s valid=%s', (scope, valid) => {
const p = parseChartsParams(new URLSearchParams(scope ? `scope=${encodeURIComponent(scope)}` : ''))
const p = parseGlobalParams(new URLSearchParams(scope ? `scope=${encodeURIComponent(scope)}` : ''))
expect(p.scope === scope).toBe(valid)
})
it('omits one invalid field while keeping the other valid ones (independent per-field validation)', () => {
const p = parseChartsParams(new URLSearchParams('window=fortnight&tab=models&scope=total'))
expect(p).toEqual({ tab: 'models', scope: 'total' })
it('omits one invalid field while keeping the other valid ones', () => {
const p = parseGlobalParams(new URLSearchParams('range=fortnight&cmp=prev&scope=all'))
expect(p).toEqual({ cmp: 'prev', scope: 'all' })
})
it('returns an empty object for an empty query', () => {
expect(parseChartsParams(new URLSearchParams())).toEqual({})
expect(parseGlobalParams(new URLSearchParams())).toEqual({})
})
})
describe('parseUsageParams', () => {
it('reads q when present', () => {
expect(parseUsageParams(new URLSearchParams('q=alice'))).toEqual({ q: 'alice' })
describe('parsePageParams', () => {
it('excludes range/cmp/scope but keeps every other key', () => {
const p = parsePageParams(new URLSearchParams('range=30d&cmp=prev&scope=all&by=model&metric=cost'))
expect(p).toEqual({ by: 'model', metric: 'cost' })
})
it('omits q when absent', () => {
expect(parseUsageParams(new URLSearchParams())).toEqual({})
it('drops a key with an explicit empty-string value', () => {
expect(parsePageParams(new URLSearchParams('q='))).toEqual({})
})
it('omits q when it is an explicit empty string', () => {
expect(parseUsageParams(new URLSearchParams('q='))).toEqual({})
})
})
describe('parseEventsParams', () => {
it('reads kind and user when both present', () => {
expect(parseEventsParams(new URLSearchParams('kind=rate_limit&user=bob'))).toEqual({
kind: 'rate_limit',
user: 'bob',
})
})
it('accepts a kind this build does not recognize as an AdminEventKind (forward-compat, never rejected)', () => {
expect(parseEventsParams(new URLSearchParams('kind=some_future_kind'))).toEqual({ kind: 'some_future_kind' })
})
it('omits either field independently when absent', () => {
expect(parseEventsParams(new URLSearchParams('kind=budget'))).toEqual({ kind: 'budget' })
expect(parseEventsParams(new URLSearchParams('user=alice'))).toEqual({ user: 'alice' })
it('passes through a key this build does not recognize (forward-compat)', () => {
expect(parsePageParams(new URLSearchParams('someFutureParam=x'))).toEqual({ someFutureParam: 'x' })
})
it('returns an empty object for an empty query', () => {
expect(parseEventsParams(new URLSearchParams())).toEqual({})
expect(parsePageParams(new URLSearchParams())).toEqual({})
})
})
+90 -99
View File
@@ -1,135 +1,126 @@
import { hashToTab, TAB_VALUES, tabToHash, type TabValue } from '@/composables/useTabHash'
import type { ChartTab, ModelMetric } from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
import { DEFAULT_PAGE, isPageId } from '@/lib/pages'
import type { PageId } from '@/lib/pages'
import { isCmpMode, isRangeKey } from '@/lib/range'
import type { CmpMode, RangeKey } from '@/lib/range'
/**
* DEFAULT_TAB is the app's landing tab when the hash names nothing
* recognized — parseHashState's own fallback, mirroring the old
* useTabHash's defaultTab parameter (which every call site passed
* 'providers' for anyway, so this needed no parameter of its own once
* folded into one composable, composables/useHashState.ts).
* LEGACY_PAGE_MAP redirects the pre-redesign flat-tab hash names to their
* redesign home (redesign-plan.md section 3.1's legacy mapping): a
* bookmark or shared link built against the old #providers/#usage/
* #charts/#events/#targets shell still lands somewhere sensible instead
* of falling back to the default page. `targets` keeps its own name —
* the MCP & Agents page is the one page that did not move.
*
* The OLD hash's query params never carry over (see parseHashState below)
* — they were shaped for the old tab (e.g. #charts's `tab=models&metric=
* cost`), and none of that maps cleanly onto the new page's own param
* names, so a legacy hash lands on a clean instance of its mapped page
* rather than misapplying a stale param.
*/
export const DEFAULT_TAB: TabValue = 'providers'
const LEGACY_PAGE_MAP: Record<string, PageId> = {
providers: 'models',
usage: 'consumers',
charts: 'spend',
events: 'reliability',
targets: 'targets',
}
export interface HashState {
tab: TabValue
page: PageId
query: URLSearchParams
}
/**
* parseHashState splits a raw location.hash ("#charts?window=day&scope=
* user:alice") into its outer tab — validated against TAB_VALUES via
* hashToTab, falling back to DEFAULT_TAB exactly like the pre-F9 useTabHash
* did for an absent/unrecognized/bogus tab — and the remaining query
* string, parsed once into a URLSearchParams every per-tab validator below
* reads from. A hash with no "?" (a bare "#usage", or the very first load
* with no hash at all) parses to an empty query, never throws.
* parseHashState splits a raw location.hash ("#spend?range=30d&by=model")
* into its outer page and the remaining query string. Three cases:
*
* 1. The page segment is a current PageId (lib/pages.ts) — parse the
* query normally.
* 2. The page segment is a legacy tab name (LEGACY_PAGE_MAP) — map to the
* new page, with an EMPTY query (old params dropped, see the map's own
* doc comment).
* 3. Anything else (absent, empty, or unrecognized) — DEFAULT_PAGE, query
* parsed as-is (so a malformed page segment does not also swallow an
* otherwise-valid query string).
*
* Never throws.
*/
export function parseHashState(hash: string): HashState {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
const qIndex = raw.indexOf('?')
const tabPart = qIndex === -1 ? raw : raw.slice(0, qIndex)
const pagePart = qIndex === -1 ? raw : raw.slice(0, qIndex)
const queryPart = qIndex === -1 ? '' : raw.slice(qIndex + 1)
const tab = hashToTab(tabPart, TAB_VALUES, DEFAULT_TAB)
return { tab, query: new URLSearchParams(queryPart) }
if (isPageId(pagePart)) return { page: pagePart, query: new URLSearchParams(queryPart) }
const legacy = LEGACY_PAGE_MAP[pagePart]
if (legacy) return { page: legacy, query: new URLSearchParams() }
return { page: DEFAULT_PAGE, query: new URLSearchParams(queryPart) }
}
/**
* buildHash re-serializes a tab plus an already-default-stripped param
* record back into a "#<tab>?<query>" string (F9) — mirrors tabToHash for
* the tab-only portion, adding a "?<query>" suffix only when `params` has
* at least one entry, so the common "nothing but the default selection"
* case round-trips to a bare "#<tab>" rather than a noisy "#<tab>?".
*
* "Omit defaults" is the CALLER's job, not this function's: what counts
* as default differs per field (window's default is 'hour', scope's is
* 'total', ...), so composables/useHashState.ts's own computed simply
* never puts a default-valued key into `params` in the first place.
* buildHash itself only defends against an explicitly empty-string value
* slipping through, dropping it the same way.
* buildHash re-serializes a page plus an already-default-stripped param
* record (global filters and/or page params merged by the caller) back
* into a "#<page>?<query>" string. "Omit defaults" is the CALLER's job —
* composables/useHashState.ts's own computed decides what counts as
* default for range/cmp/scope; buildHash itself only defends against an
* explicitly empty-string value slipping through, dropping it the same
* way. No query suffix at all when `params` ends up empty, so the common
* "nothing but defaults" case round-trips to a bare "#<page>".
*/
export function buildHash(tab: TabValue, params: Record<string, string>): string {
export function buildHash(page: PageId, params: Record<string, string>): string {
const entries = Object.entries(params).filter(([, v]) => v !== '')
if (entries.length === 0) return tabToHash(tab)
return `${tabToHash(tab)}?${new URLSearchParams(entries).toString()}`
if (entries.length === 0) return `#${page}`
return `#${page}?${new URLSearchParams(entries).toString()}`
}
const HISTORY_WINDOWS: readonly HistoryWindow[] = ['hour', 'day', 'month']
const CHART_TABS: readonly ChartTab[] = ['requests', 'tokens', 'cost', 'models']
const MODEL_METRICS: readonly ModelMetric[] = ['req', 'tokin', 'tokout', 'cost']
/** SCOPE_PATTERN mirrors history.ts's own scope string convention: "total", or one of the three "{kind}:{id}" forms — an id may itself contain almost anything (a model id can contain slashes), so each kind only checks its own prefix plus a non-empty remainder, never the id's own shape. */
const SCOPE_PATTERN = /^(total|user:.+|group:.+|model:.+)$/
export interface ChartsHashParams {
window?: HistoryWindow
tab?: ChartTab
metric?: ModelMetric
export interface GlobalHashParams {
range?: RangeKey
cmp?: CmpMode
scope?: string
/**
* The Models tab's provider-prefix filter (stores/history.ts's
* modelFilter), round-tripped through the hash so a provider-header
* link (nav.goToModels) or a hand-edited `#charts?tab=models&filter=`
* URL survives a reload or a hashchange — free text, not an enum, same
* convention as `scope` below and Usage's own `q` param.
*/
filter?: string
}
/** SCOPE_PATTERN mirrors the global filter bar's own scope convention (redesign-plan.md section 3.1): "all", or one of the three "{kind}:{id}" forms — an id may contain almost anything, so each kind only checks its own prefix plus a non-empty remainder. Exported so GlobalFilterBar.vue validates its own scope input against the exact same rule this module uses to parse it back out of the hash. */
export const SCOPE_PATTERN = /^(all|group:.+|user:.+|provider:.+)$/
/**
* parseChartsParams validates the Charts tab's own hash query params.
* Each field is independently OMITTED (never throws, never substitutes a
* fabricated default itself) when absent or not one of its own known
* values — a hand-edited or stale-version URL with `window=fortnight` or
* `metric=bogus` degrades to "use whatever this field already is"
* (composables/useHashState.ts's own caller decides the actual default),
* rather than failing the whole hash or resetting every OTHER, valid
* field alongside the one bad one.
* parseGlobalParams validates the three page-independent hash params
* (range/cmp/scope) every page's hash shares. Each field is independently
* OMITTED (never throws) when absent or not a recognized value — a
* hand-edited or stale-version URL with `range=fortnight` degrades to
* "use whatever this field already is" (the caller decides the actual
* default), rather than failing the whole hash or resetting the other,
* valid fields alongside the one bad one.
*/
export function parseChartsParams(query: URLSearchParams): ChartsHashParams {
const result: ChartsHashParams = {}
const window = query.get('window')
if (window && (HISTORY_WINDOWS as readonly string[]).includes(window)) result.window = window as HistoryWindow
const tab = query.get('tab')
if (tab && (CHART_TABS as readonly string[]).includes(tab)) result.tab = tab as ChartTab
const metric = query.get('metric')
if (metric && (MODEL_METRICS as readonly string[]).includes(metric)) result.metric = metric as ModelMetric
export function parseGlobalParams(query: URLSearchParams): GlobalHashParams {
const result: GlobalHashParams = {}
const range = query.get('range')
if (range && isRangeKey(range)) result.range = range
const cmp = query.get('cmp')
if (cmp && isCmpMode(cmp)) result.cmp = cmp
const scope = query.get('scope')
if (scope && SCOPE_PATTERN.test(scope)) result.scope = scope
const filter = query.get('filter')
if (filter) result.filter = filter
return result
}
export interface UsageHashParams {
q?: string
}
/** parseUsageParams reads the Usage tab's own single `q` param. It is free-text search input, not an enum, so there is nothing to validate beyond "present and non-empty" — any string round-trips as-is. */
export function parseUsageParams(query: URLSearchParams): UsageHashParams {
const q = query.get('q')
return q ? { q } : {}
}
export interface EventsHashParams {
kind?: string
user?: string
}
/** GLOBAL_PARAM_KEYS names the three query keys parseGlobalParams reads — parsePageParams below excludes exactly these, and nothing else, from a page's own param record. */
const GLOBAL_PARAM_KEYS: ReadonlySet<string> = new Set(['range', 'cmp', 'scope'])
/**
* parseEventsParams reads the Events tab's own kind/user params. `kind` is
* deliberately NOT validated against AdminEventKind here (unlike Charts'
* enum fields above): lib/events-filter.ts's filterEvents already treats
* an unrecognized kind as "matches nothing" harmlessly, and rejecting it
* here would silently drop a link built against a server-added kind this
* webui build predates — the same "still carry it through" convention
* AdminEventView.kind's own `| string` union documents (types/api.ts).
* parsePageParams returns every hash query param EXCEPT the three global
* ones, verbatim, as a plain record. Deliberately opaque (no per-page
* validation here — see stores/nav.ts's own doc comment for why): each
* page owns interpreting its own param names/values, and a key this
* build does not recognize (an older link, or one from a newer page)
* simply passes through unused rather than being rejected.
*/
export function parseEventsParams(query: URLSearchParams): EventsHashParams {
const result: EventsHashParams = {}
const kind = query.get('kind')
const user = query.get('user')
if (kind) result.kind = kind
if (user) result.user = user
export function parsePageParams(query: URLSearchParams): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of query.entries()) {
if (GLOBAL_PARAM_KEYS.has(key)) continue
if (value === '') continue
result[key] = value
}
return result
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import {
budgetRatio,
budgetTier,
errorRateTier,
fleetErrorRate,
openBreakerCount,
sumGroupBudgetMicros,
} from './kpi'
import type { AdminGroupView, AdminProviderView } from '@/types/api'
function makeGroup(overrides: Partial<AdminGroupView> = {}): AdminGroupView {
return { name: 'eng', memberCount: 3, ...overrides }
}
function makeProvider(overrides: Partial<AdminProviderView> = {}): AdminProviderView {
return {
name: 'openai',
type: 'openai',
baseUrl: 'https://api.openai.com',
lastRefresh: '0001-01-01T00:00:00Z',
models: [],
modelCount: 0,
modelMeta: {},
discoveryEnabled: false,
healthState: 'closed',
openUntil: '0001-01-01T00:00:00Z',
attemptsDay: 0,
failuresDay: 0,
attemptsMinute: 0,
failuresMinute: 0,
modelRates: {},
...overrides,
}
}
describe('sumGroupBudgetMicros', () => {
it('sums every group\'s costPerMonthUSD limit, converted to micro-USD', () => {
const groups = [
makeGroup({ limits: { costPerMonthUSD: 10 } }),
makeGroup({ limits: { costPerMonthUSD: 25.5 } }),
]
expect(sumGroupBudgetMicros(groups)).toBe(35_500_000)
})
it('skips a group with no costPerMonthUSD configured', () => {
const groups = [makeGroup({ limits: undefined }), makeGroup({ limits: { costPerMonthUSD: 0 } })]
expect(sumGroupBudgetMicros(groups)).toBe(0)
})
it('returns 0 for an empty group list', () => {
expect(sumGroupBudgetMicros([])).toBe(0)
})
})
describe('budgetRatio / budgetTier', () => {
it('returns null when budgetMicros is 0 ("no budget set")', () => {
expect(budgetRatio(5_000_000, 0)).toBeNull()
expect(budgetTier(null)).toBe('ok')
})
it('classifies below 0.8 as ok, [0.8, 1.0) as warn, >= 1.0 as critical', () => {
expect(budgetTier(budgetRatio(700_000, 1_000_000))).toBe('ok')
expect(budgetTier(budgetRatio(900_000, 1_000_000))).toBe('warn')
expect(budgetTier(budgetRatio(1_200_000, 1_000_000))).toBe('critical')
})
})
describe('openBreakerCount', () => {
it('counts providers whose breaker is not closed', () => {
const providers = [
makeProvider({ healthState: 'closed' }),
makeProvider({ healthState: 'open' }),
makeProvider({ healthState: 'half-open' }),
]
expect(openBreakerCount(providers)).toBe(2)
})
it('returns 0 when every breaker is closed', () => {
expect(openBreakerCount([makeProvider(), makeProvider()])).toBe(0)
})
})
describe('fleetErrorRate / errorRateTier', () => {
it('sums attemptsMinute/failuresMinute across every provider', () => {
const providers = [
makeProvider({ attemptsMinute: 100, failuresMinute: 1 }),
makeProvider({ attemptsMinute: 50, failuresMinute: 4 }),
]
const result = fleetErrorRate(providers)
expect(result.attempts).toBe(150)
expect(result.failures).toBe(5)
expect(result.rate).toBeCloseTo(5 / 150)
})
it('rate is null when there were no attempts (never a fabricated 0%)', () => {
const result = fleetErrorRate([makeProvider({ attemptsMinute: 0, failuresMinute: 0 })])
expect(result.rate).toBeNull()
expect(errorRateTier(result.rate)).toBe('ok')
})
it('classifies error rate into ok/warn/critical using the shared provider-rate thresholds', () => {
expect(errorRateTier(0.001)).toBe('ok') // 99.9% success
expect(errorRateTier(0.05)).toBe('warn') // 95% success, below the 99% amber threshold
expect(errorRateTier(0.2)).toBe('critical') // 80% success, below the 90% destructive threshold
})
})
+91
View File
@@ -0,0 +1,91 @@
// Pure math behind the Home page's KpiTile.vue tiles (redesign-plan.md
// section 3.4) — kept out of the component/page the same way lib/
// provider-rate.ts and lib/usage-bars.ts keep their own tier math out of
// their components, so vitest's node-environment config exercises it
// directly, no component mount required. Deliberately reuses those two
// modules' existing thresholds (BAR_AMBER_THRESHOLD/BAR_RED_THRESHOLD,
// PROVIDER_RATE_AMBER_THRESHOLD/PROVIDER_RATE_DESTRUCTIVE_THRESHOLD)
// rather than declaring a third, independent set of budget/error-rate
// boundaries for the same two underlying concepts (a used/limit ratio,
// a success rate) the rest of this panel already colors consistently.
import { providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
import { MICROS_PER_USD, ratioTier } from '@/lib/usage-bars'
import type { AdminGroupView, AdminProviderView } from '@/types/api'
/** KpiTier is KpiTile.vue's own three-color scale — 'ok' (normal), 'warn' (approaching a limit / minor degradation), 'critical' (over a limit / severe degradation). */
export type KpiTier = 'ok' | 'warn' | 'critical'
/**
* sumGroupBudgetMicros sums every configured group's costPerMonthUSD
* limit, converted to micro-USD (Q2, redesign-plan.md DECISIONS: "Home
* budget baseline = sum of group costPerMonthUSD"). A group with no
* costPerMonthUSD configured (0 or undefined) contributes nothing — the
* caller reads a 0 total as "no budget set" (budgetRatio below), never a
* genuine zero-dollar budget.
*/
export function sumGroupBudgetMicros(groups: AdminGroupView[]): number {
let total = 0
for (const group of groups) {
const limitUsd = group.limits?.costPerMonthUSD
if (limitUsd && limitUsd > 0) total += Math.round(limitUsd * MICROS_PER_USD)
}
return total
}
/** budgetRatio is `spentMicros / budgetMicros`, or null when budgetMicros is 0 or negative ("no budget set" — Q2) — the fleet has nothing configured to compare month-to-date spend against. */
export function budgetRatio(spentMicros: number, budgetMicros: number): number | null {
if (budgetMicros <= 0) return null
return spentMicros / budgetMicros
}
/** budgetTier classifies a budgetRatio result into the Home spend tile's KpiTier, reusing lib/usage-bars.ts's own ratioTier thresholds (0.8 amber, 1.0 red) so a budget tile and a per-scope UsageBar agree on what "approaching the limit" means. A null ratio (no budget set) reads as 'ok' — there is nothing to warn about. */
export function budgetTier(ratio: number | null): KpiTier {
if (ratio === null) return 'ok'
switch (ratioTier(ratio)) {
case 'normal':
return 'ok'
case 'amber':
return 'warn'
case 'red':
return 'critical'
}
}
/** openBreakerCount counts providers whose discovery circuit breaker is not 'closed' (AdminProviderView.healthState) — the Home "open breakers" tile's own count, per-replica like the field it reads (see AdminProviderView.healthState's own doc comment). */
export function openBreakerCount(providers: AdminProviderView[]): number {
return providers.filter((p) => p.healthState !== 'closed').length
}
/** FleetErrorRate is the Home error-rate tile's fleet-wide reading: summed minute-window attempts/failures across every provider, and the resulting rate. */
export interface FleetErrorRate {
/** failures/attempts as a 0-1 fraction, or null when there were no attempts in the last minute (never a fabricated 0%). */
rate: number | null
attempts: number
failures: number
}
/** fleetErrorRate sums every provider's attemptsMinute/failuresMinute (AdminProviderView — the SAME per-replica, in-process minute counters ProviderRateBadge.vue's own minuteRateTitle reads) into one fleet-wide error rate. */
export function fleetErrorRate(providers: AdminProviderView[]): FleetErrorRate {
let attempts = 0
let failures = 0
for (const p of providers) {
attempts += p.attemptsMinute
failures += p.failuresMinute
}
const success = providerSuccessRate(attempts, failures)
return { rate: success === null ? null : 1 - success, attempts, failures }
}
/** errorRateTier classifies a fleetErrorRate() result into the Home error-rate tile's KpiTier, reusing lib/provider-rate.ts's own success-rate thresholds (99% amber, 90% red) inverted for an error-rate reading — the same boundaries the Providers tab's own badges already use. */
export function errorRateTier(rate: number | null): KpiTier {
if (rate === null) return 'ok'
switch (providerRateStatus(1 - rate)) {
case 'no-traffic':
case 'healthy':
return 'ok'
case 'degraded':
return 'warn'
case 'severe':
return 'critical'
}
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { formatLastSeen } from './last-seen'
const NOW = new Date(Date.UTC(2026, 3, 16, 12, 0, 0))
const NOW_SECONDS = Math.floor(NOW.getTime() / 1000)
describe('formatLastSeen', () => {
it.each([
[undefined, 'never'],
[0, 'never'],
])('%s seconds -> "never" (types/api.ts: 0/omitted means never seen or the feature is off)', (seconds, expected) => {
expect(formatLastSeen(seconds, NOW)).toBe(expected)
})
it('renders "just now" for a timestamp under a minute old', () => {
expect(formatLastSeen(NOW_SECONDS - 30, NOW)).toBe('just now')
})
it('renders "just now" for a timestamp in the FUTURE (clock skew), clamping delta to 0 rather than a negative duration', () => {
expect(formatLastSeen(NOW_SECONDS + 500, NOW)).toBe('just now')
})
it('renders minutes under an hour', () => {
expect(formatLastSeen(NOW_SECONDS - 5 * 60, NOW)).toBe('5m ago')
})
it('renders hours under a day', () => {
expect(formatLastSeen(NOW_SECONDS - 3 * 3600, NOW)).toBe('3h ago')
})
it('renders days under the month threshold', () => {
expect(formatLastSeen(NOW_SECONDS - 10 * 86400, NOW)).toBe('10d ago')
})
it('renders months at/beyond the month threshold', () => {
expect(formatLastSeen(NOW_SECONDS - 90 * 86400, NOW)).toBe('3mo ago')
})
it('boundary: exactly 59 seconds is still "just now"', () => {
expect(formatLastSeen(NOW_SECONDS - 59, NOW)).toBe('just now')
})
it('boundary: exactly 60 seconds is "1m ago"', () => {
expect(formatLastSeen(NOW_SECONDS - 60, NOW)).toBe('1m ago')
})
it('boundary: exactly 23h59m is still hours, not a day', () => {
expect(formatLastSeen(NOW_SECONDS - (86400 - 60), NOW)).toBe('23h ago')
})
it('defaults `now` to the real current time when omitted', () => {
const nowSeconds = Math.floor(Date.now() / 1000)
expect(formatLastSeen(nowSeconds)).toBe('just now')
})
})
+39
View File
@@ -0,0 +1,39 @@
// Consumers page (redesign-plan.md section 3.4): pure relative-time
// formatting for AdminUsageEntryView.lastSeen — a fleet-wide, throttled
// (60s/replica/scope) unix-seconds timestamp (types/api.ts's own doc
// comment). Kept out of usage-columns.ts/ConsumerDirectory.vue the same way
// every other pure display helper in this panel (lib/format.ts, lib/
// usage-bars.ts, lib/forecast.ts) is — vitest's node-environment config
// exercises it directly, no component mount or fake timers required (every
// function here takes `now` as an explicit argument, never reads Date.now()
// itself, matching lib/forecast.ts's own determinism convention).
const SECONDS_PER_MINUTE = 60
const SECONDS_PER_HOUR = 3600
const SECONDS_PER_DAY = 86400
/** MONTH_THRESHOLD_DAYS is where formatLastSeen switches from "Nd ago" to "Nmo ago" — 2 months' worth of days, so a scope last seen 6 weeks ago still reads as a legible day count rather than rounding down to "1mo ago" (a needlessly coarse figure for something that recent). */
const MONTH_THRESHOLD_DAYS = 60
const DAYS_PER_MONTH_APPROX = 30
/**
* formatLastSeen renders a scope's last-seen unix-seconds timestamp as a
* short relative-time label: "just now" under a minute, "Nm ago" under an
* hour, "Nh ago" under a day, "Nd ago" under MONTH_THRESHOLD_DAYS, "Nmo
* ago" beyond it. `undefined` or `0` means "never seen, or admin.stats.
* lastSeen is off" (types/api.ts's own doc comment: "never render 0 as an
* epoch date") — rendered as the literal "never", never "0s ago" or a
* fabricated 1970 date. A timestamp in the future (clock skew between this
* replica and the reader's own clock) clamps to "just now" rather than a
* nonsensical negative duration.
*/
export function formatLastSeen(seconds: number | undefined, now: Date = new Date()): string {
if (!seconds) return 'never'
const nowSeconds = Math.floor(now.getTime() / 1000)
const delta = Math.max(0, nowSeconds - seconds)
if (delta < SECONDS_PER_MINUTE) return 'just now'
if (delta < SECONDS_PER_HOUR) return `${Math.floor(delta / SECONDS_PER_MINUTE)}m ago`
if (delta < SECONDS_PER_DAY) return `${Math.floor(delta / SECONDS_PER_HOUR)}h ago`
const days = Math.floor(delta / SECONDS_PER_DAY)
if (days < MONTH_THRESHOLD_DAYS) return `${days}d ago`
return `${Math.floor(days / DAYS_PER_MONTH_APPROX)}mo ago`
}
+36 -21
View File
@@ -1,38 +1,53 @@
import { describe, expect, it } from 'vitest'
import { filterModelsByPrefix } from './model-filter'
import type { AdminUsageModelEntry } from '@/types/api'
import { filterModelRows } from './model-filter'
import type { ModelCatalogRow } from './model-table-columns'
const models: AdminUsageModelEntry[] = [
{ id: 'openai/gpt-5', value: 10 },
{ id: 'openai/gpt-5-mini', value: 5 },
{ id: 'anthropic/claude-opus', value: 8 },
function row(overrides: Partial<ModelCatalogRow> = {}): ModelCatalogRow {
return {
id: 'openai/gpt-5',
model: 'gpt-5',
providerName: 'openai',
priceSource: 'builtin',
aliases: [],
requests: 0,
tokensIn: 0,
tokensOut: 0,
costMicroUsd: 0,
attempts: 0,
failures: 0,
...overrides,
}
}
const rows: ModelCatalogRow[] = [
row({ id: 'openai/gpt-5' }),
row({ id: 'openai/gpt-5-mini' }),
row({ id: 'anthropic/claude-opus', aliases: ['smart', 'flagship'] }),
]
describe('filterModelsByPrefix', () => {
it('returns every entry unchanged when the prefix is empty', () => {
expect(filterModelsByPrefix(models, '')).toBe(models)
describe('filterModelRows', () => {
it('returns every row unchanged for an empty query', () => {
expect(filterModelRows(rows, '')).toBe(rows)
})
it('keeps only ids starting with the given provider prefix', () => {
const result = filterModelsByPrefix(models, 'openai/')
expect(result.map((m) => m.id)).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
it('returns every row unchanged for a whitespace-only query', () => {
expect(filterModelRows(rows, ' ')).toBe(rows)
})
it('matches a full id exactly (a single-model prefix)', () => {
const result = filterModelsByPrefix(models, 'anthropic/claude-opus')
expect(result).toEqual([{ id: 'anthropic/claude-opus', value: 8 }])
it('matches a substring of the canonical id, case-insensitively', () => {
expect(filterModelRows(rows, 'GPT-5').map((r) => r.id)).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
})
it('returns an empty array when nothing matches the prefix', () => {
expect(filterModelsByPrefix(models, 'mistral/')).toEqual([])
it('matches a provider prefix', () => {
expect(filterModelRows(rows, 'anthropic/').map((r) => r.id)).toEqual(['anthropic/claude-opus'])
})
it('is case-sensitive (ids are canonical, lowercase provider/model strings)', () => {
expect(filterModelsByPrefix(models, 'OpenAI/')).toEqual([])
it('matches an alias, case-insensitively', () => {
expect(filterModelRows(rows, 'FLAGSHIP').map((r) => r.id)).toEqual(['anthropic/claude-opus'])
})
it('does not match a substring that is not a prefix', () => {
expect(filterModelsByPrefix(models, 'gpt-5')).toEqual([])
it('returns an empty array when nothing matches', () => {
expect(filterModelRows(rows, 'mistral')).toEqual([])
})
})
+13 -11
View File
@@ -1,15 +1,17 @@
import type { AdminUsageModelEntry } from '@/types/api'
import type { ModelCatalogRow } from '@/lib/model-table-columns'
/**
* filterModelsByPrefix narrows the Models tab's already-fetched ranking to
* entries whose canonical "provider/model" id starts with `prefix` (F6,
* dashboard-plan.md) — the client-side filter ChartsView.vue applies on
* top of stores/history.ts's own `modelFilter` state, set by
* ProvidersView.vue's provider-header link (nav.goToModels, stores/nav.ts
* — WP-B1). An empty prefix is "no filter", returning `models` unchanged
* — the default state before any provider link has been clicked.
* filterModelRows narrows the Models page's already-fetched catalog rows
* (lib/model-table-columns.ts's ModelCatalogRow, built from GET
* /admin/api/catalog + usage/models + performance) to whichever match a
* free-text query (redesign-plan.md section 3.1's Models page `q` param) —
* against the canonical id (ModelChip's own copyable text) OR any of the
* model's own aliases, case-insensitively. An empty/whitespace-only query
* is "no filter", returning `rows` unchanged (the pre-redesign
* filterModelsByPrefix this function replaces made the identical choice).
*/
export function filterModelsByPrefix(models: AdminUsageModelEntry[], prefix: string): AdminUsageModelEntry[] {
if (!prefix) return models
return models.filter((m) => m.id.startsWith(prefix))
export function filterModelRows(rows: ModelCatalogRow[], query: string): ModelCatalogRow[] {
const q = query.trim().toLowerCase()
if (!q) return rows
return rows.filter((r) => r.id.toLowerCase().includes(q) || r.aliases.some((a) => a.toLowerCase().includes(q)))
}
+222 -103
View File
@@ -2,138 +2,257 @@ import type { VNode } from 'vue'
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import EntityLink from '@/components/EntityLink.vue'
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'
import { buildModelCatalogRows, modelCatalogColumns } from './model-table-columns'
import type { ModelCatalogRow } from './model-table-columns'
import type { AdminCatalogResponse, AdminPerfResponse, AdminUsageModelsResponse } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageModelEntry> = {}): AdminUsageModelEntry {
function testRow(overrides: Partial<ModelCatalogRow> = {}): ModelCatalogRow {
return {
id: 'openai/gpt-5',
value: 42,
requests: 10,
tokensIn: 100,
tokensOut: 200,
costMicroUsd: 1_234_500,
free: false,
model: 'gpt-5',
providerName: 'openai',
priceSource: 'builtin',
aliases: [],
requests: 0,
tokensIn: 0,
tokensOut: 0,
costMicroUsd: 0,
attempts: 0,
failures: 0,
...overrides,
}
}
function accessorFnOf(col: unknown): (row: AdminUsageModelEntry, index: number) => unknown {
return (col as { accessorFn: (row: AdminUsageModelEntry, index: number) => unknown }).accessorFn
function accessorFnOf(col: unknown): (row: ModelCatalogRow, index: number) => unknown {
return (col as { accessorFn: (row: ModelCatalogRow, index: number) => unknown }).accessorFn
}
function cellOf(col: unknown): (ctx: { row: { original: AdminUsageModelEntry } }) => unknown {
return (col as { cell: (ctx: { row: { original: AdminUsageModelEntry } }) => unknown }).cell
function cellOf(col: unknown): (ctx: { row: { original: ModelCatalogRow } }) => unknown {
return (col as { cell: (ctx: { row: { original: ModelCatalogRow } }) => unknown }).cell
}
describe('modelTableColumns: Model column', () => {
const columns = modelTableColumns()
const col = columns.find((c) => c.id === 'model')
describe('buildModelCatalogRows', () => {
function catalog(): AdminCatalogResponse {
return {
providers: [
{
lastRefresh: '2026-01-01T00:00:00Z',
openUntil: '0001-01-01T00:00:00Z',
name: 'openai',
type: 'openai',
healthState: 'closed',
discoveryEnabled: true,
models: [
{ id: 'openai/gpt-5', model: 'gpt-5', priceSource: 'builtin', inputPerMTokUsd: 1.5, outputPerMTokUsd: 6 },
{ id: 'openai/gpt-5-mini', model: 'gpt-5-mini', priceSource: 'unpriced' },
],
},
],
aliases: [],
}
}
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')
function ranking(): AdminUsageModelsResponse {
return {
metric: 'cost',
window: 'day',
span: 7,
offset: 0,
detail: true,
models: [{ id: 'openai/gpt-5', value: 5, requests: 10, tokensIn: 100, tokensOut: 200, costMicroUsd: 5_000_000 }],
}
}
function perf(): AdminPerfResponse {
return {
kind: 'model',
window: 'day',
span: 7,
offset: 0,
latencyEnabled: true,
rows: [{ id: 'openai/gpt-5', p50Ms: 120, p95Ms: 400, count: 10, attempts: 10, failures: 1 }],
}
}
it('produces one row per CATALOG model, not per ranking/performance row', () => {
const rows = buildModelCatalogRows(catalog(), ranking(), perf())
expect(rows).toHaveLength(2)
expect(rows.map((r) => r.id)).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
})
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
it('joins ranking and performance fields onto a matching catalog row', () => {
const rows = buildModelCatalogRows(catalog(), ranking(), perf())
const row = rows.find((r) => r.id === 'openai/gpt-5')
expect(row).toMatchObject({
requests: 10,
tokensIn: 100,
tokensOut: 200,
costMicroUsd: 5_000_000,
p50Ms: 120,
p95Ms: 400,
attempts: 10,
failures: 1,
})
})
it('defaults usage/performance fields to zero/undefined for a catalog model with no traffic', () => {
const rows = buildModelCatalogRows(catalog(), ranking(), perf())
const row = rows.find((r) => r.id === 'openai/gpt-5-mini')
expect(row).toMatchObject({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 0, attempts: 0, failures: 0 })
expect(row?.p50Ms).toBeUndefined()
expect(row?.p95Ms).toBeUndefined()
})
it('carries priceSource/displayFree/aliases straight from the catalog model', () => {
const c = catalog()
c.providers[0].models[0].displayFree = true
c.providers[0].models[0].aliases = ['smart']
const rows = buildModelCatalogRows(c, ranking(), perf())
const row = rows.find((r) => r.id === 'openai/gpt-5')
expect(row?.priceSource).toBe('builtin')
expect(row?.displayFree).toBe(true)
expect(row?.aliases).toEqual(['smart'])
})
it('defaults aliases to an empty array when the catalog model omits it', () => {
const rows = buildModelCatalogRows(catalog(), ranking(), perf())
expect(rows.find((r) => r.id === 'openai/gpt-5-mini')?.aliases).toEqual([])
})
})
describe('modelCatalogColumns: id column', () => {
const col = modelCatalogColumns().find((c) => c.id === 'id')
it('cell renders a ModelChip and an EntityLink pointed at kind "model"', () => {
const rendered = cellOf(col)({ row: { original: testRow({ id: 'anthropic/claude' }) } }) as VNode
const children = rendered.children as VNode[]
const chip = children.find((c) => c.type === ModelChip)
expect(chip).toBeDefined()
const link = children.find((c) => c.type === EntityLink)
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?.kind).toBe('model')
expect(link?.props?.id).toBe('anthropic/claude')
expect(link?.props?.label).toBe('anthropic/claude')
})
})
describe('modelTableColumns: detail count columns sort on raw numbers, render CompactNumber', () => {
const columns = modelTableColumns()
describe('modelCatalogColumns: price column', () => {
const col = modelCatalogColumns().find((c) => c.id === 'price')
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
it('renders a destructive "unpriced" Badge for priceSource "unpriced"', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'unpriced' }) } }) 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')
expect(rendered.props?.variant).toBe('destructive')
})
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 })]
it('renders a "free" Badge for priceSource "free"', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'free' }) } }) as VNode
expect(rendered.type).toBe(Badge)
const slot = (rendered.children as { default: () => string }).default
expect(slot()).toBe('free')
})
it('renders the formatted price plus a source Badge for override/builtin/litellm', () => {
const rendered = cellOf(col)({
row: { original: testRow({ priceSource: 'litellm', inputPerMTokUsd: 1, outputPerMTokUsd: 2 }) },
}) as VNode
const children = rendered.children as VNode[]
expect((children[0] as VNode).children).toBe('in $1.00 / out $2.00 per MTok')
const badge = children[1] as VNode
expect(badge.type).toBe(Badge)
expect((badge.children as { default: () => string }).default()).toBe('litellm')
})
it('renders an em dash placeholder for a priced source with unknown per-token cost (version skew)', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'override' }) } }) as VNode
const children = rendered.children as VNode[]
expect((children[0] as VNode).children).toBe('—')
})
it('sorts unpriced first, then ascending by input price', () => {
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'])
const rows = [
testRow({ id: 'a', priceSource: 'builtin', inputPerMTokUsd: 5 }),
testRow({ id: 'b', priceSource: 'unpriced' }),
testRow({ id: 'c', priceSource: 'builtin', inputPerMTokUsd: 1 }),
]
const sorted = [...rows].sort((x, y) => (accessor(x, 0) as number) - (accessor(y, 0) as number))
expect(sorted.map((r) => r.id)).toEqual(['b', 'c', 'a'])
})
})
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')
describe('modelCatalogColumns: free column (":free" suffix, distinct from priceSource)', () => {
const col = modelCatalogColumns().find((c) => c.id === 'free')
it('renders a Badge only when displayFree is true', () => {
const rendered = cellOf(col)({ row: { original: testRow({ displayFree: true }) } }) as VNode
expect(rendered.type).toBe(Badge)
})
it('renders an em dash when displayFree is false/undefined, even for priceSource "free"', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'free' }) } })
expect(isVNode(rendered) && rendered.type).toBe('span')
expect(isVNode(rendered) && rendered.children).toBe('—')
})
})
describe('modelCatalogColumns: aliases column', () => {
const col = modelCatalogColumns().find((c) => c.id === 'aliases')
it('renders an em dash for an empty alias list', () => {
const rendered = cellOf(col)({ row: { original: testRow({ aliases: [] }) } })
expect(isVNode(rendered) && rendered.children).toBe('—')
})
it('renders one Badge per alias', () => {
const rendered = cellOf(col)({ row: { original: testRow({ aliases: ['a', 'b'] }) } }) as VNode
const children = rendered.children as VNode[]
expect(children).toHaveLength(2)
expect(children.every((c) => c.type === Badge)).toBe(true)
})
})
describe('modelCatalogColumns: cost column', () => {
const col = modelCatalogColumns().find((c) => c.id === 'cost')
it('renders a "free" Badge for a zero-cost free/displayFree row instead of $0.0000', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'free', costMicroUsd: 0 }) } }) as VNode
expect(rendered.type).toBe(Badge)
const rendered2 = cellOf(col)({ row: { original: testRow({ displayFree: true, costMicroUsd: 0 }) } }) as VNode
expect(rendered2.type).toBe(Badge)
})
it('renders the real formatted cost when non-zero, even if free', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'free', costMicroUsd: 1_234_500 }) } })
expect(rendered).toBe('$1.2345')
})
it('renders plain formatCost for a priced, non-free row', () => {
const rendered = cellOf(col)({ row: { original: testRow({ costMicroUsd: 2_000_000 }) } })
expect(rendered).toBe('$2.0000')
})
})
describe('modelCatalogColumns: p50/p95/error rate columns', () => {
it('p50/p95 render an em dash when undefined, formatted latency otherwise', () => {
const p50 = modelCatalogColumns().find((c) => c.id === 'p50')
const p95 = modelCatalogColumns().find((c) => c.id === 'p95')
expect(cellOf(p50)({ row: { original: testRow() } })).toMatchObject({ children: '—' })
expect(cellOf(p95)({ row: { original: testRow({ p95Ms: 1500 }) } })).toMatchObject({ children: '1.5s' })
})
it('error rate renders "no data" for zero attempts, a ceiled percent otherwise', () => {
const col = modelCatalogColumns().find((c) => c.id === 'errorRate')
expect(cellOf(col)({ row: { original: testRow({ attempts: 0, failures: 0 }) } })).toMatchObject({ children: 'no data' })
expect(cellOf(col)({ row: { original: testRow({ attempts: 100, failures: 1 }) } })).toMatchObject({ children: '1%' })
})
it('error rate sorts a no-traffic row (-1) below every real rate', () => {
const col = modelCatalogColumns().find((c) => c.id === 'errorRate')
const accessor = accessorFnOf(col)
expect(accessor(testRow({ attempts: 0, failures: 0 }), 0)).toBe(-1)
expect(accessor(testRow({ attempts: 10, failures: 0 }), 0)).toBe(0)
})
})
+242 -85
View File
@@ -2,109 +2,266 @@ import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import EntityLink from '@/components/EntityLink.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'
import { formatContextWindow, formatCost, formatLatencyMs, formatModelCostHover } from '@/lib/format'
import { formatErrorRatePercent, providerErrorRate } from '@/lib/provider-rate'
import type { AdminCatalogResponse, AdminPerfResponse, AdminUsageModelsResponse, PriceSource } 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).
* ModelCatalogRow (redesign-plan.md section 3.4: "join catalog + ranking +
* performance") is the Models page's one row shape, built by
* buildModelCatalogRows below from THREE independent responses: GET
* /admin/api/catalog (stores/catalog.ts, WP-D — every configured model,
* regardless of traffic), GET /admin/api/usage/models?detail=1 (the
* current global range's ranking — zero for a model with no traffic in
* range), and GET /admin/api/performance?kind=model (fleet p50/p95/
* attempts/failures — top 50 by req in span, server default; a model
* outside that top 50 simply carries zero attempts/failures here, same as
* one with no ranking entry).
*
* The catalog is the DRIVING list (one row per catalog model, not per
* ranking/performance row) — this is a catalog table first, matching
* AdminCatalogModel's own doc comment ("computed for EVERY catalog model,
* not just ones with observed traffic").
*/
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}` }),
]),
}
export interface ModelCatalogRow {
/** Canonical "provider/model" — the join key across all three source responses. */
id: string
/** The bare upstream model id, without the provider prefix (AdminCatalogModel.model). */
model: string
providerName: string
contextTokens?: number
inputPerMTokUsd?: number
outputPerMTokUsd?: number
priceSource: PriceSource
/** True for a ':free' suffix model — display-only; priceSource carries the real billing truth regardless (types/api.ts's own AdminCatalogModel doc comment). */
displayFree?: boolean
aliases: string[]
requests: number
tokensIn: number
tokensOut: number
costMicroUsd: number
p50Ms?: number
p95Ms?: number
attempts: number
failures: number
}
/**
* 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).
* buildModelCatalogRows joins the three source responses by canonical id.
* Pure and Vue-free — testable directly, and reusable by stores/models.ts
* without that store needing to know the join's own internals.
*/
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 }),
export function buildModelCatalogRows(
catalog: AdminCatalogResponse,
ranking: AdminUsageModelsResponse,
perf: AdminPerfResponse,
): ModelCatalogRow[] {
const rankingById = new Map(ranking.models.map((m) => [m.id, m]))
const perfById = new Map((perf.rows ?? []).map((r) => [r.id, r]))
const rows: ModelCatalogRow[] = []
for (const provider of catalog.providers) {
for (const m of provider.models) {
const usage = rankingById.get(m.id)
const perfRow = perfById.get(m.id)
rows.push({
id: m.id,
model: m.model,
providerName: provider.name,
contextTokens: m.contextTokens,
inputPerMTokUsd: m.inputPerMTokUsd,
outputPerMTokUsd: m.outputPerMTokUsd,
priceSource: m.priceSource,
displayFree: m.displayFree,
aliases: m.aliases ?? [],
requests: usage?.requests ?? 0,
tokensIn: usage?.tokensIn ?? 0,
tokensOut: usage?.tokensOut ?? 0,
costMicroUsd: usage?.costMicroUsd ?? 0,
p50Ms: perfRow?.p50Ms,
p95Ms: perfRow?.p95Ms,
attempts: perfRow?.attempts ?? 0,
failures: perfRow?.failures ?? 0,
})
}
}
return rows
}
/** PRICE_SOURCE_LABEL is the price-source Badge's visible text for the three "priced by a rule" sources — 'free'/'unpriced' get their own dedicated badge treatment in priceCell below, never this generic label. */
const PRICE_SOURCE_LABEL: Record<Extract<PriceSource, 'override' | 'builtin' | 'litellm'>, string> = {
override: 'override',
builtin: 'builtin',
litellm: 'litellm',
}
function priceCell(row: ModelCatalogRow) {
if (row.priceSource === 'unpriced') {
return h(
Badge,
{ as: 'span', variant: 'destructive', class: 'font-normal', title: 'No pricing rule matched — billed as $0.' },
() => 'unpriced',
)
}
if (row.priceSource === 'free') {
return h(Badge, { as: 'span', variant: 'secondary', class: 'font-normal', title: 'Configured free (modelMeta.free) — priced at $0.' }, () => 'free')
}
const known = row.inputPerMTokUsd !== undefined && row.outputPerMTokUsd !== undefined
// Narrowed to a local BEFORE the h() callback below — TS control-flow
// narrowing on `row.priceSource` (from the two early returns above)
// does not cross a closure boundary, so the callback itself still sees
// the full PriceSource union unless the already-narrowed value is
// captured into its own variable first.
const source = row.priceSource as Exclude<PriceSource, 'unpriced' | 'free'>
return h('span', { class: 'inline-flex flex-wrap items-center gap-1.5' }, [
h('span', {}, known ? formatModelCostHover(row.inputPerMTokUsd as number, row.outputPerMTokUsd as number) : '—'),
h(Badge, { as: 'span', variant: 'outline', class: 'font-normal' }, () => PRICE_SOURCE_LABEL[source]),
])
}
/**
* 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.
* priceSortValue orders unpriced first (nothing to report, worth an
* operator's attention), then by ascending input price — accessorFn's
* numeric return, not the rendered text, is what DataTable actually sorts
* on.
*/
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)
},
}
function priceSortValue(row: ModelCatalogRow): number {
if (row.priceSource === 'unpriced') return -1
return row.inputPerMTokUsd ?? 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.
* modelCatalogColumns builds the Models page's catalog table (redesign-
* plan.md section 3.4): id, context, price + source badge, free (the
* ':free' suffix flag — distinct from the price column's own 'free'
* priceSource badge, see ModelCatalogRow's own doc comment), aliases,
* req/tokens/cost for the current global range, p50/p95, and error rate.
* p50/p95 read "—" whenever undefined, regardless of WHY (no observations
* yet, or admin.stats.latency off entirely) — ModelCatalogTable.vue itself
* carries the admin.stats.latency hint at the table level (task
* instruction: "handle latencyEnabled=false with a hint naming
* admin.stats.latency"), not repeated on every row.
*/
export function modelTableColumns(): ColumnDef<AdminUsageModelEntry, unknown>[] {
export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
return [
modelColumn(),
numericDetailColumn('requests', 'Requests', (e) => e.requests),
numericDetailColumn('tokensIn', 'Tokens in', (e) => e.tokensIn),
numericDetailColumn('tokensOut', 'Tokens out', (e) => e.tokensOut),
costColumn(),
{
id: 'id',
header: 'Model',
accessorFn: (r) => r.id,
cell: ({ row }) =>
h('span', { class: 'inline-flex flex-wrap items-center gap-1.5' }, [
h(ModelChip, {
id: row.original.id,
contextTokens: row.original.contextTokens,
inputPerMTokUsd: row.original.inputPerMTokUsd,
outputPerMTokUsd: row.original.outputPerMTokUsd,
}),
h(EntityLink, { label: row.original.id, kind: 'model', id: row.original.id }),
]),
},
{
id: 'context',
header: 'Context',
meta: { align: 'right' },
accessorFn: (r) => r.contextTokens ?? 0,
cell: ({ row }) =>
h(
'span',
{ class: 'tabular-nums' },
row.original.contextTokens !== undefined ? formatContextWindow(row.original.contextTokens) : '—',
),
},
{
id: 'price',
header: 'Price',
accessorFn: (r) => priceSortValue(r),
cell: ({ row }) => priceCell(row.original),
},
{
id: 'free',
header: 'Free',
accessorFn: (r) => (r.displayFree ? 1 : 0),
cell: ({ row }) =>
row.original.displayFree
? h(
Badge,
{ as: 'span', variant: 'secondary', class: 'font-normal', title: "':free' suffix model — display-only, billing follows the Price column's own source" },
() => 'free',
)
: h('span', { class: 'text-muted-foreground' }, '—'),
},
{
id: 'aliases',
header: 'Aliases',
enableSorting: false,
accessorFn: (r) => r.aliases.join(', '),
cell: ({ row }) => {
const aliases = row.original.aliases
if (aliases.length === 0) return h('span', { class: 'text-muted-foreground' }, '—')
return h(
'div',
{ class: 'flex flex-wrap gap-1' },
aliases.map((a) => h(Badge, { key: a, as: 'span', variant: 'outline', class: 'font-mono font-normal' }, () => a)),
)
},
},
{
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (r) => r.requests,
cell: ({ row }) => h(CompactNumber, { value: row.original.requests }),
},
{
id: 'tokensIn',
header: 'Tokens in',
meta: { align: 'right' },
accessorFn: (r) => r.tokensIn,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensIn }),
},
{
id: 'tokensOut',
header: 'Tokens out',
meta: { align: 'right' },
accessorFn: (r) => r.tokensOut,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensOut }),
},
{
id: 'cost',
header: 'Cost',
meta: { align: 'right' },
accessorFn: (r) => r.costMicroUsd,
cell: ({ row }) => {
const r = row.original
if ((r.priceSource === 'free' || r.displayFree) && r.costMicroUsd === 0) {
return h(Badge, { as: 'span', variant: 'secondary', class: 'font-normal', title: 'priced at $0 per token' }, () => 'free')
}
return formatCost(r.costMicroUsd)
},
},
{
id: 'p50',
header: 'p50',
meta: { align: 'right' },
accessorFn: (r) => r.p50Ms ?? -1,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.p50Ms !== undefined ? formatLatencyMs(row.original.p50Ms) : '—'),
},
{
id: 'p95',
header: 'p95',
meta: { align: 'right' },
accessorFn: (r) => r.p95Ms ?? -1,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.p95Ms !== undefined ? formatLatencyMs(row.original.p95Ms) : '—'),
},
{
id: 'errorRate',
header: 'Error rate',
meta: { align: 'right' },
accessorFn: (r) => providerErrorRate(r.attempts, r.failures) ?? -1,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, formatErrorRatePercent(providerErrorRate(row.original.attempts, row.original.failures))),
},
]
}
+48
View File
@@ -0,0 +1,48 @@
import {
faCoins,
faCubes,
faHeartPulse,
faHouse,
faPlug,
faSliders,
faUsers,
} from '@fortawesome/free-solid-svg-icons'
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'
/**
* PAGE_IDS is the sidebar redesign's page union (redesign-plan.md section
* 3.1) — replaces composables/useTabHash.ts's old flat TAB_VALUES. The
* single source of truth for every "which page" check: SidebarNav.vue's
* link list, stores/nav.ts's `page` state, and lib/hash-state.ts's
* outer "#<page>" segment all import PAGE_IDS/PageId rather than each
* redeclaring their own copy.
*/
export const PAGE_IDS = ['home', 'spend', 'consumers', 'models', 'reliability', 'config', 'targets'] as const
export type PageId = (typeof PAGE_IDS)[number]
/** The landing page when the hash names nothing recognized, or is absent (first load). */
export const DEFAULT_PAGE: PageId = 'home'
/** One sidebar entry: the page it navigates to, its visible label, and its icon (redesign-plan.md section 3.1's fixed icon list). */
export interface PageNavEntry {
id: PageId
label: string
icon: IconDefinition
}
/** PAGE_NAV drives SidebarNav.vue's link list, in display order. */
export const PAGE_NAV: readonly PageNavEntry[] = [
{ id: 'home', label: 'Home', icon: faHouse },
{ id: 'spend', label: 'Spend', icon: faCoins },
{ id: 'consumers', label: 'Consumers', icon: faUsers },
{ id: 'models', label: 'Models', icon: faCubes },
{ id: 'reliability', label: 'Reliability', icon: faHeartPulse },
{ id: 'config', label: 'Config', icon: faSliders },
{ id: 'targets', label: 'MCP & Agents', icon: faPlug },
]
/** isPageId is a runtime type guard against PAGE_IDS — the only place that string union is checked against untrusted input (a hash, a hand-typed URL). */
export function isPageId(value: string): value is PageId {
return (PAGE_IDS as readonly string[]).includes(value)
}
+6 -4
View File
@@ -1,8 +1,10 @@
// createVisibilityPoller is the ONE polling primitive stores/dashboard.ts,
// stores/history.ts, and stores/events.ts each wire their own refresh
// action into (vue.md: "if you've written it twice, you owe an
// abstraction") — three near-identical setInterval-plus-immediate-tick
// blocks collapsed into one tested module. Beyond the plain interval every
// stores/spend.ts, stores/events.ts, and composables/useNow.ts each wire
// their own refresh action into (vue.md: "if you've written it twice,
// you owe an abstraction") — originally three near-identical
// setInterval-plus-immediate-tick blocks (including the pre-redesign
// stores/history.ts, since deleted) collapsed into one tested module.
// Beyond the plain interval every
// one of them already had, this adds visibility awareness: a background
// browser tab has no reason to keep polling a Redis-backed admin panel
// every 5s/30s for no reader to see, and it catches back up the instant
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import { joinPricingHealth, pricingHealthOrder } from './pricing-health'
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
describe('joinPricingHealth', () => {
const catalog = new Map<string, AdminCatalogModel>([
[
'a/priced',
{
id: 'a/priced',
model: 'priced',
priceSource: 'builtin',
contextTokens: 128000,
inputPerMTokUsd: 1,
outputPerMTokUsd: 2,
aliases: ['a/alias'],
},
],
['a/free-suffix', { id: 'a/free-suffix', model: 'free-suffix:free', priceSource: 'unpriced', displayFree: true }],
])
it('joins a served model with its catalog entry by canonical id', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'a/priced', value: 5, requests: 5, tokensIn: 100, tokensOut: 50, costMicroUsd: 1234, free: false },
]
expect(joinPricingHealth(models, catalog)).toEqual([
{
id: 'a/priced',
priceSource: 'builtin',
displayFree: false,
requests: 5,
tokensIn: 100,
tokensOut: 50,
costMicroUsd: 1234,
contextTokens: 128000,
inputPerMTokUsd: 1,
outputPerMTokUsd: 2,
aliases: ['a/alias'],
},
])
})
it('reads displayFree from the catalog join, not the ranking free flag', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/free-suffix', value: 1, requests: 1, free: false }]
expect(joinPricingHealth(models, catalog)[0]?.displayFree).toBe(true)
expect(joinPricingHealth(models, catalog)[0]?.priceSource).toBe('unpriced')
})
it('skips a served id missing from the catalog entirely', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/removed', value: 1, requests: 1 }]
expect(joinPricingHealth(models, catalog)).toEqual([])
})
it('defaults every detail field to 0 when the ranking entry omits it', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/priced', value: 1 }]
const row = joinPricingHealth(models, catalog)[0]!
expect(row.requests).toBe(0)
expect(row.tokensIn).toBe(0)
expect(row.tokensOut).toBe(0)
expect(row.costMicroUsd).toBe(0)
})
it('returns an empty array for an empty ranking', () => {
expect(joinPricingHealth([], catalog)).toEqual([])
})
// N1 (verify-redesign-final.md): an override-priced model must join
// with its billed inputPerMTokUsd/outputPerMTokUsd present, not
// undefined — PricingHealthTable.vue's own priceCell renders "—"
// whenever either is undefined, which used to fire for EVERY
// 'override' row regardless of whether a `pricing:` override was
// actually configured (admin_catalog.go filled these fields from a
// separate display resolution that an override-only model never
// populated).
it('joins an override-priced model with its billed price present, not "—"', () => {
const overrideCatalog = new Map<string, AdminCatalogModel>([
['a/override', { id: 'a/override', model: 'override', priceSource: 'override', inputPerMTokUsd: 5, outputPerMTokUsd: 10 }],
])
const models: AdminUsageModelEntry[] = [{ id: 'a/override', value: 1, requests: 3, tokensIn: 10, tokensOut: 5, costMicroUsd: 100 }]
const row = joinPricingHealth(models, overrideCatalog)[0]!
expect(row.priceSource).toBe('override')
expect(row.inputPerMTokUsd).toBe(5)
expect(row.outputPerMTokUsd).toBe(10)
})
})
describe('pricingHealthOrder', () => {
it('puts unpriced rows first regardless of their requests', () => {
const rows = [
{ id: 'a', priceSource: 'builtin' as const, displayFree: false, requests: 1000, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
{ id: 'b', priceSource: 'unpriced' as const, displayFree: false, requests: 1, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
]
expect(pricingHealthOrder(rows).map((r) => r.id)).toEqual(['b', 'a'])
})
it('orders the full priceSource priority: unpriced, override, builtin, litellm, free', () => {
const rows = (['free', 'litellm', 'builtin', 'override', 'unpriced'] as const).map((priceSource, i) => ({
id: `m${i}`,
priceSource,
displayFree: false,
requests: 1,
tokensIn: 0,
tokensOut: 0,
costMicroUsd: 0,
}))
expect(pricingHealthOrder(rows).map((r) => r.priceSource)).toEqual(['unpriced', 'override', 'builtin', 'litellm', 'free'])
})
it('breaks ties within the same priceSource by descending requests', () => {
const rows = [
{ id: 'low', priceSource: 'unpriced' as const, displayFree: false, requests: 5, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
{ id: 'high', priceSource: 'unpriced' as const, displayFree: false, requests: 50, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
]
expect(pricingHealthOrder(rows).map((r) => r.id)).toEqual(['high', 'low'])
})
it('does not mutate the input array', () => {
const rows = [
{ id: 'a', priceSource: 'free' as const, displayFree: false, requests: 1, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
{ id: 'b', priceSource: 'unpriced' as const, displayFree: false, requests: 1, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
]
const copy = [...rows]
pricingHealthOrder(rows)
expect(rows).toEqual(copy)
})
})
+94
View File
@@ -0,0 +1,94 @@
// Pure join/ordering behind PricingHealthTable.vue (redesign-plan.md
// section 3.4): "served models in range joined with catalog; unpriced
// first with destructive badge". Kept out of the component the same way
// every other lib/*.ts module in this feature keeps its own math/ordering
// out of its component.
import type { AdminCatalogModel, AdminUsageModelEntry, PriceSource } from '@/types/api'
/**
* PricingHealthRow is one PricingHealthTable.vue row: a model actually
* served in the current range (GET /admin/api/usage/models, detail=1),
* joined with its catalog entry (GET /admin/api/catalog) for the pricing
* fields the ranking alone does not carry. `priceSource`/`displayFree`
* come from the catalog join — the BILLING truth (types/api.ts's
* PriceSource doc comment) — never re-derived from the ranking's own
* `free` flag, which reflects display-only relabeling for a ':free'
* suffix, not necessarily the same thing.
*/
export interface PricingHealthRow {
id: string
priceSource: PriceSource
displayFree: boolean
requests: number
tokensIn: number
tokensOut: number
costMicroUsd: number
contextTokens?: number
inputPerMTokUsd?: number
outputPerMTokUsd?: number
aliases?: string[]
}
/**
* joinPricingHealth joins every served model (`models`, a detail=1 ranking
* — only entries the server already knows had non-zero usage in range) against
* its catalog entry by canonical id. A served id absent from `catalog`
* entirely (a model removed from configuration since it was last used, or
* the catalog simply has not loaded yet) is SKIPPED — there is no pricing
* fact to report for it, and fabricating an "unpriced" row for a model
* that may no longer even exist would be misleading rather than merely
* incomplete.
*/
export function joinPricingHealth(models: AdminUsageModelEntry[], catalog: Map<string, AdminCatalogModel>): PricingHealthRow[] {
const rows: PricingHealthRow[] = []
for (const m of models) {
const cat = catalog.get(m.id)
if (!cat) continue
rows.push({
id: m.id,
priceSource: cat.priceSource,
displayFree: cat.displayFree ?? false,
requests: m.requests ?? 0,
tokensIn: m.tokensIn ?? 0,
tokensOut: m.tokensOut ?? 0,
costMicroUsd: m.costMicroUsd ?? 0,
contextTokens: cat.contextTokens,
inputPerMTokUsd: cat.inputPerMTokUsd,
outputPerMTokUsd: cat.outputPerMTokUsd,
aliases: cat.aliases,
})
}
return rows
}
/**
* PRICING_HEALTH_SOURCE_RANK orders pricingHealthOrder's primary sort key
* — 'unpriced' first (redesign-plan.md section 3.4: "unpriced first with
* destructive badge", the row that most needs an operator's attention),
* then the two explicit/deliberate sources (override, builtin — an
* operator or the built-in table already priced these correctly), then
* litellm (a live, less-pinned lookup), then 'free' last (a deliberately
* zero-cost model — nothing to flag at all).
*/
const PRICING_HEALTH_SOURCE_RANK: Record<PriceSource, number> = {
unpriced: 0,
override: 1,
builtin: 2,
litellm: 3,
free: 4,
}
/**
* pricingHealthOrder sorts joined rows: unpriced-first by priceSource
* (PRICING_HEALTH_SOURCE_RANK), then by descending requests within each
* source group — the biggest-traffic unpriced model surfaces first, since
* that is the one costing the operator the most in unbilled requests.
* Returns a NEW array; never mutates `rows`.
*/
export function pricingHealthOrder(rows: PricingHealthRow[]): PricingHealthRow[] {
return [...rows].sort((a, b) => {
const bySource = PRICING_HEALTH_SOURCE_RANK[a.priceSource] - PRICING_HEALTH_SOURCE_RANK[b.priceSource]
if (bySource !== 0) return bySource
return b.requests - a.requests
})
}
+35
View File
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest'
import {
dayRateTitle,
formatErrorRatePercent,
formatRatePercent,
isModelDegraded,
minuteRateTitle,
PROVIDER_RATE_AMBER_THRESHOLD,
PROVIDER_RATE_DESTRUCTIVE_THRESHOLD,
providerErrorRate,
providerRateStatus,
providerSuccessRate,
} from './provider-rate'
@@ -143,3 +145,36 @@ describe('dayRateTitle', () => {
expect(dayRateTitle(5, 0)).toBe('5 attempts, 0 failures today (100%)')
})
})
describe('providerErrorRate', () => {
it('is 1 minus the success rate', () => {
expect(providerErrorRate(100, 1)).toBeCloseTo(0.01)
})
it('is null for zero attempts (no traffic), never a fabricated 0', () => {
expect(providerErrorRate(0, 0)).toBeNull()
})
it('is 1 when every attempt failed', () => {
expect(providerErrorRate(10, 10)).toBe(1)
})
})
describe('formatErrorRatePercent', () => {
it('ceils rather than floors, so a genuinely non-zero rate never rounds down to "0%"', () => {
expect(formatErrorRatePercent(0.001)).toBe('1%')
expect(formatErrorRatePercent(0.011)).toBe('2%')
})
it('renders exactly 0 as "0%"', () => {
expect(formatErrorRatePercent(0)).toBe('0%')
})
it('renders null as "no data", never "0%"', () => {
expect(formatErrorRatePercent(null)).toBe('no data')
})
it('does not clamp a rate above 1 (should not occur with real counters, but is not silently capped)', () => {
expect(formatErrorRatePercent(1.8)).toBe('180%')
})
})
+39
View File
@@ -90,6 +90,45 @@ export function formatRatePercent(rate: number): string {
return `${Math.floor(rate * 100)}%`
}
/**
* providerErrorRate is failures/attempts directly — conceptually
* providerSuccessRate's complement, but computed as its OWN division
* rather than `1 - providerSuccessRate(...)`: chaining a subtraction onto
* an already-divided value compounds binary floating-point error (e.g.
* 1 - (99/100) computes a hair above 0.01, which then ceils to "2%"
* instead of "1%" once formatErrorRatePercent multiplies by 100 — a
* caught review bug, not a hypothetical one). Returns null (never a
* fabricated 0%) when there is no traffic to compute a rate from at all.
* Shared by the Models page's per-model error-rate column
* (lib/model-table-columns.ts) and the Reliability page's own bucket-by-
* bucket series (which instead derives its ratio via lib/series.ts's
* ratioSeries, but formats it through formatErrorRatePercent below) — the
* ONE place "attempts/failures -> error rate" is computed for a single
* snapshot value, as opposed to a time series.
*/
export function providerErrorRate(attempts: number, failures: number): number | null {
if (!Number.isFinite(attempts) || attempts <= 0) return null
const safeFailures = Number.isFinite(failures) ? failures : 0
return safeFailures / attempts
}
/**
* formatErrorRatePercent renders a 0-1(+) error-rate fraction CEILING
* rather than floored — the opposite honesty direction from
* formatRatePercent above (a SUCCESS rate must never overstate itself as
* 100%; an ERROR rate must never round down to a misleadingly clean 0%).
* `null` (no traffic to compute a rate from — providerErrorRate's own
* "nothing to report" case) renders as "no data", never "0%". Not clamped
* to 100%: a rate above 1 should not occur with real counters, but this
* function reports whatever the caller computed rather than silently
* capping it (lib/series.ts's own ratioSeries makes the identical choice).
*/
export function formatErrorRatePercent(rate: number | null): string {
if (rate === null) return 'no data'
if (rate <= 0) return '0%'
return `${Math.ceil(rate * 100)}%`
}
/** countWord returns singular when n is exactly 1, plural otherwise — shared by minuteRateTitle and dayRateTitle so their wording never drifts apart. */
function countWord(n: number, singular: string, plural: string): string {
return n === 1 ? singular : plural
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import {
comparison,
dayOrMonthClampNotice,
dayOrMonthWindow,
DEFAULT_CMP,
DEFAULT_RANGE,
isCmpMode,
isRangeKey,
RANGE_KEYS,
RANGE_PRESETS,
seriesUrl,
totalsUrl,
} from './range'
describe('RANGE_PRESETS', () => {
it('resolves every preset to the documented (window, span) pair', () => {
expect(RANGE_PRESETS['24h']).toEqual({ window: 'hour', span: 24 })
expect(RANGE_PRESETS['48h']).toEqual({ window: 'hour', span: 48 })
expect(RANGE_PRESETS['7d']).toEqual({ window: 'day', span: 7 })
expect(RANGE_PRESETS['14d']).toEqual({ window: 'day', span: 14 })
expect(RANGE_PRESETS['30d']).toEqual({ window: 'day', span: 30 })
expect(RANGE_PRESETS['3mo']).toEqual({ window: 'month', span: 3 })
expect(RANGE_PRESETS['6mo']).toEqual({ window: 'month', span: 6 })
expect(RANGE_PRESETS['12mo']).toEqual({ window: 'month', span: 12 })
})
it('covers every RangeKey with no gaps', () => {
for (const key of RANGE_KEYS) expect(RANGE_PRESETS[key]).toBeDefined()
})
})
describe('DEFAULT_RANGE / DEFAULT_CMP', () => {
it('defaults to 7d / none', () => {
expect(DEFAULT_RANGE).toBe('7d')
expect(DEFAULT_CMP).toBe('none')
})
})
describe('comparison', () => {
it.each([
['24h', true, 24],
['7d', true, 7],
['14d', true, 14],
['3mo', true, 3],
['6mo', true, 6],
] as const)('%s is available with offset %i', (range, available, offset) => {
const result = comparison(range)
expect(result.available).toBe(available)
expect(result.offset).toBe(offset)
expect(result.reason).toBeUndefined()
})
it.each(['48h', '30d', '12mo'] as const)('%s is unavailable (2*span exceeds retention max)', (range) => {
const result = comparison(range)
expect(result.available).toBe(false)
expect(result.reason).toBe('retention limit')
})
})
describe('isRangeKey / isCmpMode', () => {
it('accepts every real value and rejects bogus ones', () => {
for (const key of RANGE_KEYS) expect(isRangeKey(key)).toBe(true)
expect(isRangeKey('bogus')).toBe(false)
expect(isCmpMode('none')).toBe(true)
expect(isCmpMode('prev')).toBe(true)
expect(isCmpMode('bogus')).toBe(false)
})
})
// dayOrMonthWindow (P2 item 2): the shared clamp GET /admin/api/usage/
// totals?kind=usermodel|modeluser|targetcaller needs — moved here from
// lib/target-columns.ts so stores/consumers.ts (usermodel) and
// stores/spend.ts (usermodel drilldown) share the identical clamp
// TargetCallers.vue already used, rather than each re-deriving it (or,
// before this fix, not clamping at all and 400ing on an hour-window
// range).
describe('dayOrMonthWindow', () => {
it('passes a day window through unchanged, with its own span', () => {
expect(dayOrMonthWindow('day', 14)).toEqual({ window: 'day', span: 14 })
})
it('passes a month window through unchanged, with its own span', () => {
expect(dayOrMonthWindow('month', 6)).toEqual({ window: 'month', span: 6 })
})
it('clamps an hour window (no day/month equivalent span) to a fixed one-week day window', () => {
expect(dayOrMonthWindow('hour', 48)).toEqual({ window: 'day', span: 7 })
expect(dayOrMonthWindow('hour', 24)).toEqual({ window: 'day', span: 7 })
})
})
// dayOrMonthClampNotice (N3, verify-redesign-final.md): the shared
// "this view silently clamped your range" note text for every reader of
// dayOrMonthWindow above that surfaces the clamp to a screen.
describe('dayOrMonthClampNotice', () => {
it('is null for a day window — dayOrMonthWindow passes it through unchanged', () => {
expect(dayOrMonthClampNotice('day', 14)).toBeNull()
})
it('is null for a month window — dayOrMonthWindow passes it through unchanged', () => {
expect(dayOrMonthClampNotice('month', 6)).toBeNull()
})
it('names the exact substituted range for an hour window, regardless of the requested span', () => {
expect(dayOrMonthClampNotice('hour', 24)).toBe('Showing last 7 days — per-hour data is not kept for this view.')
expect(dayOrMonthClampNotice('hour', 48)).toBe('Showing last 7 days — per-hour data is not kept for this view.')
})
})
describe('seriesUrl', () => {
it('repeats scope as separate params and omits offset when 0', () => {
const url = seriesUrl({ scope: ['user:alice', 'group:eng'], metric: 'cost', window: 'day', span: 7 })
expect(url).toBe('/admin/api/usage/series?scope=user%3Aalice&scope=group%3Aeng&metric=cost&window=day&span=7')
})
it('includes offset when non-zero', () => {
const url = seriesUrl({ scope: ['total'], metric: 'req', window: 'hour', span: 24, offset: 24 })
expect(url).toContain('offset=24')
})
})
describe('totalsUrl', () => {
it('builds the minimal query for kind alone', () => {
expect(totalsUrl({ kind: 'user' })).toBe('/admin/api/usage/totals?kind=user')
})
it('joins metrics with commas and includes every optional param provided', () => {
const url = totalsUrl({
kind: 'usermodel',
window: 'month',
span: 1,
metrics: ['req', 'cost'],
limit: 5,
user: 'alice',
})
expect(url).toBe('/admin/api/usage/totals?kind=usermodel&window=month&span=1&metrics=req%2Ccost&limit=5&user=alice')
})
})
+184
View File
@@ -0,0 +1,184 @@
import type { HistoryWindow, TotalsKind } from '@/types/api'
/**
* RANGE_KEYS is the global filter bar's fixed set of time-range presets
* (redesign-plan.md section 3.2). Each maps to a bucket window and a
* bucket count (`span`) — the same (window, span) pair GET /admin/api/
* usage/series and /usage/models already accept.
*/
export const RANGE_KEYS = ['24h', '48h', '7d', '14d', '30d', '3mo', '6mo', '12mo'] as const
export type RangeKey = (typeof RANGE_KEYS)[number]
export const DEFAULT_RANGE: RangeKey = '7d'
/** One range preset's resolved (window, span) pair. */
export interface RangePreset {
window: HistoryWindow
span: number
}
export const RANGE_PRESETS: Record<RangeKey, RangePreset> = {
'24h': { window: 'hour', span: 24 },
'48h': { window: 'hour', span: 48 },
'7d': { window: 'day', span: 7 },
'14d': { window: 'day', span: 14 },
'30d': { window: 'day', span: 30 },
'3mo': { window: 'month', span: 3 },
'6mo': { window: 'month', span: 6 },
'12mo': { window: 'month', span: 12 },
}
/**
* HISTORY_MAX_SPAN mirrors the server's own retention ceiling per window
* (limits.go: historyMaxSpan — hour 48, day 35, month 13). The frontend
* keeps its own copy rather than reading it from an endpoint: it never
* changes without a server redeploy, and comparison() below needs it
* synchronously to decide whether a comparison request would even be
* valid before firing it.
*/
export const HISTORY_MAX_SPAN: Record<HistoryWindow, number> = {
hour: 48,
day: 35,
month: 13,
}
/** The comparison toggle's two states (redesign-plan.md section 3.1's `cmp` hash param). */
export const CMP_MODES = ['none', 'prev'] as const
export type CmpMode = (typeof CMP_MODES)[number]
export const DEFAULT_CMP: CmpMode = 'none'
export interface ComparisonResult {
/** Whether "vs previous period" can be requested for this range at all. */
available: boolean
/** How many buckets back the comparison window starts (span+offset stays within HISTORY_MAX_SPAN when available). */
offset: number
/** Set only when `available` is false — the reason a GlobalFilterBar cmp toggle disables itself, e.g. as a disabled control's title. */
reason?: string
}
/**
* comparison reports whether `range` can be paired with a "vs previous
* period" comparison request, and what `offset` that comparison would
* use (Q5, redesign-plan.md DECISIONS). A comparison covers the SAME
* span twice — the current period and the one immediately before it — so
* it needs `2 * span` buckets total; it is disabled whenever that would
* exceed the window's own retention ceiling (HISTORY_MAX_SPAN), which is
* exactly 48h, 30d, and 12mo among the eight presets above.
*/
export function comparison(range: RangeKey): ComparisonResult {
const { window, span } = RANGE_PRESETS[range]
const max = HISTORY_MAX_SPAN[window]
if (2 * span > max) return { available: false, offset: span, reason: 'retention limit' }
return { available: true, offset: span }
}
/**
* dayOrMonthWindow clamps an arbitrary HistoryWindow/span pair (usually the
* global filters store's current range) down to the day/month-only window
* GET /admin/api/usage/totals accepts for its usermodel/modeluser/
* targetcaller kinds (redesign-plan.md section 1.3.v: "usermodel/modeluser/
* targetcaller: day/month only"). 'month' passes through unchanged; 'hour'
* (the 24h/48h global ranges, which have no day/month equivalent span at
* all) falls back to a fixed one-week day window rather than an arbitrary
* guess at what span the reader meant; 'day' passes its own span through
* unchanged. The single shared clamp for every one of those three kinds —
* stores/consumers.ts (usermodel), stores/spend.ts (usermodel drilldown),
* lib/target-columns.ts/TargetCallers.vue (targetcaller) all import this
* rather than each re-deriving their own.
*/
export function dayOrMonthWindow(window: HistoryWindow, span: number): { window: 'day' | 'month'; span: number } {
if (window === 'month') return { window: 'month', span }
if (window === 'day') return { window: 'day', span }
return { window: 'day', span: 7 }
}
/**
* dayOrMonthClampNotice (N3 fix, verify-redesign-final.md) is
* dayOrMonthWindow's own "worth telling the reader" half: dayOrMonthWindow
* silently substitutes a fixed day-window whenever `window` is 'hour'
* (24h/48h has no day/month equivalent span at all) — this reports the
* exact substituted range as a one-line note when that happened, and
* null when it did not ('day'/'month' pass through dayOrMonthWindow
* unchanged, so there is nothing to disclose).
*
* Every reader of dayOrMonthWindow that surfaces its result to a screen
* — UserDetail's "Models used", the Spend drilldown's usermodel level,
* TargetCallers — shares this one function via ClampedRangeNotice.vue,
* so the wording never drifts between them. ReliabilityPage.vue's own
* month-clamp note is a SEPARATE, unrelated clamp (hour/day -> a fixed
* 30-day window, stores/reliability.ts's hourOrDayWindow) and stays its
* own inline Alert, not this one.
*/
export function dayOrMonthClampNotice(window: HistoryWindow, span: number): string | null {
if (window !== 'hour') return null
const clamped = dayOrMonthWindow(window, span)
return `Showing last ${clamped.span} days — per-hour data is not kept for this view.`
}
/** isRangeKey is a runtime type guard against RANGE_KEYS — the only place that string union is checked against untrusted input (a hash, a hand-typed URL). */
export function isRangeKey(value: string): value is RangeKey {
return (RANGE_KEYS as readonly string[]).includes(value)
}
/** isCmpMode is CMP_MODES' own runtime type guard, mirroring isRangeKey above. */
export function isCmpMode(value: string): value is CmpMode {
return (CMP_MODES as readonly string[]).includes(value)
}
export interface SeriesUrlParams {
/** One or more scope ids — repeated as separate `scope` query params, matching admin.go's `scope` (1..100, repeated). */
scope: string[]
metric: string
window: HistoryWindow
span: number
/** Buckets back from "now" the window starts — omitted (server default 0) when 0. */
offset?: number
}
/** seriesUrl builds the query string for GET /admin/api/usage/series (redesign-plan.md section 1.3.iv) — the one place every store that reads it (spend/consumers/models/reliability, phase 2) builds that URL, rather than each hand-rolling its own URLSearchParams. */
export function seriesUrl(params: SeriesUrlParams): string {
const q = new URLSearchParams()
for (const scope of params.scope) q.append('scope', scope)
q.set('metric', params.metric)
q.set('window', params.window)
q.set('span', String(params.span))
if (params.offset) q.set('offset', String(params.offset))
return `/admin/api/usage/series?${q.toString()}`
}
export interface TotalsUrlParams {
kind: TotalsKind
window?: HistoryWindow
span?: number
offset?: number
/** Comma-joined server-side (admin.go's `metrics` query param) — omitted entirely (server default: every metric that kind allows) when empty/absent. */
metrics?: string[]
limit?: number
/** kind 'user' only — narrows to one group's members. */
group?: string
/** kind 'usermodel' only — required by the server. */
user?: string
/** kind 'modeluser' only — required by the server. */
model?: string
/** kind 'targetcaller' only — "mcp/{name}" or "agent/{name}". */
target?: string
}
/** totalsUrl builds the query string for GET /admin/api/usage/totals (redesign-plan.md section 1.3.v), mirroring seriesUrl above. */
export function totalsUrl(params: TotalsUrlParams): string {
const q = new URLSearchParams()
q.set('kind', params.kind)
if (params.window) q.set('window', params.window)
if (params.span !== undefined) q.set('span', String(params.span))
if (params.offset) q.set('offset', String(params.offset))
if (params.metrics && params.metrics.length > 0) q.set('metrics', params.metrics.join(','))
if (params.limit !== undefined) q.set('limit', String(params.limit))
if (params.group) q.set('group', params.group)
if (params.user) q.set('user', params.user)
if (params.model) q.set('model', params.model)
if (params.target) q.set('target', params.target)
return `/admin/api/usage/totals?${q.toString()}`
}
+5 -5
View File
@@ -118,11 +118,11 @@ describe('an item manually opened before search, still matches, clicked closed d
})
// This module is domain-agnostic on what an "id" represents — it started as
// ProvidersView.vue's provider names, but UsageView.vue now feeds it group
// ids too (see that view's own doc comment: matchingIds there is every
// group that matches ONLY via a member, never via its own name). These two
// cases exist to pin that the module behaves identically for that second
// domain, not because anything here differs by id shape.
// ProviderHealthPanel.vue's provider names, but ConsumerDirectory.vue also
// feeds it group ids (see that component's own doc comment: matchingIds
// there is every group that matches ONLY via a member, never via its own
// name). These two cases exist to pin that the module behaves identically
// for that second domain, not because anything here differs by id shape.
describe('generic ids (Usage group ids, not just Providers provider names)', () => {
it('auto-expands and can be collapsed/overridden identically for a group id', () => {
const state = newState()
+18 -17
View File
@@ -1,18 +1,18 @@
/**
* Pure expand/collapse state for a search-filterable shadcn-vue Accordion —
* shared by ProvidersView.vue (providers) and UsageView.vue (groups), and
* generalized (originally provider-expand.ts) so both consume ONE tested
* implementation instead of two copies of the same logic (vue.md: "if
* you've written it twice, you owe an abstraction"). Extracted out of the
* component so the exact toggle sequence a review flagged (see
* toggleItemExpand's own comment) is tested in isolation, without mounting
* Vue — see search-expand.spec.ts (vitest), which exercises this module
* directly, including the full flagged sequence.
* shared by ProviderHealthPanel.vue (providers) and ConsumerDirectory.vue
* (groups), and generalized (originally provider-expand.ts) so both
* consume ONE tested implementation instead of two copies of the same
* logic (vue.md: "if you've written it twice, you owe an abstraction").
* Extracted out of the component so the exact toggle sequence a review
* flagged (see toggleItemExpand's own comment) is tested in isolation,
* without mounting Vue — see search-expand.spec.ts (vitest), which
* exercises this module directly, including the full flagged sequence.
*
* Two sets track state, keyed on each accordion item's own string id (a
* provider name in Providers, a group id in Usage — this module has no
* opinion on what the id means or on what counts as a "match"; callers
* decide that and pass the resulting id list in):
* provider name on the Models page, a group id on the Consumers page —
* this module has no opinion on what the id means or on what counts as a
* "match"; callers decide that and pass the resulting id list in):
* - manuallyExpanded: items the user explicitly opened, independent of any
* search query. This is the state a cleared search returns to.
* - manuallyCollapsed: items the user explicitly closed WHILE a search
@@ -34,12 +34,13 @@ export interface ExpandState {
* manuallyExpanded plus every id in matchingIds that is not in
* manuallyCollapsed (auto-expand every match, unless the user explicitly
* closed that one). The caller decides what "matching" means for its own
* view, and this module has no opinion on it either way — ProvidersView.vue
* passes every provider that itself matched at all (every match there
* implies at least one matching model, so every match auto-expands);
* UsageView.vue instead passes only the subset of its own matches that
* matched via a member rather than via the group's own name (see that
* view's own doc comment for why it draws that narrower distinction).
* view, and this module has no opinion on it either way —
* ProviderHealthPanel.vue passes every provider that itself matched at
* all (every match there implies at least one matching model, so every
* match auto-expands); ConsumerDirectory.vue instead passes only the
* subset of its own matches that matched via a member rather than via
* the group's own name (see that component's own doc comment for why it
* draws that narrower distinction).
*/
export function computeExpandedItems(state: ExpandState, hasQuery: boolean, matchingIds: string[]): Set<string> {
if (!hasQuery) return new Set(state.manuallyExpanded)
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { other, ratioSeries, stackTopN, sumByProvider } from './series'
describe('stackTopN', () => {
it('returns the n series with the largest total, descending', () => {
const series = [
{ scope: 'model:a', points: [1, 1] }, // total 2
{ scope: 'model:b', points: [10, 10] }, // total 20
{ scope: 'model:c', points: [5, 5] }, // total 10
]
expect(stackTopN(series, 2)).toEqual([
{ scope: 'model:b', points: [10, 10] },
{ scope: 'model:c', points: [5, 5] },
])
})
it('returns every series, sorted, when n exceeds the array length', () => {
const series = [
{ scope: 'model:a', points: [1] },
{ scope: 'model:b', points: [2] },
]
expect(stackTopN(series, 10)).toHaveLength(2)
})
it('returns an empty array for n <= 0', () => {
const series = [{ scope: 'model:a', points: [1] }]
expect(stackTopN(series, 0)).toEqual([])
expect(stackTopN(series, -1)).toEqual([])
})
it('breaks ties on original array order (stable sort)', () => {
const series = [
{ scope: 'model:a', points: [5] },
{ scope: 'model:b', points: [5] },
]
expect(stackTopN(series, 2).map((s) => s.scope)).toEqual(['model:a', 'model:b'])
})
it('does not mutate the input array', () => {
const series = [
{ scope: 'model:a', points: [1] },
{ scope: 'model:b', points: [2] },
]
const copy = [...series]
stackTopN(series, 1)
expect(series).toEqual(copy)
})
})
describe('other', () => {
it('subtracts the sum of charted series from the total, per bucket', () => {
const total = [100, 200]
const charted = [
{ scope: 'model:a', points: [30, 50] },
{ scope: 'model:b', points: [20, 40] },
]
expect(other(total, charted)).toEqual([50, 110])
})
it('returns the total verbatim when charted is empty', () => {
expect(other([1, 2, 3], [])).toEqual([1, 2, 3])
})
it('floors a bucket at 0 rather than going negative', () => {
const total = [10]
const charted = [{ scope: 'model:a', points: [15] }]
expect(other(total, charted)).toEqual([0])
})
it('treats a missing point in a shorter charted series as 0', () => {
const total = [10, 10]
const charted = [{ scope: 'model:a', points: [4] }]
expect(other(total, charted)).toEqual([6, 10])
})
})
describe('sumByProvider', () => {
it('groups model-scoped series by their provider prefix and sums per bucket', () => {
const series = [
{ scope: 'model:openai/gpt-5', points: [10, 20] },
{ scope: 'model:openai/gpt-5-mini', points: [5, 5] },
{ scope: 'model:anthropic/claude', points: [1, 1] },
]
const result = sumByProvider(series)
expect(result).toEqual([
{ scope: 'openai', points: [15, 25] },
{ scope: 'anthropic', points: [1, 1] },
])
})
it('handles a bare (unprefixed) scope without a "model:" prefix', () => {
const series = [{ scope: 'openai/gpt-5', points: [3] }]
expect(sumByProvider(series)).toEqual([{ scope: 'openai', points: [3] }])
})
it('handles a discovered id with an extra internal slash — only the first segment is the provider', () => {
const series = [{ scope: 'model:real/uni/deepseek-v4-flash-0731', points: [7] }]
expect(sumByProvider(series)).toEqual([{ scope: 'real', points: [7] }])
})
it('sorts the grouped result by descending total', () => {
const series = [
{ scope: 'model:small/x', points: [1] },
{ scope: 'model:big/y', points: [100] },
]
expect(sumByProvider(series).map((s) => s.scope)).toEqual(['big', 'small'])
})
it('returns an empty array for an empty input', () => {
expect(sumByProvider([])).toEqual([])
})
})
describe('ratioSeries', () => {
it('divides numerator by denominator per bucket', () => {
expect(ratioSeries([1, 5, 10], [10, 10, 10])).toEqual([0.1, 0.5, 1])
})
it('returns null for a bucket with 0 attempts rather than NaN or a fabricated 0', () => {
expect(ratioSeries([0, 3], [0, 10])).toEqual([null, 0.3])
})
it('treats a missing numerator point as 0', () => {
expect(ratioSeries([], [5])).toEqual([0])
})
it('returns an empty array for empty input', () => {
expect(ratioSeries([], [])).toEqual([])
})
})
+112
View File
@@ -0,0 +1,112 @@
// Pure series math shared by every page that renders a GET
// /admin/api/usage/series response through TimeSeriesChart.vue: Spend's
// cost breakdown (by=model/provider/group, redesign-plan.md section 3.4)
// and, per that same section, WP-F's Reliability page ("TimeSeriesChart
// for error rate per provider (lib/series.ts ratioSeries fail/attempt per
// bucket)"). Owned by WP-D (section 4) precisely because Spend needs it
// first, but kept generic — no metric/scope-kind-specific logic — so a
// second page building its own breakdown never grows a duplicate copy
// (vue.md: "if you've written it twice, you owe an abstraction"). Kept out
// of any component/store the same way lib/forecast.ts and lib/usage-bars.ts
// keep their own math out of theirs — vitest's node-environment config
// exercises every function here directly, no component mount required.
/** One AdminSeriesResponse series entry: a scope id and its bucket-aligned points (types/api.ts: AdminSeriesResponse['series'][number]). */
export interface Series {
scope: string
points: number[]
}
/**
* sumSeries totals one series' own points — the ranking key stackTopN
* sorts by, and the per-bucket contribution `other` subtracts. A missing
* point (an index beyond `points.length`, which should not happen for a
* well-formed AdminSeriesResponse where every series is exactly
* `buckets.length` long) reads as 0 rather than throwing.
*/
function sumSeries(points: number[]): number {
let total = 0
for (const p of points) total += p
return total
}
/**
* stackTopN returns the N series with the largest total value (sumSeries,
* descending), for stacking individually in a breakdown chart — Spend's
* by=model view fetches series for every model the ranking endpoint
* already reported as non-idle, then charts only the biggest N of them,
* folding the rest into an "other" bucket via `other` below. Ties break on
* the ORIGINAL array order (Array.prototype.sort is stable), so two
* series with an identical total never reorder from one call to the next.
* `n` beyond `series.length` simply returns every series, sorted; `n <= 0`
* returns an empty array.
*/
export function stackTopN(series: Series[], n: number): Series[] {
if (n <= 0) return []
return [...series].sort((a, b) => sumSeries(b.points) - sumSeries(a.points)).slice(0, n)
}
/**
* other computes the per-bucket remainder of `totalPoints` once every
* series in `charted` (Spend's top-N breakdown) is subtracted — the
* "other" bucket redesign-plan.md section 3.3 describes ("other = total -
* sum(top)"). Floored at 0 per bucket: a genuine remainder is never
* negative (every charted series is a strict subset of the total), so a
* small negative reading can only be a rounding/timing artifact between
* the two separate HTTP requests that produced `totalPoints` and
* `charted` — the same "never render a fabricated crossed boundary"
* discipline lib/format.ts's formatCompactCount already documents for an
* unrelated flooring case. `charted` may be empty (no breakdown at all —
* `other` then equals `totalPoints` verbatim, floored).
*/
export function other(totalPoints: number[], charted: Series[]): number[] {
return totalPoints.map((total, i) => {
let chartedSum = 0
for (const s of charted) chartedSum += s.points[i] ?? 0
return Math.max(0, total - chartedSum)
})
}
/**
* sumByProvider groups a set of `model:{canonical}` (or bare canonical)
* scoped series by their provider prefix (routableModelId's own inverse:
* the FIRST path segment before the first "/", lib/format.ts's own doc
* comment on routableModelId spells out why only the first segment counts
* even when a discovered model id itself contains further slashes) and
* sums their points per bucket — Spend's by=provider view (redesign-
* plan.md section 3.3): the /usage/series endpoint has no cost/req/tokin/
* tokout metric for a `provider:{name}` scope directly (section 1.3.iv's
* own metric x kind table — provider scope only carries attempt/fail/
* timeout/fover), so a provider-level cost breakdown can only be DERIVED
* by summing its own models' series client-side. Returned in descending
* total order (stackTopN's own convention) — the biggest provider first,
* both for a stable, useful stacking order and so a caller never has to
* re-sort.
*/
export function sumByProvider(series: Series[]): Series[] {
const byProvider = new Map<string, number[]>()
for (const s of series) {
const id = s.scope.startsWith('model:') ? s.scope.slice('model:'.length) : s.scope
const slash = id.indexOf('/')
const provider = slash === -1 ? id : id.slice(0, slash)
const acc = byProvider.get(provider) ?? new Array<number>(s.points.length).fill(0)
for (let i = 0; i < s.points.length; i++) acc[i] = (acc[i] ?? 0) + (s.points[i] ?? 0)
byProvider.set(provider, acc)
}
const grouped = Array.from(byProvider.entries()).map(([scope, points]) => ({ scope, points }))
return grouped.sort((a, b) => sumSeries(b.points) - sumSeries(a.points))
}
/**
* ratioSeries divides `numerator` by `denominator` bucket-for-bucket
* (Reliability's per-provider error rate, lib/provider-rate.ts's own
* fleet-wide equivalent for a single snapshot) — null for any bucket
* where `denominator` is 0 (no attempts that bucket), never a fabricated
* 0% or a NaN from dividing by zero. Both arrays must already be aligned
* to the SAME buckets (the caller's responsibility, matching
* AdminSeriesResponse's own "every series is buckets.length long, index-
* for-index" contract).
*/
export function ratioSeries(numerator: number[], denominator: number[]): (number | null)[] {
return denominator.map((d, i) => (d > 0 ? (numerator[i] ?? 0) / d : null))
}
+321
View File
@@ -0,0 +1,321 @@
import { describe, expect, it, vi } from 'vitest'
import {
API_KEY_PLACEHOLDER,
generateApiKey,
groupGrantSnippet,
groupLimitsSnippet,
isValidName,
isValidNonNegative,
isValidNonNegativeInt,
isValidUserName,
modelMetaSnippet,
pricingSnippet,
userGrantJsonFragment,
userJsonLine,
userLimitsJsonFragment,
validatePersonalGrant,
} from './snippets'
// Golden strings below are checked against the REAL production shapes in
// home-cluster/kubernetes/namespaces/traefik/base/llmgateway/middleware.yaml
// and users-configmap.yaml (read this session) — structure and field order
// only. Every value used here is fabricated/public (model ids, provider
// names, group names are not secrets); no api key or password literal from
// either production file appears anywhere in this file.
describe('groupLimitsSnippet: golden YAML, spec.plugin.llmgateway.groups.<name>.limits', () => {
it('renders one configured field', () => {
expect(groupLimitsSnippet('friends', { requestsPerDay: 1000 })).toBe(
['spec:', ' plugin:', ' llmgateway:', ' groups:', ' friends:', ' limits:', ' requestsPerDay: 1000'].join(
'\n',
),
)
})
it('renders every configured field in LimitsConfig field order, regardless of input key order', () => {
const snippet = groupLimitsSnippet('eng', {
costPerMonthUSD: 50,
requestsPerMinute: 10,
tokensPerDay: 500000,
})
expect(snippet).toBe(
[
'spec:',
' plugin:',
' llmgateway:',
' groups:',
' eng:',
' limits:',
' requestsPerMinute: 10',
' tokensPerDay: 500000',
' costPerMonthUSD: 50',
].join('\n'),
)
})
it('omits an unset (0/undefined) field entirely, never emitting a fabricated 0', () => {
const snippet = groupLimitsSnippet('friends', { requestsPerDay: 0, costPerDayUSD: 5 })
expect(snippet).not.toContain('requestsPerDay')
expect(snippet).toContain('costPerDayUSD: 5')
})
})
describe('groupGrantSnippet: golden YAML, matches middleware.yaml groups.friends/groups.home shape', () => {
it('emits every one of the four access lists explicitly, even when empty — "friends" shape (providers set, rest empty)', () => {
const snippet = groupGrantSnippet('friends', { providers: ['minimax', 'uni'], models: [], mcpServers: [], agents: [] })
expect(snippet).toBe(
[
'spec:',
' plugin:',
' llmgateway:',
' groups:',
' friends:',
' providers: [minimax, uni]',
' models: []',
' mcpServers: []',
' agents: []',
].join('\n'),
)
})
it('renders "home" shape — every list empty (unrestricted)', () => {
const snippet = groupGrantSnippet('home', { providers: [], models: [], mcpServers: [], agents: [] })
expect(snippet).toBe(
[
'spec:',
' plugin:',
' llmgateway:',
' groups:',
' home:',
' providers: []',
' models: []',
' mcpServers: []',
' agents: []',
].join('\n'),
)
})
})
describe('modelMetaSnippet: golden YAML flow map, matches middleware.yaml modelMeta entries exactly', () => {
it('matches the gx10-embed entry shape: "{ contextTokens: 16384, free: true }"', () => {
const snippet = modelMetaSnippet('gx10-embed/text-embedding-qwen3-0.6b', { contextTokens: 16384, free: true })
expect(snippet).toBe(
['spec:', ' plugin:', ' llmgateway:', ' modelMeta:', ' "gx10-embed/text-embedding-qwen3-0.6b": { contextTokens: 16384, free: true }'].join(
'\n',
),
)
})
it('matches the "gx10/current" entry shape: "{ free: true }"', () => {
const snippet = modelMetaSnippet('gx10/current', { free: true })
expect(snippet.split('\n').at(-1)).toBe(' "gx10/current": { free: true }')
})
// P2 item 11: modelMeta keys are ALWAYS quoted in production, even a
// bare id with no "/" — unlike pricingSnippet below, whose own golden
// test (examples/kubernetes.yaml, read this session) shows an
// unquoted bare id in that DIFFERENT config section.
it('a bare model id with no "/" or ":" is still quoted — modelMeta always quotes, unlike pricing', () => {
const snippet = modelMetaSnippet('ornith-397b', { contextTokens: 262144, free: true })
expect(snippet.split('\n').at(-1)).toBe(' "ornith-397b": { contextTokens: 262144, free: true }')
})
it('field order is always contextTokens, free — contextTokens alone', () => {
const snippet = modelMetaSnippet('openai/gpt-4', { contextTokens: 128000 })
expect(snippet.split('\n').at(-1)).toBe(' "openai/gpt-4": { contextTokens: 128000 }')
})
it('omits every field the caller did not set — an empty ModelMeta renders "{}"', () => {
const snippet = modelMetaSnippet('openai/gpt-4', {})
expect(snippet.split('\n').at(-1)).toBe(' "openai/gpt-4": {}')
})
})
describe('pricingSnippet: golden YAML flow map, matches examples/kubernetes.yaml pricing entry exactly', () => {
it('matches the "gpt-5-mini" entry shape: "pricing:\\n gpt-5-mini: {inputPerM: 0.25, outputPerM: 2.0}"', () => {
const snippet = pricingSnippet('gpt-5-mini', { inputPerM: 0.25, outputPerM: 2.0 })
expect(snippet).toBe(
['spec:', ' plugin:', ' llmgateway:', ' pricing:', ' gpt-5-mini: { inputPerM: 0.25, outputPerM: 2 }'].join('\n'),
)
})
it('nests under spec.plugin.llmgateway.pricing, NOT modelMeta — a different config section from ModelMetaForm', () => {
const snippet = pricingSnippet('openai/gpt-4', { inputPerM: 0.5, outputPerM: 1.5 })
expect(snippet).toContain(' pricing:')
expect(snippet).not.toContain('modelMeta')
})
it('quotes a "provider/model" canonical id (contains "/")', () => {
const snippet = pricingSnippet('openai/gpt-4', { inputPerM: 0.5, outputPerM: 1.5 })
expect(snippet.split('\n').at(-1)).toBe(' "openai/gpt-4": { inputPerM: 0.5, outputPerM: 1.5 }')
})
it('field order is always inputPerM then outputPerM', () => {
const snippet = pricingSnippet('bare-model', { inputPerM: 1, outputPerM: 2 })
expect(snippet.split('\n').at(-1)).toBe(' bare-model: { inputPerM: 1, outputPerM: 2 }')
})
})
describe('userLimitsJsonFragment / userGrantJsonFragment: bare JSON fields for an EXISTING users.json entry', () => {
it('renders the limits fragment in LimitsConfig field order', () => {
expect(userLimitsJsonFragment({ costPerMonthUSD: 5, requestsPerDay: 100 })).toBe('"limits": {"requestsPerDay": 100, "costPerMonthUSD": 5}')
})
it('renders the grant fragment — providers then models, both present', () => {
expect(userGrantJsonFragment({ providers: ['gx10'], models: ['gx10/current'] })).toBe('"providers": ["gx10"], "models": ["gx10/current"]')
})
it('renders only providers when models is empty — matches barteq\'s real shape ("providers": ["gx10"]) structurally', () => {
expect(userGrantJsonFragment({ providers: ['gx10'], models: [] })).toBe('"providers": ["gx10"]')
})
})
describe('userJsonLine: golden JSON, matches users-configmap.yaml entry shape exactly (fabricated apiKey, never a real one)', () => {
it('matches the "barteq" entry shape: name, apiKey, group, providers', () => {
const line = userJsonLine({ name: 'barteq', apiKey: 'sk-test-FAKEKEY0000000000000000000000000000', group: 'friends', providers: ['gx10'] })
expect(line).toBe('{"name": "barteq", "apiKey": "sk-test-FAKEKEY0000000000000000000000000000", "group": "friends", "providers": ["gx10"]}')
})
it('matches the "admin" entry shape: name, apiKey, group, admin', () => {
const line = userJsonLine({ name: 'admin', apiKey: 'sk-test-FAKEKEY0000000000000000000000000000', group: 'home', admin: true })
expect(line).toBe('{"name": "admin", "apiKey": "sk-test-FAKEKEY0000000000000000000000000000", "group": "home", "admin": true}')
})
it('matches a minimal entry shape: name, apiKey, group only (no providers/admin)', () => {
const line = userJsonLine({ name: 'yalso', apiKey: 'sk-test-FAKEKEY0000000000000000000000000000', group: 'home' })
expect(line).toBe('{"name": "yalso", "apiKey": "sk-test-FAKEKEY0000000000000000000000000000", "group": "home"}')
})
it('emits "groups" (plural) instead of "group" when multiple groups are set', () => {
const line = userJsonLine({ name: 'multi', apiKey: 'sk-test-x', groups: ['home', 'friends'] })
expect(line).toBe('{"name": "multi", "apiKey": "sk-test-x", "groups": ["home", "friends"]}')
})
it('includes a nested limits object and admin, in key order name/apiKey/group/groups/providers/models/limits/admin', () => {
const line = userJsonLine({
name: 'full',
apiKey: 'sk-test-x',
group: 'friends',
providers: ['gx10'],
models: ['gx10/current'],
limits: { requestsPerDay: 10 },
admin: true,
})
expect(line).toBe(
'{"name": "full", "apiKey": "sk-test-x", "group": "friends", "providers": ["gx10"], "models": ["gx10/current"], "limits": {"requestsPerDay": 10}, "admin": true}',
)
})
it('omits admin entirely when false (never emits "admin": false, matching every non-admin production entry)', () => {
const line = userJsonLine({ name: 'plain', apiKey: 'sk-test-x', group: 'home', admin: false })
expect(line).not.toContain('admin')
})
})
describe('validatePersonalGrant: mirrors auth.go\'s models-without-providers rejection', () => {
it('rejects models with no providers', () => {
expect(validatePersonalGrant([], ['gx10/current'])).not.toBeNull()
})
it('accepts models with at least one provider', () => {
expect(validatePersonalGrant(['gx10'], ['gx10/current'])).toBeNull()
})
it('accepts providers with no models (providers-only grant)', () => {
expect(validatePersonalGrant(['gx10'], [])).toBeNull()
})
it('accepts an entirely empty grant', () => {
expect(validatePersonalGrant([], [])).toBeNull()
})
})
describe('isValidName', () => {
it.each([
['gx10-embed', true],
['friends_2', true],
['eng.team', true],
['has space', false],
['', false],
['slash/here', false],
['wild*card', false],
])('%s -> %s', (name, expected) => {
expect(isValidName(name)).toBe(expected)
})
})
describe('isValidNonNegative', () => {
it.each([
[0, true],
[1, true],
[0.5, true],
[-1, false],
[NaN, false],
[Infinity, false],
])('%s -> %s', (n, expected) => {
expect(isValidNonNegative(n)).toBe(expected)
})
})
// P2 item 12: LimitsConfig.requestsPerMinute/requestsPerDay/tokensPerDay/
// tokensPerMonth and ModelMetaConfig.ContextTokens are Go int64/int —
// isValidNonNegative alone (fractions allowed) lets a value through that
// json.Unmarshal rejects server-side.
describe('isValidNonNegativeInt', () => {
it.each([
[0, true],
[1, true],
[1000, true],
[0.5, false],
[-1, false],
[NaN, false],
[Infinity, false],
])('%s -> %s', (n, expected) => {
expect(isValidNonNegativeInt(n)).toBe(expected)
})
})
// P3 item 27: auth.go's buildEntry only requires a non-empty user name —
// no configNamePattern character restriction (that pattern exists only
// because provider/mcpServers/agents names are embedded as URL path
// segments; a user name never is).
describe('isValidUserName', () => {
it.each([
['alice', true],
['jan@x', true],
["O'Brien", true],
['has space', true],
['', false],
[' ', false],
])('%s -> %s', (name, expected) => {
expect(isValidUserName(name)).toBe(expected)
})
})
describe('generateApiKey / API_KEY_PLACEHOLDER', () => {
it('the placeholder is visibly not a real key', () => {
expect(API_KEY_PLACEHOLDER).toBe('sk-llmgw-REPLACE_ME')
})
it('generates a "sk-llmgw-" prefixed base64url token from 32 random bytes', () => {
const spy = vi.spyOn(crypto, 'getRandomValues').mockImplementation(((arr: Uint8Array) => {
arr.fill(7)
return arr
}) as typeof crypto.getRandomValues)
try {
const key = generateApiKey()
expect(key.startsWith('sk-llmgw-')).toBe(true)
expect(key).not.toContain('+')
expect(key).not.toContain('/')
expect(key).not.toContain('=')
expect(key.length).toBeGreaterThan('sk-llmgw-'.length)
} finally {
spy.mockRestore()
}
})
it('two calls produce different keys (real CSPRNG, not mocked)', () => {
expect(generateApiKey()).not.toBe(generateApiKey())
})
})
+293
View File
@@ -0,0 +1,293 @@
// Config page's ChangeHelper (redesign-plan.md section 3.4): every form
// (LimitsForm, GrantForm, PricingForm, ModelMetaForm, UserForm) funnels
// through this module to build the actual paste-ready text — the ONE place
// that knows the production file's exact shapes (home-cluster namespaces/
// traefik/base/llmgateway/middleware.yaml, users-configmap.yaml), so every
// form's snippet output is provably identical in format, not five
// independent guesses. Group/provider/admin snippets are YAML (lib/
// yaml-emit.ts, nested at spec.plugin.llmgateway.<section>); a user's own
// entry lives in users.json instead, so USER-targeted output is a
// single-line JSON object (new user) or a bare JSON field fragment (adding
// limits/a personal grant to an EXISTING user's line) — never YAML.
//
// Validation lives here too, not in each form component, for the same
// "one place, not five copies" reason: NAME_PATTERN/isValidName (provider
// and group names), isValidNonNegative (every limit/price field), and
// validatePersonalGrant (auth.go's own "models without providers" rejection
// — auth.go ~1006, a user/group with a `models` glob but an EMPTY
// `providers` list can never actually reach any of those models, since
// authorizedProviders is computed first and gates model resolution).
import { emitConfigSnippet, emitYamlFlowMap, emitYamlKey, emitYamlScalar } from '@/lib/yaml-emit'
import type { YamlValue } from '@/lib/yaml-emit'
import type { LimitsConfig } from '@/types/api'
/** NAME_PATTERN is the operator-facing name rule every provider/group id must satisfy — letters, digits, dot, underscore, hyphen, matching every real name in middleware.yaml (anthropic, gx10-embed, macstudio-rerank, friends, ...) and mirroring the plugin's own path.Match glob-safe charset. */
export const NAME_PATTERN = /^[a-zA-Z0-9._-]+$/
/** isValidName reports whether a provider/group name satisfies NAME_PATTERN — GrantForm/LimitsForm's own `targetKind === 'group'` path, and PricingForm/ModelMetaForm's provider-prefixed model ids indirectly (via NAME_PATTERN-shaped provider segments). NOT for a user name — see isValidUserName below. */
export function isValidName(name: string): boolean {
return NAME_PATTERN.test(name)
}
/**
* isValidUserName mirrors auth.go's own buildEntry check (~955-975,
* ground truth): a user name only needs to be non-empty. Go deliberately
* does NOT apply configNamePattern's provider/mcpServers/agents character
* restriction (providers.go ~497-502, `^[a-zA-Z0-9._-]+$`) to user names
* — that pattern exists ONLY because those three kinds get embedded
* verbatim as a URL path segment (passthroughRoute, targetRoute); a user
* name is never used as a route path segment anywhere, only as a
* counter-key component and a JSON field (auth.go's own doc comment,
* word for word: "Restricting the character set beyond 'non-empty' would
* risk rejecting a real, currently-working operator-chosen name"). P3
* item 27: GrantForm.vue/LimitsForm.vue/UserForm.vue used to apply
* isValidName (NAME_PATTERN) to a user name too, so a real user such as
* "jan@x" could never get a snippet. userJsonLine/userGrantJsonFragment/
* userLimitsJsonFragment all go through JSON.stringify for every string
* field, so relaxing this check to match Go carries no injection risk.
*/
export function isValidUserName(name: string): boolean {
return name.trim() !== ''
}
/** isValidNonNegative reports whether a limit/price field is a finite, non-negative number — every LimitsConfig/pricing field's own domain (a negative or NaN limit/price is meaningless). Fractions ARE valid here: costPerDayUSD/costPerMonthUSD (LimitsConfig) and inputPerM/outputPerM (ModelPricing) are all Go float64 fields. */
export function isValidNonNegative(n: number): boolean {
return Number.isFinite(n) && n >= 0
}
/**
* isValidNonNegativeInt reports whether a count/limit field is a finite,
* non-negative WHOLE number — the domain LimitsForm.vue's
* requestsPerMinute/requestsPerDay/tokensPerDay/tokensPerMonth
* (LimitsConfig, all Go `int64`) and ModelMetaForm.vue's contextTokens
* (ModelMetaConfig.ContextTokens, Go `int`) actually decode into (P2 item
* 12): `json.Unmarshal` rejects a fractional value like `1.5` against an
* int64/int target outright, so a snippet built from an unchecked
* fraction would fail to apply, not just round unexpectedly.
*/
export function isValidNonNegativeInt(n: number): boolean {
return Number.isInteger(n) && n >= 0
}
/**
* validatePersonalGrant mirrors auth.go's own buildEntry rejection
* (~line 1005-1007, ground truth: redesign-plan.md section 0): a USER's
* personal `models` glob list with an EMPTY `providers` list is rejected
* outright — `llmgateway: user %q: personal "models" requires personal
* "providers"`. This is a USER-ONLY rule (GrantForm.vue must call this
* only when targetKind === 'user'): a GROUP's own `providers: []` means
* "every configured provider" (matchesGlob's own empty-means-all
* contract, auth.go ~487-490) and is applied directly with no such check
* at group-construction time (auth.go ~763) — a group with `providers: []`
* and a `models` list is valid config today. For a user's PERSONAL grant
* specifically, that same empty-means-all reading is the problem, not the
* fix: it would silently widen the grant to reach every configured
* provider, including ones no member group of that user covers at all
* (auth.go's own comment, ~995-1000) — so Go rejects it rather than
* silently granting more than the operator meant to type. Returns an
* error string when invalid, null when the grant is acceptable.
*/
export function validatePersonalGrant(providers: string[], models: string[]): string | null {
if (models.length > 0 && providers.length === 0) {
return 'A personal grant naming models needs at least one provider too — an empty providers list means EVERY configured provider (not none), so this would silently widen access far beyond what was typed (auth.go rejects it).'
}
return null
}
/** LIMITS_FIELD_ORDER is LimitsConfig's own field order (types/api.ts) — the single source of truth every limits snippet (YAML block or JSON fragment) below renders in, so the two never drift relative to each other. */
const LIMITS_FIELD_ORDER: (keyof LimitsConfig)[] = [
'requestsPerMinute',
'requestsPerDay',
'tokensPerDay',
'tokensPerMonth',
'costPerDayUSD',
'costPerMonthUSD',
]
/** limitsEntries reads only the CONFIGURED (truthy) fields off a LimitsConfig, in canonical order — mirrors lib/format.ts's formatLimits/lib/usage-bars.ts's budgetRatios own "0/omitted means unlimited" convention: an unset limit is never emitted as `0`. */
function limitsEntries(limits: LimitsConfig): [string, number][] {
const out: [string, number][] = []
for (const key of LIMITS_FIELD_ORDER) {
const value = limits[key]
if (value) out.push([key, value])
}
return out
}
/** groupLimitsSnippet builds a group's YAML limits block, nested at spec.plugin.llmgateway.groups.<name>.limits — LimitsForm's group-target output. */
export function groupLimitsSnippet(groupName: string, limits: LimitsConfig): string {
const body: { [key: string]: YamlValue | undefined } = {}
for (const [key, value] of limitsEntries(limits)) body[key] = value
return emitConfigSnippet(['groups', groupName], { limits: body })
}
function jsonString(s: string): string {
return JSON.stringify(s)
}
function jsonStringArray(values: string[]): string {
return `[${values.map(jsonString).join(', ')}]`
}
/** jsonLimitsObject renders a LimitsConfig as a JSON object literal (no surrounding key) — "{\"requestsPerDay\": 1000}" — the SAME field order/omit-when-unset convention limitsEntries applies for the YAML path, just JSON syntax instead. */
function jsonLimitsObject(limits: LimitsConfig): string {
const parts = limitsEntries(limits).map(([key, value]) => `${jsonString(key)}: ${value}`)
return `{${parts.join(', ')}}`
}
/** userLimitsJsonFragment builds the bare `"limits": {...}` field to paste into an EXISTING user's users.json line — LimitsForm's user-target output (a user's own limits live inside their JSON object, not a separate YAML block). */
export function userLimitsJsonFragment(limits: LimitsConfig): string {
return `"limits": ${jsonLimitsObject(limits)}`
}
/** GroupGrant is the four access lists GroupConfig carries (llmgateway.go) — GrantForm's group-target input. Every field is always emitted, even empty (middleware.yaml's own "every field explicit and empty, not a bare {}" convention, groups.home). */
export interface GroupGrant {
providers: string[]
models: string[]
mcpServers: string[]
agents: string[]
}
/** groupGrantSnippet builds a group's YAML access-list block, nested at spec.plugin.llmgateway.groups.<name> — GrantForm's group-target output. */
export function groupGrantSnippet(groupName: string, grant: GroupGrant): string {
return emitConfigSnippet(['groups', groupName], {
providers: grant.providers,
models: grant.models,
mcpServers: grant.mcpServers,
agents: grant.agents,
})
}
/** userGrantJsonFragment builds the bare `"providers": [...], "models": [...]` fields to paste into an EXISTING user's users.json line — GrantForm's user-target (personal grant) output. Only non-empty lists are emitted (an omitted personal-grant field means "nothing extra beyond the user's group", the same "0/omitted means unset" convention every other optional field in this file follows) — callers MUST reject the grant first via validatePersonalGrant. */
export function userGrantJsonFragment(grant: { providers: string[]; models: string[] }): string {
const parts: string[] = []
if (grant.providers.length > 0) parts.push(`"providers": ${jsonStringArray(grant.providers)}`)
if (grant.models.length > 0) parts.push(`"models": ${jsonStringArray(grant.models)}`)
return parts.join(', ')
}
/**
* ModelMeta is a model's modelMeta entry — every field independently
* optional (ModelMetaConfig, llmgateway.go: `contextTokens`,
* `inputCostPerMTokMicroUsd`, `outputCostPerMTokMicroUsd`, `free`), mirroring
* AdminModelMetaView/AdminCatalogModel's own "undefined means unset"
* convention. ModelMetaForm sets only contextTokens/free — the two fields
* this webui exposes an editor for (its own doc comment). PricingForm
* does NOT go through this type or modelMetaSnippet below: per-token
* price overrides that drive BILLING live in the top-level Config.Pricing
* map (`spec.plugin.llmgateway.pricing`, ModelPricing{InputPerM,
* OutputPerM} — see pricingSnippet), a config section entirely distinct
* from modelMeta's own cost fields, which only drive metadata EXPOSURE
* (GET /v1/models, Config's own ModelMeta doc comment: "distinct from
* Pricing above, which drives request cost ACCOUNTING").
*/
export interface ModelMeta {
contextTokens?: number
free?: boolean
}
/**
* modelMetaSnippet builds a model's modelMeta YAML entry as a single-line
* flow map, matching middleware.yaml's own convention EXACTLY (golden
* strings, e.g. `"gx10-embed/text-embedding-qwen3-0.6b": { contextTokens:
* 16384, free: true }`): field order contextTokens, free — only the
* fields the caller actually set, in that fixed order, every other field
* entirely absent from the flow map (never a fabricated 0/false). modelId
* is ALWAYS quoted (JSON.stringify, not lib/yaml-emit.ts's conditional
* emitYamlKey — production quotes every modelMeta id, P2 item 11), and
* the flow map itself via emitYamlFlowMap — placed at modelMeta's own
* fixed 4-level indent by hand rather than through the generic recursive
* block renderer, since a flow map is a single line, not a further nested
* block.
*/
export function modelMetaSnippet(modelId: string, meta: ModelMeta): string {
const entries: [string, boolean | number][] = []
if (meta.contextTokens !== undefined) entries.push(['contextTokens', meta.contextTokens])
if (meta.free !== undefined) entries.push(['free', meta.free])
const flow = emitYamlFlowMap(entries)
// Always quoted (JSON.stringify, not the conditional emitYamlKey) — the
// production file quotes every modelMeta id regardless of whether
// emitYamlKey's own general rule would require it (P2 item 11),
// matching pricingSnippet's identical convention below.
return ['spec:', ' plugin:', ' llmgateway:', ' modelMeta:', ` ${JSON.stringify(modelId)}: ${flow}`].join('\n')
}
/** ModelPricingOverride mirrors ModelPricing exactly (llmgateway.go: `InputPerM float64 json:"inputPerM"`, `OutputPerM float64 json:"outputPerM"`) — the billing-authoritative per-token price override, keyed by canonical model id under the top-level Config.Pricing map. Both fields are required together (PricingForm.vue's own validation): a one-sided override has no billing meaning. */
export interface ModelPricingOverride {
inputPerM: number
outputPerM: number
}
/**
* pricingSnippet builds one model's entry in the top-level `pricing` map
* (`spec.plugin.llmgateway.pricing.<id>`, Config.Pricing/ModelPricing,
* llmgateway.go) as a single-line flow map — matching examples/
* kubernetes.yaml's own real shape exactly: `gpt-5-mini: {inputPerM: 0.25,
* outputPerM: 2.0}`. Hand-built at `pricing`'s own fixed depth, the same
* convention modelMetaSnippet above follows for its own sibling top-level
* map — a flow map is a single line, not a further nested block, so it is
* not routed through emitConfigSnippet's generic recursive block
* renderer.
*/
export function pricingSnippet(modelId: string, pricing: ModelPricingOverride): string {
const flow = emitYamlFlowMap([
['inputPerM', pricing.inputPerM],
['outputPerM', pricing.outputPerM],
])
// Conditional (yaml-emit.ts's own emitYamlKey), NOT always-quoted —
// unlike modelMetaSnippet's own convention above: examples/
// kubernetes.yaml's real "pricing:" entry (line 146, read this session)
// is `gpt-5-mini: {inputPerM: 0.25, outputPerM: 2.0}`, a bare id with
// no quotes, so pricing does NOT share modelMeta's "always quote"
// production convention — it follows the general rule instead.
return ['spec:', ' plugin:', ' llmgateway:', ' pricing:', ` ${emitYamlKey(modelId)}: ${flow}`].join('\n')
}
/** API_KEY_PLACEHOLDER is UserForm's default apiKey field value before the operator clicks "Generate" — visibly a placeholder, never mistaken for a real key (Q10, redesign-plan.md DECISIONS: "browser key generation offered, placeholder by default"). */
export const API_KEY_PLACEHOLDER = 'sk-llmgw-REPLACE_ME'
/** generateApiKey produces a real random bearer token client-side: 32 bytes from the Web Crypto CSPRNG, base64url-encoded — the same byte length and character set as the production tokens in users-configmap.yaml (openssl rand -hex 24 there is 24 bytes/48 hex chars; 32 raw bytes here is a comparable-or-stronger entropy budget, base64url rather than hex purely for a shorter pasted string). Never sent anywhere — ChangeHelper only ever displays it for the operator to copy into their own users.json edit. */
export function generateApiKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
const base64 = btoa(binary)
return `sk-llmgw-${base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`
}
/** UserSnippetInput is UserForm's full input shape — mirrors AdminConsumerUser (types/api.ts) plus the two config-only fields (groups plural, limits) that view never carries but users.json itself supports. */
export interface UserSnippetInput {
name: string
apiKey: string
/** Single primary group — production's own convention (users-configmap.yaml: every existing user sets exactly this field). Mutually exclusive with `groups` in practice (userJsonLine emits whichever is set; a caller offering both is a UserForm UI bug, not something this function arbitrates). */
group?: string
/** Multi-group membership (newer plugin support, mirrors AdminUsageEntryView.groups' own multi-group doc comment) — used instead of `group` when the operator selects more than one. */
groups?: string[]
providers?: string[]
models?: string[]
limits?: LimitsConfig
admin?: boolean
}
/**
* userJsonLine builds one users.json array entry as a single-line JSON
* object — GOLDEN-STRING matched against users-configmap.yaml's own real
* entries, e.g. `{"name": "barteq", "apiKey": "sk-friend-...", "group":
* "friends", "providers": ["gx10"]}`: key order name, apiKey, group,
* groups, providers, models, limits, admin (redesign-plan.md section 3.4),
* `": "` after every key and `", "` between fields (NOT JSON.stringify's
* own no-space-after-colon default — production's own file has the space),
* only present/truthy fields emitted, `admin` only ever appears as the
* literal `true` (an admin:false entry is simply omitted, matching every
* non-admin user in the production file).
*/
export function userJsonLine(user: UserSnippetInput): string {
const parts: string[] = [`"name": ${jsonString(user.name)}`, `"apiKey": ${jsonString(user.apiKey)}`]
if (user.group) parts.push(`"group": ${jsonString(user.group)}`)
if (user.groups && user.groups.length > 0) parts.push(`"groups": ${jsonStringArray(user.groups)}`)
if (user.providers && user.providers.length > 0) parts.push(`"providers": ${jsonStringArray(user.providers)}`)
if (user.models && user.models.length > 0) parts.push(`"models": ${jsonStringArray(user.models)}`)
if (user.limits && limitsEntries(user.limits).length > 0) parts.push(`"limits": ${jsonLimitsObject(user.limits)}`)
if (user.admin) parts.push(`"admin": ${emitYamlScalar(true)}`)
return `{${parts.join(', ')}}`
}
+94 -2
View File
@@ -1,9 +1,10 @@
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import CompactNumber from '@/components/CompactNumber.vue'
import { Badge } from '@/components/ui/badge'
import { targetColumns } from './target-columns'
import type { AdminTargetHealthView, AdminTargetView } from '@/types/api'
import { composeTargetId, parseTargetCallerId, targetCallerColumns, targetColumns } from './target-columns'
import type { AdminTargetHealthView, AdminTargetView, AdminTotalsResponse } from '@/types/api'
/**
* Target health rendering (feat/target-health): mirrors usage-columns.
@@ -184,3 +185,94 @@ describe('targetColumns: access column', () => {
expect(children.every((c) => isVNode(c) && c.type === Badge)).toBe(true)
})
})
describe('targetColumns: name column selection', () => {
it('renders plain text with no onSelect (default, backward-compatible)', () => {
const col = targetColumns().find((c) => c.id === 'name')
const cell = col?.cell as (ctx: { row: { original: AdminTargetView } }) => unknown
const rendered = cell({ row: { original: testTarget({ state: 'unknown', consecutiveFailures: 0 }) } })
expect(isVNode(rendered) && rendered.type).toBe('span')
})
it('renders a clickable button that invokes onSelect with the row when given', () => {
const selected: AdminTargetView[] = []
const col = targetColumns((t) => selected.push(t)).find((c) => c.id === 'name')
const cell = col?.cell as (ctx: { row: { original: AdminTargetView } }) => unknown
const target = testTarget({ state: 'unknown', consecutiveFailures: 0 })
const rendered = cell({ row: { original: target } })
expect(isVNode(rendered) && rendered.type).toBe('button')
const onClick = (rendered as { props?: Record<string, unknown> }).props?.onClick as (e: MouseEvent) => void
onClick({ stopPropagation: () => {} } as MouseEvent)
expect(selected).toEqual([target])
})
})
describe('composeTargetId', () => {
it('joins kind and name with a slash', () => {
expect(composeTargetId('mcp', 'fetch')).toBe('mcp/fetch')
expect(composeTargetId('agent', 'researcher')).toBe('agent/researcher')
})
})
describe('parseTargetCallerId', () => {
it('splits a well-formed "kind/target/caller" id into its three parts', () => {
expect(parseTargetCallerId('mcp/fetch/alice')).toEqual({ kind: 'mcp', target: 'fetch', caller: 'alice' })
})
it('handles a caller name that itself contains a slash by keeping it in `caller` verbatim', () => {
expect(parseTargetCallerId('agent/researcher/team/bob')).toEqual({ kind: 'agent', target: 'researcher', caller: 'team/bob' })
})
it('falls back to an empty target/caller for an id with no slash at all, without throwing', () => {
expect(parseTargetCallerId('malformed')).toEqual({ kind: '', target: '', caller: 'malformed' })
})
it('falls back to an empty caller for an id with only one slash', () => {
expect(parseTargetCallerId('mcp/fetch')).toEqual({ kind: 'mcp', target: 'fetch', caller: '' })
})
})
// dayOrMonthWindow moved to lib/range.ts (P2 item 2: a SHARED clamp used
// by stores/consumers.ts, stores/spend.ts, AND this module's own
// TargetCallers.vue — see range.spec.ts's own describe('dayOrMonthWindow').
describe('targetCallerColumns', () => {
function row(id: string, req: number): AdminTotalsResponse['rows'][number] {
return { id, values: { req } }
}
it('omits the Target column when showTarget is false', () => {
const columns = targetCallerColumns(false)
expect(columns.find((c) => c.id === 'target')).toBeUndefined()
expect(columns.map((c) => c.id)).toEqual(['caller', 'requests'])
})
it('includes the Target column, rendering "kind/target", when showTarget is true', () => {
const columns = targetCallerColumns(true)
const col = columns.find((c) => c.id === 'target')
expect(col).toBeDefined()
const cell = col?.cell as (ctx: { row: { original: AdminTotalsResponse['rows'][number] } }) => unknown
const rendered = cell({ row: { original: row('mcp/fetch/alice', 3) } })
expect(isVNode(rendered) && rendered.children).toBe('mcp/fetch')
})
it('caller column reads the parsed caller name, falling back to the raw id', () => {
const columns = targetCallerColumns(false)
const col = columns.find((c) => c.id === 'caller') as unknown as {
accessorFn: (r: AdminTotalsResponse['rows'][number], i: number) => unknown
}
const accessor = col.accessorFn
expect(accessor(row('mcp/fetch/alice', 3), 0)).toBe('alice')
expect(accessor(row('malformed', 3), 0)).toBe('malformed')
})
it('requests column renders a CompactNumber reading values.req, defaulting to 0', () => {
const columns = targetCallerColumns(false)
const col = columns.find((c) => c.id === 'requests')
const cell = col?.cell as (ctx: { row: { original: AdminTotalsResponse['rows'][number] } }) => unknown
const rendered = cell({ row: { original: { id: 'mcp/fetch/alice', values: {} } } })
expect(isVNode(rendered) && rendered.type).toBe(CompactNumber)
expect(isVNode(rendered) && rendered.props?.value).toBe(0)
})
})
+127 -10
View File
@@ -4,7 +4,7 @@ import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import { Badge } from '@/components/ui/badge'
import { formatElapsedAgo, formatLatencyMs } from '@/lib/format'
import type { AdminTargetHealthView, AdminTargetView } from '@/types/api'
import type { AdminTargetHealthView, AdminTargetView, AdminTotalsResponse } from '@/types/api'
/**
* numericColumn builds one right-aligned, sortable request-counter
@@ -30,7 +30,7 @@ function numericColumn(id: string, header: string, read: (t: AdminTargetView) =>
// --- target health (feat/target-health) ---
//
// Mirrors ProvidersView.vue's own healthBadgeVariant/healthBadgeLabel
// Mirrors ProviderHealthPanel.vue's own healthBadgeVariant/healthBadgeLabel
// convention for the discovery circuit breaker: a Badge only for the two
// states worth flagging (unhealthy, unknown); the common healthy case
// renders no badge at all, just muted inline text — the same "nothing to
@@ -107,20 +107,40 @@ function healthCell(health: AdminTargetHealthView) {
* servers and the Agents DataTable in TargetsView.vue use (admin.go:
* adminTargetView — the two tables share one identical row shape). name is
* plain text (not the ModelChip copy treatment — a target name is not a
* routable model id); url is muted and truncated with a title attribute
* for the full value; health is the Badge/muted-text pair healthCell above
* builds (feat/target-health); access renders as a Badge per allowed
* group, or a muted "All groups" when unrestricted — the exact idiom
* UsageView.vue's own group Access block already established (Providers/
* Models/MCP servers/Agents dt/dd pairs there).
* routable model id) UNLESS `onSelect` is given, in which case it renders
* as a real `<button>` (same structural convention as EntityLink.vue: a
* click opens that target's own caller breakdown, TargetCallers.vue,
* rather than navigating away — redesign-plan.md section 3.4's
* "TargetCallers.vue expander"). url is muted and truncated with a title
* attribute for the full value; health is the Badge/muted-text pair
* healthCell above builds (feat/target-health); access renders as a
* Badge per allowed group, or a muted "All groups" when unrestricted —
* the exact idiom ConsumerDirectory.vue's own group Access block already
* established (Providers/Models/MCP servers/Agents dt/dd pairs there).
*/
export function targetColumns(): ColumnDef<AdminTargetView, unknown>[] {
export function targetColumns(onSelect?: (target: AdminTargetView) => void): ColumnDef<AdminTargetView, unknown>[] {
return [
{
id: 'name',
header: 'Name',
accessorFn: (t) => t.name,
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.name),
cell: ({ row }) => {
if (!onSelect) return h('span', { class: 'font-medium' }, row.original.name)
return h(
'button',
{
type: 'button',
class:
'cursor-pointer rounded-sm font-medium hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
title: `View ${row.original.name}'s callers`,
onClick: (event: MouseEvent) => {
event.stopPropagation()
onSelect(row.original)
},
},
row.original.name,
)
},
},
{
id: 'url',
@@ -166,3 +186,100 @@ export function targetColumns(): ColumnDef<AdminTargetView, unknown>[] {
numericColumn('reqMonth', 'req/month', (t) => t.counters.requestsPerMonth),
]
}
// --- target x caller (redesign-plan.md section 3.4's "TargetCallers.vue
// expander" + "top callers" card) ---
//
// GET /admin/api/usage/totals?kind=targetcaller (redesign-plan.md section
// 1.3.v) has no dedicated row type in types/api.ts — AdminTotalsResponse
// inlines its row shape rather than naming it, so `TargetCallerRow` is
// derived from that response's own `rows` element type here (single source
// of truth: a future field added to the inline shape needs no second edit
// in this file).
type TargetCallerRow = AdminTotalsResponse['rows'][number]
export type TargetKind = 'mcp' | 'agent'
/**
* composeTargetId builds the "mcp/{name}" / "agent/{name}" composite id
* GET /admin/api/usage/totals?kind=targetcaller&target= expects (admin.go)
* — the SAME prefix a targetcaller row's own `id` starts with
* (parseTargetCallerId below splits it back apart).
*/
export function composeTargetId(kind: TargetKind, name: string): string {
return `${kind}/${name}`
}
/**
* ParsedTargetCallerId is one targetcaller row's `id`
* ("mcp/fetch/alice" — AdminTotalsResponse's own doc comment) split into
* its three parts.
*/
export interface ParsedTargetCallerId {
kind: string
target: string
caller: string
}
/**
* parseTargetCallerId never throws: an id missing either slash (a
* version-skewed server, or a caller name that happens to contain no
* slash of its own — the only way this format is ambiguous) degrades
* gracefully rather than dropping the row — `target`/`caller` fall back to
* "" so a caller can still detect the shortfall (empty string, not
* undefined) and choose to render the raw id verbatim.
*/
export function parseTargetCallerId(id: string): ParsedTargetCallerId {
const first = id.indexOf('/')
if (first === -1) return { kind: '', target: '', caller: id }
const kind = id.slice(0, first)
const rest = id.slice(first + 1)
const second = rest.indexOf('/')
if (second === -1) return { kind, target: rest, caller: '' }
return { kind, target: rest.slice(0, second), caller: rest.slice(second + 1) }
}
/**
* targetCallerColumns builds the shared TanStack `ColumnDef` set
* TargetCallers.vue's DataTable uses for both its modes: a single
* target's own caller breakdown (`showTarget: false` — the panel's own
* heading already names the target) and the fleet-wide "top callers" card
* (`showTarget: true` — each row's own target is not implied by anything
* else on screen). `requests` is the only metric the server returns for
* this kind (contract: "targetcaller req only"), so there is no metric
* picker here, unlike model-table-columns.ts's multi-metric table.
*/
export function targetCallerColumns(showTarget: boolean): ColumnDef<TargetCallerRow, unknown>[] {
const columns: ColumnDef<TargetCallerRow, unknown>[] = []
if (showTarget) {
columns.push({
id: 'target',
header: 'Target',
accessorFn: (row) => {
const p = parseTargetCallerId(row.id)
return `${p.kind}/${p.target}`
},
cell: ({ row }) => {
const p = parseTargetCallerId(row.original.id)
return h('span', { class: 'font-mono text-xs text-muted-foreground' }, `${p.kind}/${p.target}`)
},
})
}
columns.push({
id: 'caller',
header: 'Caller',
accessorFn: (row) => parseTargetCallerId(row.id).caller || row.id,
cell: ({ row }) => {
const caller = parseTargetCallerId(row.original.id).caller
return h('span', { class: 'font-medium' }, caller || row.original.id)
},
})
columns.push({
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (row) => row.values.req ?? 0,
cell: ({ row }) => h(CompactNumber, { value: row.original.values.req ?? 0 }),
})
return columns
}
+6
View File
@@ -120,3 +120,9 @@ describe('budgetRatios', () => {
expect(ratios.map((r) => r.id)).toEqual(['reqMin', 'reqDay', 'tokDay', 'tokMonth', 'costDay', 'costMonth'])
})
})
// estimatedRunOutDate was removed (P2 item 14 — it disagreed with
// lib/burndown.ts's runOutDate on an already-exceeded budget and on day 1
// of the month; UserDetail.vue and BurnDownChart.vue now both call the
// one function in lib/burndown.ts, which owns this coverage —
// lib/burndown.spec.ts's own `describe('runOutDate', ...)`).
+16 -8
View File
@@ -1,8 +1,9 @@
// F1 (usage-vs-limit bars): pure ratio math for the Usage tab's per-scope
// budget bars (UsageBar.vue), kept out of the component the same way
// lib/provider-rate.ts keeps the Providers tab's success-rate math out of
// ProvidersView.vue — vitest's node-environment config (vite.config.ts's
// `test` block) exercises it directly, no component mount required.
// F1 (usage-vs-limit bars): pure ratio math for the Consumers page's
// per-scope budget bars (UsageBar.vue), kept out of the component the
// same way lib/provider-rate.ts keeps ProviderHealthPanel.vue's own
// success-rate math out of it — vitest's node-environment config
// (vite.config.ts's `test` block) exercises it directly, no component
// mount required.
import type { AdminUsageEntryView } from '@/types/api'
/** BAR_AMBER_THRESHOLD is the used/limit ratio at and above which a bar reads amber ("approaching the limit") — below it, a bar reads its normal (healthy) tier. */
@@ -53,9 +54,10 @@ export const MICROS_PER_USD = 1_000_000
/**
* BUDGET_RATIO_LABEL (P10) names each BudgetRatio id in accurate,
* accessible text — shared by UsageView.vue's group-detail-grid bars and
* usage-columns.ts's own per-column bars (UsageBar's `label` prop, its
* aria-label). tokDay/tokMonth spell out "(in+out)" explicitly: the
* accessible text — shared by ConsumerDirectory.vue's group-detail-grid
* bars, usage-columns.ts's own per-column bars, and UserDetail.vue's
* headroom bars (UsageBar's `label` prop, its aria-label). tokDay/
* tokMonth spell out "(in+out)" explicitly: the
* underlying ratio combines both directions (see budgetRatios' own doc
* comment below), and unlike a sighted reader — who sees the separate
* tokIn/tokOut columns beside it for context — a screen-reader user
@@ -129,3 +131,9 @@ export function budgetRatios(entry: AdminUsageEntryView): BudgetRatio[] {
}
return out
}
// estimatedRunOutDate was removed (P2 item 14, DRY): UserDetail.vue and
// BurnDownChart.vue now both call lib/burndown.ts's own runOutDate, the
// ONE run-out projection in this codebase — it used to disagree with this
// one on an already-exceeded budget (null here vs `now` there) and on day
// 1 of the month (clamped/overstated here vs null there).
+64 -16
View File
@@ -2,7 +2,7 @@ import type { VNode } from 'vue'
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import ScopeLink from '@/components/ScopeLink.vue'
import EntityLink from '@/components/EntityLink.vue'
import { usageColumns } from './usage-columns'
import type { AdminUsageEntryView } from '@/types/api'
@@ -182,39 +182,87 @@ describe('usageColumns: F1 usage-vs-limit bars', () => {
})
})
// F6 (ScopeLink id cell): the id column renders a ScopeLink pointed at
// this row's own "kind:id" Charts scope, never a plain span — the click-
// through-to-Charts affordance every Users/Groups/Members row gets.
describe('usageColumns: F6 id cell is a ScopeLink', () => {
// F6 (EntityLink id cell): the id column renders an EntityLink pointed at
// Consumers?user={id}, never a plain span — the click-through-to-detail
// affordance every Users/Members row gets.
describe('usageColumns: F6 id cell is an EntityLink', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
const col = columns.find((c) => c.id === 'id')
it('renders a ScopeLink vnode, not a plain span', () => {
it('renders an EntityLink vnode, not a plain span', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user' }) } }) as VNode
expect(rendered.type).toBe(ScopeLink)
expect(rendered.type).toBe(EntityLink)
})
it('builds the scope as "user:{id}" for a user row', () => {
it('builds kind "user" and the row id, with the label matching', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user' }) } }) as VNode
expect(rendered.props?.scope).toBe('user:alice')
expect(rendered.props?.kind).toBe('user')
expect(rendered.props?.id).toBe('alice')
expect(rendered.props?.label).toBe('alice')
})
it('builds the scope as "group:{id}" for a group row', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'eng', kind: 'group' }) } }) as VNode
expect(rendered.props?.scope).toBe('group:eng')
it('is not masked by storeDown — the link stays clickable even for a row with stale/unknown counters', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user', storeDown: true }) } }) as VNode
expect(rendered.type).toBe(EntityLink)
})
})
describe('usageColumns: last seen column', () => {
const now = new Date(Date.UTC(2026, 3, 16, 12, 0, 0))
const columns = usageColumns('Name', 'Group', () => 'g', now)
const col = columns.find((c) => c.id === 'lastSeen')
it('accessorFn reads the raw unix-seconds value, defaulting to 0 when unset', () => {
expect(accessorFnOf(col)(testEntry(), 0)).toBe(0)
expect(accessorFnOf(col)(testEntry({ lastSeen: 123 }), 0)).toBe(123)
})
it('is not masked by storeDown — a scope link stays clickable even for a row with stale/unknown counters', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user', storeDown: true }) } }) as VNode
expect(rendered.type).toBe(ScopeLink)
it('cell renders "never" when unset', () => {
expect(cellOf(col)({ row: { original: testEntry() } })).toBe('never')
})
it('cell renders a relative-time label when set', () => {
const seconds = Math.floor(now.getTime() / 1000) - 300
expect(cellOf(col)({ row: { original: testEntry({ lastSeen: seconds }) } })).toBe('5m ago')
})
it('is NOT masked by storeDown — lastSeen is its own counter family, independent of the usage counters storeDown guards', () => {
const seconds = Math.floor(now.getTime() / 1000) - 300
expect(cellOf(col)({ row: { original: testEntry({ lastSeen: seconds, storeDown: true }) } })).toBe('5m ago')
})
})
describe('usageColumns: optional source column', () => {
it('is absent when no sourceValue is passed', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
expect(columns.find((c) => c.id === 'source')).toBeUndefined()
})
it('is inserted right after the secondary column when sourceValue is passed', () => {
const columns = usageColumns('Name', 'Group', () => 'g', new Date(), (e) => (e.id === 'alice' ? 'inline' : undefined))
const ids = columns.map((c) => c.id)
expect(ids.indexOf('source')).toBe(ids.indexOf('secondary') + 1)
})
it('renders the resolved source text', () => {
const columns = usageColumns('Name', 'Group', () => 'g', new Date(), (e) => (e.id === 'alice' ? 'inline' : undefined))
const col = columns.find((c) => c.id === 'source')
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice' }) } }) as VNode
expect(rendered.children).toBe('inline')
})
it('renders an em dash when the lookup returns undefined (no matching /consumers entry)', () => {
const columns = usageColumns('Name', 'Group', () => 'g', new Date(), () => undefined)
const col = columns.find((c) => c.id === 'source')
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'bob' }) } }) as VNode
expect(rendered.children).toBe('—')
})
})
// F4 (rejected/day column): destructive tint applies only once the count
// is actually positive — a healthy scope (0 rejections) renders plain,
// matching every other "nothing to show reads as no flag" indicator in
// this panel (ProvidersView.vue's badges).
// this panel (ProviderHealthPanel.vue's badges).
describe('usageColumns: F4 rejected/day column', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
const col = columns.find((c) => c.id === 'rejDay')
+73 -21
View File
@@ -2,10 +2,11 @@ import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import EntityLink from '@/components/EntityLink.vue'
import UsageBar from '@/components/UsageBar.vue'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
import { formatLastSeen } from '@/lib/last-seen'
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
import type { AdminUsageEntryView } from '@/types/api'
@@ -150,38 +151,84 @@ function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntry
}
}
/**
* sourceColumn (Consumers redesign, redesign-plan.md section 3.4) renders
* a user's directory source — 'inline' (defined directly in the plugin
* config) or 'file' (loaded from users.file, admin.go: adminConsumerUser.
* Source) — via the caller-supplied `sourceValue` lookup (ConsumerDirectory.
* vue joins this row's id against the /admin/api/consumers response, which
* this module has no fetch access to itself). Only ever added when a
* caller passes `sourceValue` to usageColumns (see its own doc comment) —
* a group row (no such concept) and the headless Groups sort toolbar never
* get this column at all. `sourceValue` returning undefined (a group's
* nested member row whose name has no matching /consumers entry yet, e.g.
* mid-fetch) renders an em dash rather than blank, so the column never
* looks like a rendering bug.
*/
function sourceColumn(sourceValue: (entry: AdminUsageEntryView) => string | undefined): ColumnDef<AdminUsageEntryView, unknown> {
return {
id: 'source',
header: 'Source',
accessorFn: (entry) => sourceValue(entry) ?? '',
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, sourceValue(row.original) ?? '—'),
}
}
/**
* lastSeenColumn (Consumers redesign) renders AdminUsageEntryView.lastSeen
* via lib/last-seen.ts's formatLastSeen — added unconditionally (unlike
* sourceColumn above): lastSeen is a fleet-wide counter written for BOTH
* user and group scopes (redesign-plan.md section 1.2's last-seen family:
* "user + every group scope"), so it is meaningful in every context
* usageColumns() is used in, not just the flat Users table. A storeDown
* row is NOT masked as "?" here (unlike every numericColumn) — lastSeen is
* its own separate counter family (a SET, not the same INCRBY scope
* counters storeDown actually guards), so a down usage store does not
* make this value stale/unknown the way it does for req/tok/cost.
*/
function lastSeenColumn(now: Date): ColumnDef<AdminUsageEntryView, unknown> {
return {
id: 'lastSeen',
header: 'Last seen',
accessorFn: (entry) => entry.lastSeen ?? 0,
cell: ({ row }) => formatLastSeen(row.original.lastSeen, now),
}
}
/**
* usageColumns builds the shared TanStack `ColumnDef` set for one
* AdminUsageEntryView table. Used by UsageTable.vue (the flat Users list,
* and — reused unmodified — a group's nested member-user list inside the
* Usage view's Groups accordion) and, headlessly (no `<Table>` render, see
* that view's own doc comment for why), by UsageView.vue's Groups sort
* bar, so both surfaces sort by the exact same column semantics.
* and — reused unmodified — a group's nested member-user list inside
* ConsumerDirectory.vue's Groups accordion) and, headlessly (no `<Table>`
* render, see that component's own doc comment for why), by its Groups
* sort bar, so both surfaces sort by the exact same column semantics.
* idLabel/secondaryColumnLabel name the first two columns; secondaryValue
* reads the second column's per-row value (a user's group name, or a
* group's member count). `now` (F7) defaults to the real current time —
* see costMonthProjColumn's own doc comment for why a caller can override
* it.
* it. `sourceValue`, when supplied, inserts sourceColumn right after the
* secondary column — ConsumerDirectory.vue's flat Users table is the only
* caller that passes it (see sourceColumn's own doc comment).
*/
export function usageColumns(
idLabel: string,
secondaryColumnLabel: string,
secondaryValue: (entry: AdminUsageEntryView) => string,
now: Date = new Date(),
sourceValue?: (entry: AdminUsageEntryView) => string | undefined,
): ColumnDef<AdminUsageEntryView, unknown>[] {
const elapsedFraction = monthProgress(now)
return [
const columns: ColumnDef<AdminUsageEntryView, unknown>[] = [
{
id: 'id',
header: idLabel,
accessorFn: (entry) => entry.id,
// F6: ScopeLink jumps to this row's own Charts scope — "user:{id}"
// or "group:{id}", matching entry.kind exactly (admin.go only ever
// sets kind to "user"/"group"/"total"; a table row is always the
// first two, the "total" entry renders separately in the Total
// card, never as a table row — see UsageView.vue).
cell: ({ row }) =>
h(ScopeLink, { class: 'font-medium', label: row.original.id, scope: `${row.original.kind}:${row.original.id}` }),
// EntityLink (redesign-plan.md section 3.5, replacing ScopeLink):
// this column only ever renders 'user' kind entries in practice —
// UsageTable.vue's own doc comment ("users, or a group's member
// users") — so it links straight to Consumers?user={id}, opening
// that row's own UserDetail, rather than a Charts scope jump.
cell: ({ row }) => h(EntityLink, { class: 'font-medium', label: row.original.id, kind: 'user', id: row.original.id }),
},
{
id: 'secondary',
@@ -189,18 +236,21 @@ export function usageColumns(
accessorFn: secondaryValue,
// 'alphanumeric' (not the default auto-detected sortingFn): this
// column's value is a plain string everywhere EXCEPT the Groups
// toolbar, where UsageView.vue passes memberCountOf — a numeric count
// rendered as a string. TanStack's getAutoSortingFn only inspects
// rows 11+ (flatRows.slice(10)) to decide numeric vs basic, so a
// groups table with 10 or fewer rows silently fell back to `basic`
// (plain a > b string comparison), sorting "10" before "9".
// 'alphanumeric' handles both cases correctly regardless of row
// toolbar, where ConsumerDirectory.vue passes memberCountOf — a
// numeric count rendered as a string. TanStack's getAutoSortingFn
// only inspects rows 11+ (flatRows.slice(10)) to decide numeric vs
// basic, so a groups table with 10 or fewer rows silently fell back
// to `basic` (plain a > b string comparison), sorting "10" before
// "9". 'alphanumeric' handles both cases correctly regardless of row
// count: numeric substrings compare numerically, and it degrades
// gracefully for the plain-text group-name case (secondaryValue on
// the Users table).
sortingFn: 'alphanumeric',
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, secondaryValue(row.original)),
},
]
if (sourceValue) columns.push(sourceColumn(sourceValue))
columns.push(
{
id: 'limits',
header: 'Limits',
@@ -217,5 +267,7 @@ export function usageColumns(
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false, 'costDay'),
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false, 'costMonth'),
costMonthProjColumn(elapsedFraction),
]
lastSeenColumn(now),
)
return columns
}
+19 -81
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { modelDetailCsv, modelRankingCsv, usageCsv } from './usage-csv'
import type { AdminUsageEntryView, AdminUsageModelEntry } from '@/types/api'
import { usageCsv } from './usage-csv'
import type { AdminUsageEntryView } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
return {
@@ -28,7 +28,7 @@ const NOW = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
describe('usageCsv', () => {
const HEADER =
'Name,Group,Limits,req/min,req/day,rejected/day,tokIn/day,tokOut/day,tokIn/month,tokOut/month,cost/day (USD),cost/month (USD),"cost/month (proj., USD)"'
'Name,Group,Limits,req/min,req/day,rejected/day,tokIn/day,tokOut/day,tokIn/month,tokOut/month,cost/day (USD),cost/month (USD),"cost/month (proj., USD)",last seen (unix seconds)'
it('renders the Users header row with "Group" as the secondary column', () => {
const csv = usageCsv([], 'users', () => '', NOW)
@@ -43,21 +43,26 @@ describe('usageCsv', () => {
// P6 review fix: every numeric cell is now a PLAIN machine-readable
// number — no thousands separators (formatExactInt) and no "$" prefix
// (formatCost) — and the trailing cost/month (proj.) column is present.
it('renders one data row per entry, as plain numbers (no locale grouping, no currency symbol), including the projected month-end cost', () => {
const entries = [testEntry({ id: 'alice' }), testEntry({ id: 'bob' })]
it('renders one data row per entry, as plain numbers (no locale grouping, no currency symbol), including the projected month-end cost and last-seen unix seconds', () => {
const entries = [testEntry({ id: 'alice', lastSeen: 1_700_000_000 }), testEntry({ id: 'bob' })]
const csv = usageCsv(entries, 'users', (e) => `group-of-${e.id}`, NOW)
const lines = csv.split('\r\n')
// costPerDayMicroUsd 1_234_500 -> 1.234500 USD; costPerMonthMicroUsd
// 12_345_000 -> 12.345000 USD; projected = 12_345_000 / 0.5 = 24_690_000
// micro-USD -> 24.690000 USD.
expect(lines[1]).toBe('alice,group-of-alice,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000')
expect(lines[2]).toBe('bob,group-of-bob,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000')
// micro-USD -> 24.690000 USD. bob has no lastSeen -> 0.
expect(lines[1]).toBe('alice,group-of-alice,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000,1700000000')
expect(lines[2]).toBe('bob,group-of-bob,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000,0')
})
it('renders "not enough data yet" for the proj. column when too little of the month has elapsed', () => {
const earlyInMonth = new Date(Date.UTC(2026, 3, 1, 0, 5, 0))
const csv = usageCsv([testEntry()], 'users', () => 'g', earlyInMonth)
expect(csv.split('\r\n')[1]).toBe('alice,g,none,1,2,7,3,4,5,6,1.234500,12.345000,not enough data yet')
expect(csv.split('\r\n')[1]).toBe('alice,g,none,1,2,7,3,4,5,6,1.234500,12.345000,not enough data yet,0')
})
it('never masks last-seen as "?" for a storeDown entry — it is its own counter family, independent of the counters storeDown guards', () => {
const csv = usageCsv([testEntry({ storeDown: true, lastSeen: 1_700_000_000 })], 'users', () => 'g', NOW)
expect(csv.split('\r\n')[1]).toBe('alice,g,none,?,?,?,?,?,?,?,?,?,?,1700000000')
})
it('formats Limits via formatLimits, matching the on-screen table', () => {
@@ -68,7 +73,7 @@ describe('usageCsv', () => {
it('masks every counter as "?" for a storeDown entry, never a stale number', () => {
const csv = usageCsv([testEntry({ storeDown: true })], 'users', () => 'g', NOW)
const dataLine = csv.split('\r\n')[1]
expect(dataLine).toBe('alice,g,none,?,?,?,?,?,?,?,?,?,?')
expect(dataLine).toBe('alice,g,none,?,?,?,?,?,?,?,?,?,?,0')
})
it('quotes a field that needs it (e.g. a secondaryValue containing a comma)', () => {
@@ -81,77 +86,10 @@ describe('usageCsv', () => {
// exact projected value depends on the real clock, covered precisely
// by the explicit-`now` tests above.
const csv = usageCsv([testEntry()], 'users', () => 'g')
expect(csv.split('\r\n')[1].split(',')).toHaveLength(13)
expect(csv.split('\r\n')[1].split(',')).toHaveLength(14)
})
})
describe('modelRankingCsv', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'openai/gpt-4', value: 100 },
{ id: 'anthropic/claude', value: 50 },
]
it('renders a header naming the ranked metric, window, and span (quoted — the label itself contains a comma)', () => {
const csv = modelRankingCsv(models, 'Cost', '24h', 24)
expect(csv.split('\r\n')[0]).toBe('Model,"Cost (24h, span 24)"')
})
it('renders one row per model with its raw value when isCostMetric is omitted/false', () => {
const csv = modelRankingCsv(models, 'Requests', '30d', 30)
const lines = csv.split('\r\n')
expect(lines[1]).toBe('openai/gpt-4,100')
expect(lines[2]).toBe('anthropic/claude,50')
})
it('renders just the header for an empty ranking', () => {
expect(modelRankingCsv([], 'Cost', '24h', 24)).toBe('Model,"Cost (24h, span 24)"\r\n')
})
// P6 review fix: a cost-metric ranking's `value` is raw micro-USD with no
// unit stated anywhere — isCostMetric:true states the unit in the header
// and converts each value to a plain decimal USD number.
it('states the USD unit in the header and converts raw micro-USD values to plain decimal USD when isCostMetric is true', () => {
const costModels: AdminUsageModelEntry[] = [{ id: 'openai/gpt-4', value: 1_234_500 }]
const csv = modelRankingCsv(costModels, 'Cost', '24h', 24, true)
const lines = csv.split('\r\n')
expect(lines[0]).toBe('Model,"Cost (USD) (24h, span 24)"')
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')
})
})
// modelRankingCsv/modelDetailCsv (and their own describe blocks here)
// were removed with the functions themselves (P3 item 25, dead code) —
// see usage-csv.ts's own doc comment.
+20 -85
View File
@@ -1,9 +1,5 @@
// F8 (CSV export): builds the actual CSV text for the Usage tab's Users/
// 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
// F8 (CSV export): builds the actual CSV text for ConsumerDirectory.vue's
// Users/Groups exports (usageCsv, CsvExportButton.vue). 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).
@@ -11,9 +7,9 @@ import { toCsv } from '@/lib/csv'
import { monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatLimits } from '@/lib/format'
import { MICROS_PER_USD } from '@/lib/usage-bars'
import type { AdminUsageEntryView, AdminUsageModelEntry } from '@/types/api'
import type { AdminUsageEntryView } from '@/types/api'
/** UsageCsvKind picks the second column's label — "Group" for a Users export (each row's own group membership), "Members" for a Groups export (each row's member count) — mirroring usage-columns.ts's own usageColumns(idLabel, secondaryColumnLabel, ...) call sites in UsageTable.vue/UsageView.vue exactly. */
/** UsageCsvKind picks the second column's label — "Group" for a Users export (each row's own group membership), "Members" for a Groups export (each row's member count) — mirroring usage-columns.ts's own usageColumns(idLabel, secondaryColumnLabel, ...) call sites in UsageTable.vue/ConsumerDirectory.vue exactly. */
export type UsageCsvKind = 'users' | 'groups'
const USAGE_CSV_SECONDARY_LABEL: Record<UsageCsvKind, string> = {
@@ -49,8 +45,9 @@ function csvCostUsd(micros: number): string {
*
* `secondaryValue` is the same per-row accessor usageColumns() itself
* takes (groupNameOf for Users, memberCountOf for Groups, both
* UsageView.vue-local) — this module has no opinion on what a group's
* member count is, only how to render the column once given the value.
* ConsumerDirectory.vue-local) — this module has no opinion on what a
* group's member count is, only how to render the column once given the
* value.
*
* Every numeric cell is a PLAIN machine-readable number (csvCostUsd above
* for the three cost columns, the raw integer — no formatExactInt
@@ -89,6 +86,7 @@ export function usageCsv(
'cost/day (USD)',
'cost/month (USD)',
'cost/month (proj., USD)',
'last seen (unix seconds)',
]
const rows = entries.map((entry) => {
const masked = entry.storeDown
@@ -107,83 +105,20 @@ export function usageCsv(
masked ? '?' : csvCostUsd(entry.costPerDayMicroUsd),
masked ? '?' : csvCostUsd(entry.costPerMonthMicroUsd),
masked ? '?' : projected === null ? 'not enough data yet' : csvCostUsd(projected),
// Never masked — lastSeen is its own SET-based counter family
// (redesign-plan.md section 1.2), independent of the INCRBY usage
// counters `storeDown` actually guards (see usage-columns.ts's
// lastSeenColumn's own doc comment). 0 means never seen, matching
// AdminUsageEntryView.lastSeen's own "0/omitted" convention.
entry.lastSeen ?? 0,
]
})
return toCsv(headers, rows)
}
/**
* modelRankingCsv renders the Models tab's current ranking (WP-B2,
* ChartsView.vue) as CSV text: one row per ranked model, `id` (the
* canonical "provider/model" that served the traffic — AdminUsageModelEntry's
* own doc comment, types/api.ts) and its `value` for the ranked metric.
* `metricLabel`/`windowLabel`/`span` are the caller's own already-resolved
* DISPLAY strings/number (e.g. MODEL_METRIC_LABEL[modelMetric],
* WINDOW_LABEL[window], WINDOW_SPAN[window] — stores/history.ts, WP-B2),
* not re-derived here: this module stays independent of WP-B2's store, and
* the second header column names exactly what was ranked and over what
* span, so the exported file is self-describing without a caller needing
* to cross-reference the filename.
*
* `isCostMetric` (P6 review fix): the cost-metric ranking's `value` is raw
* micro-USD — the on-screen chart/CSV both used to export it as-is, with
* no unit stated anywhere in the header. The caller states whether the
* CURRENTLY ranked metric is cost (`history.modelMetric === 'cost'`); when
* true, the header gains an explicit " (USD)" unit suffix and every value
* converts to a plain decimal USD number via the same csvCostUsd
* convention usageCsv uses above. Every OTHER metric (requests, tokens in/
* out) is already a plain raw integer with no unit ambiguity, so this
* defaults to false and changes nothing for them.
*/
export function modelRankingCsv(
models: AdminUsageModelEntry[],
metricLabel: string,
windowLabel: string,
span: number,
isCostMetric = false,
): string {
const unit = isCostMetric ? ' (USD)' : ''
const headers = ['Model', `${metricLabel}${unit} (${windowLabel}, span ${span})`]
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)
}
// modelRankingCsv/modelDetailCsv were removed (P3 item 25, dead code):
// both were the pre-redesign Models tab's own CSV export (ChartsView.vue,
// stores/history.ts), and neither has a caller anywhere in the
// redesigned shell — grep confirms only this file and its own now-deleted
// spec section ever referenced them. The redesigned Models page
// (ModelCatalogTable.vue) has no CSV export of its own.
+13 -9
View File
@@ -2,11 +2,14 @@ import type { AdminUsageEntryView } from '@/types/api'
/**
* Shared matching helpers for the "Filter users or groups" search
* UsageView.vue and ChartsView.vue both offer (operator directive:
* identical semantics, ONE implementation — no second copy of this logic).
* ConsumerDirectory.vue offers (operator directive: identical semantics,
* ONE implementation — no second copy of this logic; extracted here
* rather than left inline specifically because the pre-redesign Usage
* AND Charts tabs both needed the identical matching rules before this
* module existed).
*
* Every `query` parameter here is expected to already be trimmed and
* lowercased by the caller, mirroring ProvidersView.vue's own
* lowercased by the caller, mirroring ProviderHealthPanel.vue's own
* normalizedQuery convention (modelMatches/aliasMatches there never
* re-normalize either) — these functions stay pure string/array
* operations, with the caller owning the single source of truth for "is a
@@ -32,11 +35,11 @@ export function groupNameMatches(group: Pick<AdminUsageEntryView, 'id'>, query:
* `groupName` alone: a multi-group user would otherwise silently drop out
* of every group but their first. This is a plain client-side join against
* the already-polled GET /admin/api/usage response, no extra fetch.
* UsageView.vue calls this directly (replacing its own former local
* membersOf helper); matchingMembersOfGroup below builds on it too, which
* is how ChartsView.vue's groupMatches call ends up depending on this same
* join — indirectly, through matchingMembersOfGroup — without calling
* membersOfGroup itself.
* ConsumerDirectory.vue calls this directly (replacing the pre-redesign
* Usage tab's own former local membersOf helper); matchingMembersOfGroup
* below builds on it too, which is how ConsumerDirectory.vue's own
* groupMatches call ends up depending on this same join — indirectly,
* through matchingMembersOfGroup — without calling membersOfGroup itself.
*/
export function membersOfGroup(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[]): AdminUsageEntryView[] {
return users.filter((u) => (u.groups ?? [u.groupName]).includes(group.id))
@@ -57,7 +60,8 @@ export function matchingMembersOfGroup(
* need to distinguish WHICH of the two reasons a group matched — to decide
* whether to show all of its members or only the matching ones — use
* groupNameMatches and matchingMembersOfGroup directly instead (see
* UsageView.vue's own doc comment for that distinction).
* ConsumerDirectory.vue's own memberOnlyMatchIds doc comment for that
* distinction).
*/
export function groupMatches(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[], query: string): boolean {
return groupNameMatches(group, query) || matchingMembersOfGroup(group, users, query).length > 0
+287
View File
@@ -0,0 +1,287 @@
import { describe, expect, it } from 'vitest'
import {
emitConfigSnippet,
emitYamlDocument,
emitYamlFlowList,
emitYamlFlowMap,
emitYamlKey,
emitYamlScalar,
toYamlValue,
yamlKeyNeedsQuoting,
} from './yaml-emit'
describe('yamlKeyNeedsQuoting / emitYamlKey', () => {
it.each([
['gx10/current', true],
['a:b', true],
['plain', false],
['eng-team', false],
['friends_2', false],
])('key %s needs quoting: %s', (key, expected) => {
expect(yamlKeyNeedsQuoting(key)).toBe(expected)
})
it('quotes a key containing "/" — modelMeta\'s own routable-id convention', () => {
expect(emitYamlKey('gx10/current')).toBe('"gx10/current"')
})
it('leaves a plain key unquoted', () => {
expect(emitYamlKey('friends')).toBe('friends')
})
})
/**
* P2 item 11 — a table of tricky scalars a naive quoting rule gets wrong,
* checked against BOTH emitYamlKey and emitYamlScalar (one shared rule,
* needsQuoting — the fix instruction's own "apply the same rule to
* keys"). Every "needs quoting: true" row here was independently
* sanity-checked with PyYAML's own safe_load round trip during the
* verify pass this fixes (no network access needed to reason about it:
* PyYAML, like every YAML 1.1-lineage parser, resolves a flow-indicator
* character anywhere in a plain scalar, a leading special/digit
* character, a "key: value"-shaped substring, or a case-insensitive
* boolean/null spelling, the same way regardless of position — this
* table exercises each category at least once).
*/
describe('needsQuoting (P2 item 11): tricky inputs table', () => {
it.each([
// Flow indicators ANYWHERE, not just leading (auth.go:492 path.Match
// globs are legitimate config — `gpt-[45]*` used to break a flow
// list because only a LEADING `[`/`]` was quoted).
['gpt-[45]*', 'glob bracket mid-string', true],
['a]b', 'closing bracket mid-string', true],
['a}b', 'closing brace mid-string', true],
['a,b', 'comma mid-string', true],
['*m', 'leading flow/alias indicator', true],
['{m}', 'leading brace', true],
['- x', 'leading "- " (block sequence indicator)', true],
// Leading/trailing space, and a comment start that used to be
// truncated mid-scalar rather than quoted away entirely.
[' leading-space', 'leading space', true],
['trailing-space ', 'trailing space', true],
['x #y', 'space then "#" (comment start)', true],
// "key: value"-shaped substrings.
['a: b', 'colon-space (nested-mapping shape)', true],
['a:', 'trailing colon', true],
['12:30', 'time-of-day shaped', true],
// YAML 1.1 type-changers: numbers, dates, booleans, null, "infinity".
['0x10', 'hex-looking number', true],
['1e3', 'exponent-looking number', true],
['2026-01-01', 'date-shaped', true],
['.inf', 'YAML 1.1 infinity literal', true],
['~', 'YAML null shorthand', true],
['on', 'YAML 1.1 boolean (lowercase)', true],
['Off', 'YAML 1.1 boolean (mixed case)', true],
['yes', 'YAML 1.1 boolean', true],
['NO', 'YAML 1.1 boolean (uppercase)', true],
['true', 'YAML 1.2 boolean', true],
['null', 'YAML null literal', true],
['y', 'YAML 1.1 single-letter boolean', true],
// Safe: this module's real values (group/provider names, canonical
// model ids) must stay unquoted for readability.
['plain', 'ordinary bare word', false],
['gx10-embed', 'hyphenated name', false],
['friends_2', 'underscored name', false],
['eng.team', 'dotted name', false],
])('%s (%s) needs quoting: %s', (input, _label, expected) => {
expect(yamlKeyNeedsQuoting(input)).toBe(expected)
const scalarRendered = emitYamlScalar(input)
expect(scalarRendered === `"${input}"` || scalarRendered === input).toBe(true)
expect(scalarRendered.startsWith('"')).toBe(expected)
})
it('a group named "on"/"yes"/"true"/"null" (all allowed by GrantForm/LimitsForm\'s NAME_PATTERN) renders as a quoted, still-a-string YAML key', () => {
for (const name of ['on', 'yes', 'true', 'null']) {
expect(emitYamlKey(name)).toBe(JSON.stringify(name))
}
})
})
describe('emitYamlScalar', () => {
it.each([
[null, 'null'],
[true, 'true'],
[false, 'false'],
[0, '0'],
[5, '5'],
[0.5, '0.5'],
['anthropic', 'anthropic'],
])('renders %p as %s', (value, expected) => {
expect(emitYamlScalar(value as never)).toBe(expected)
})
it('quotes an empty string', () => {
expect(emitYamlScalar('')).toBe('""')
})
it('quotes a string that would otherwise parse as a boolean literal', () => {
expect(emitYamlScalar('true')).toBe('"true"')
})
it('quotes a string that would otherwise parse as a number', () => {
expect(emitYamlScalar('123')).toBe('"123"')
})
it('quotes a string containing ": " (would misparse as a nested mapping)', () => {
expect(emitYamlScalar('a: b')).toBe('"a: b"')
})
// N6 (verify-redesign-final.md): JSON.stringify leaves these four code
// points as literal bytes/code units — valid JSON, but a YAML 1.1
// scanner (PyYAML, go-yaml v3) treats U+0085/U+2028/U+2029 as line
// breaks (folded into whitespace, or a scanner error on a key) and
// rejects U+007F outright as non-printable. Each must come back
// escaped, never as a raw character.
it.each([
['NEL (U+0085)', '\u0085', '\\x85'],
['a C1 control (U+009F)', '\u009f', '\\x9f'],
['LINE SEPARATOR (U+2028)', '\u2028', '\\u2028'],
['PARAGRAPH SEPARATOR (U+2029)', '\u2029', '\\u2029'],
['DEL (U+007F)', '\u007f', '\\x7f'],
])('escapes %s inside a quoted scalar', (_label, char, escape) => {
const rendered = emitYamlScalar(`a${char}b`)
expect(rendered).toBe(`"a${escape}b"`)
expect(rendered).not.toContain(char)
})
it('escapes the same control characters inside a quoted key', () => {
const rendered = emitYamlKey('a\u0085b')
expect(rendered).toBe('"a\\x85b"')
expect(rendered).not.toContain('\u0085')
})
it('round-trips a string that is ONLY the unsafe character, still quoted and escaped', () => {
expect(emitYamlScalar('\u2028')).toBe('"\\u2028"')
})
})
describe('emitYamlFlowList', () => {
it('renders an empty list as "[]" — the production file\'s own "allow everything" convention', () => {
expect(emitYamlFlowList([])).toBe('[]')
})
it('renders a short string list inline, matching middleware.yaml\'s alibaba/xiaomi provider entries', () => {
expect(emitYamlFlowList(['qwen3.8-max', 'qwen3.8-flash'])).toBe('[qwen3.8-max, qwen3.8-flash]')
})
it('renders numbers unquoted inside a flow list', () => {
expect(emitYamlFlowList([1, 2, 3])).toBe('[1, 2, 3]')
})
})
describe('emitYamlFlowMap', () => {
it('renders an empty map as "{}"', () => {
expect(emitYamlFlowMap([])).toBe('{}')
})
// Golden strings: middleware.yaml's own modelMeta entries, verbatim.
it('matches "{ free: true }" (middleware.yaml modelMeta, e.g. gx10/current)', () => {
expect(emitYamlFlowMap([['free', true]])).toBe('{ free: true }')
})
it('matches "{ contextTokens: 16384, free: true }" (middleware.yaml, gx10-embed model)', () => {
expect(
emitYamlFlowMap([
['contextTokens', 16384],
['free', true],
]),
).toBe('{ contextTokens: 16384, free: true }')
})
})
describe('emitYamlDocument', () => {
it('renders nested maps at 2-space-per-level indent, matching middleware.yaml exactly', () => {
const doc = emitYamlDocument({
spec: {
plugin: {
llmgateway: {
admin: { enabled: true },
},
},
},
})
expect(doc).toBe(['spec:', ' plugin:', ' llmgateway:', ' admin:', ' enabled: true'].join('\n'))
})
it('drops undefined keys entirely (omitempty parity), never emitting them as null', () => {
const doc = emitYamlDocument({ a: 1, b: undefined, c: 'x' })
expect(doc).toBe('a: 1\nc: x')
})
it('renders an explicitly-empty nested map as "key: {}", not omitted', () => {
const doc = emitYamlDocument({ cache: {} })
expect(doc).toBe('cache: {}')
})
it('renders an empty string-array field as "[]" inline, matching middleware.yaml\'s groups.home block (every field explicit and empty, not a bare "{}")', () => {
const doc = emitYamlDocument({
home: { providers: [], models: [], mcpServers: [], agents: [] },
})
expect(doc).toBe(['home:', ' providers: []', ' models: []', ' mcpServers: []', ' agents: []'].join('\n'))
})
// Defensive-only path: this schema never actually produces an array of
// objects, but a generic render of an unforeseen server shape must not
// throw.
it('falls back to block "-" items with JSON.stringify per element for an array containing a non-primitive (defensive, never hit by this schema)', () => {
const doc = emitYamlDocument({ weird: [{ a: 1 }] })
expect(doc).toBe('weird:\n - {"a":1}')
})
})
describe('emitConfigSnippet: matches middleware.yaml\'s own indent depth exactly', () => {
it('wraps a group\'s access-list body under spec.plugin.llmgateway.groups.<name> (middleware.yaml groups.home shape)', () => {
const snippet = emitConfigSnippet(['groups', 'home'], {
providers: [],
models: [],
mcpServers: [],
agents: [],
})
expect(snippet).toBe(
[
'spec:',
' plugin:',
' llmgateway:',
' groups:',
' home:',
' providers: []',
' models: []',
' mcpServers: []',
' agents: []',
].join('\n'),
)
})
it('wraps a group\'s limits at one further nesting level', () => {
const snippet = emitConfigSnippet(['groups', 'friends'], { limits: { requestsPerDay: 1000 } })
expect(snippet).toBe(
['spec:', ' plugin:', ' llmgateway:', ' groups:', ' friends:', ' limits:', ' requestsPerDay: 1000'].join(
'\n',
),
)
})
it('wraps a top-level, non-nested section (e.g. admin) with no extra name level', () => {
const snippet = emitConfigSnippet(['admin'], { enabled: true })
expect(snippet).toBe(['spec:', ' plugin:', ' llmgateway:', ' admin:', ' enabled: true'].join('\n'))
})
})
describe('toYamlValue', () => {
it('passes primitives through unchanged', () => {
expect(toYamlValue('x')).toBe('x')
expect(toYamlValue(5)).toBe(5)
expect(toYamlValue(true)).toBe(true)
expect(toYamlValue(null)).toBe(null)
})
it('recurses through nested arrays and objects', () => {
expect(toYamlValue({ a: [1, { b: 'c' }] })).toEqual({ a: [1, { b: 'c' }] })
})
it('never throws on an unexpected type, falling back to String()', () => {
const weird = (() => 1) as unknown
expect(toYamlValue(weird)).toBe(String(weird))
})
})
+260
View File
@@ -0,0 +1,260 @@
// Config page (redesign-plan.md section 3.4): a deterministic, dependency-
// free YAML emitter. Two jobs share it:
// 1. ConfigTree.vue renders the FULL redacted config (GET /admin/api/
// config's arbitrary Record<string, unknown>) as read-only YAML —
// emitYamlDocument, via toYamlValue's defensive unknown->YamlValue cast.
// 2. lib/snippets.ts builds small, targeted "paste this into middleware.
// yaml" fragments (emitConfigSnippet) for the ChangeHelper forms —
// GOLDEN-STRING tested against the real production file (home-cluster
// namespaces/traefik/base/llmgateway/middleware.yaml), never guessed.
//
// Kept intentionally narrow: this schema's own config never nests an array
// of objects (every ProviderConfig/GroupConfig/LimitsConfig/RedisConfig/
// RetryConfig/CacheConfig/AdminConfig field that is a list is a list of
// strings — providers/models/mcpServers/agents/passthroughPaths), so this
// module does not attempt general-purpose YAML (anchors, multiline scalars,
// comments) — only what this one config shape, and its own redacted mirror,
// ever produce.
/** YamlPrimitive is one leaf value — a plain scalar, matching every non-container field this config's Go structs carry (strings, numbers, bools, or Go's untyped nil for an omitted pointer). */
export type YamlPrimitive = string | number | boolean | null
/** YamlValue is any value this emitter can render: a primitive, a list of primitives, or a nested map. `undefined` on a map's own value is the one non-YAML sentinel this module recognizes — it drops that key entirely (mirrors Go's `omitempty`), never emitted as `null`. */
export type YamlValue = YamlPrimitive | YamlValue[] | { [key: string]: YamlValue | undefined }
const INDENT_UNIT = ' '
/**
* SAFE_UNQUOTED_PATTERN is the whitelist every plain (unquoted) YAML
* scalar this module emits — key OR value, block OR flow context — must
* satisfy: a leading letter or underscore, then any run of letters,
* digits, `.`, `_`, or `-`. This is deliberately far narrower than what
* YAML 1.2 actually permits unquoted (this module's own top-of-file doc
* comment: "only what this one config shape ... ever produces"), so every
* structurally significant character this schema's real values could
* ever contain — a space, `:`, `,`, `[`, `]`, `{`, `}`, `#`, a quote, a
* leading digit, or a leading special character — falls OUTSIDE it and
* gets quoted, unconditionally, in BOTH the two places this module
* renders a scalar: a block "key: value" line (renderMapLines) and a
* flow list/map element (emitYamlFlowList/emitYamlFlowMap) — the same
* needsQuoting call backs both emitYamlKey and emitYamlScalar below, so
* neither can drift from the other's rule (P2 item 11's own fix
* instruction: "apply the same rule to keys").
*
* `/` is deliberately NOT in the allowed charset either, even though it
* is not YAML-structurally significant on its own: this codebase's own
* pre-existing golden tests (yamlKeyNeedsQuoting('gx10/current') === true,
* checked against the real production file) already establish that a
* routable "provider/model" id is quoted by this GENERAL rule, not by a
* one-off special case — modelMetaSnippet's own "always quoted" note
* (P2 item 11: "Always quote modelMeta keys") is then true by
* CONSTRUCTION for every real model id (they all contain "/"), not a
* separate mechanism fighting this whitelist.
*
* P2 item 11 (PyYAML-checked): a flow-list glob like `gpt-[45]*`
* (path.Match globs are legitimate config, auth.go:492) previously broke
* parsing because `[`/`]` were only checked as the FIRST character, not
* anywhere in the string — the old scalar rule quoted a LEADING `[` but
* not one in the middle. A comma or `}`/`{` anywhere has the identical
* problem inside this module's own flow-style output. This whitelist
* quotes the whole string whenever ANY of those characters appear, at
* any position — always correct, since a flow-context character is
* unsafe everywhere in a flow scalar, and quoting it in a block context
* too costs nothing (`"literal"` is valid wherever `literal` was).
*/
const SAFE_UNQUOTED_PATTERN = /^[A-Za-z_][A-Za-z0-9._-]*$/
/**
* RESERVED_WORDS is every bare word SAFE_UNQUOTED_PATTERN would otherwise
* let through unquoted (it is made entirely of letters, so the pattern's
* charset alone cannot exclude it) but that a YAML parser resolves to a
* non-string type instead of the literal string it looks like. This
* config is consumed as a Kubernetes CRD spec field (YAML -> JSON via the
* API machinery's own converter, which follows YAML 1.1-style scalar
* resolution — the same lineage go.yaml.in/yaml/v3's own decode defaults
* sit in), so the broader YAML 1.1 boolean/null set is included
* defensively (on/off, yes/no, not just YAML 1.2's own true/false/null),
* matched case-insensitively since YAML resolution itself is
* case-insensitive for these ("True", "ON", "Null" all resolve the same
* way a parser's plain-scalar resolver would).
*/
const RESERVED_WORDS = new Set(['true', 'false', 'yes', 'no', 'y', 'n', 'on', 'off', 'null', 'nil', 'nan', 'inf'])
/**
* needsQuoting is the ONE predicate this module uses to decide whether a
* plain scalar is safe to emit unquoted — SAFE_UNQUOTED_PATTERN first (a
* fast, purely structural check that also independently catches every
* number-looking string this schema ever produces: `0x10`, `1e3`,
* `2026-01-01`, `12:30`, `.inf`, and a bare `~` all contain a character
* outside the whitelist, or start with a digit/`.`/`~` rather than a
* letter/underscore, so none of them need their own separate numeric
* check), then RESERVED_WORDS for the purely-alphabetic words the
* pattern alone cannot rule out.
*/
function needsQuoting(s: string): boolean {
if (!SAFE_UNQUOTED_PATTERN.test(s)) return true
return RESERVED_WORDS.has(s.toLowerCase())
}
/** yamlKeyNeedsQuoting reports whether a mapping key needs JSON-style double-quoting to round-trip as the literal string it is — the same needsQuoting rule emitYamlScalar applies to values below (P2 item 11: keys and values share one rule, not two independently-drifting ones). A modelMeta/pricing "provider/model" key always trips this (contains "/"), matching the production file's own convention of quoting every routable id. */
export function yamlKeyNeedsQuoting(key: string): boolean {
return needsQuoting(key)
}
/**
* YAML_UNSAFE_CONTROL_PATTERN matches every character JSON.stringify's own
* quoting leaves as a literal byte/code unit but that a YAML 1.1 scanner
* (this config's consumer: Kubernetes' CRD machinery, go.yaml.in/yaml/v3 —
* needsQuoting's own doc comment) does not accept literally inside a
* double-quoted scalar (N6, verify-redesign-final.md — checked against
* PyYAML and go-yaml v3 across 346 round-trip cases):
* - U+0085 (NEL) and the rest of the C1 control range U+0080-U+009F —
* NEL is itself a YAML 1.1 line-break character, folded into a space
* or rejected outright by a scanner mid-key; the surrounding C1 range
* is non-printable by the same spec.
* - U+2028 (LINE SEPARATOR) / U+2029 (PARAGRAPH SEPARATOR) — YAML's other
* two line-break characters.
* - U+007F (DEL) — non-printable, rejected outright by PyYAML.
* JSON already escapes every C0 control (U+0000-U+001F) and the two
* structural characters ('"', '\\'), so this only needs to add the range
* JSON's own grammar has no reason to touch.
*/
const YAML_UNSAFE_CONTROL_PATTERN = new RegExp(
'[\\u007f\\u0080-\\u009f' + String.fromCharCode(0x2028) + String.fromCharCode(0x2029) + ']',
'g',
)
/**
* escapeYamlUnsafeControls post-processes a JSON.stringify'd, already-
* quoted string, replacing every YAML_UNSAFE_CONTROL_PATTERN character
* with a YAML double-quoted escape a scanner accepts literally: `\xHH`
* (YAML's 2-hex-digit, 8-bit escape) for a single-byte code point
* (U+007F, U+0080-U+009F), `\uHHHH` (4-hex-digit, 16-bit escape) for
* U+2028/U+2029. Safe to run over the whole quoted string, including its
* surrounding quotes and any escape sequences JSON.stringify already
* produced: none of those ever contain a raw byte in this pattern's
* range (JSON's own escapes are all plain ASCII), so this only ever
* touches a literal occurrence of one of the target characters.
*/
function escapeYamlUnsafeControls(quoted: string): string {
return quoted.replace(YAML_UNSAFE_CONTROL_PATTERN, (ch) => {
const code = ch.codePointAt(0) as number
return code <= 0xff ? `\\x${code.toString(16).padStart(2, '0')}` : `\\u${code.toString(16).padStart(4, '0')}`
})
}
/** emitYamlKey renders one mapping key — quoted (JSON-style, valid YAML double-quoting) only when yamlKeyNeedsQuoting says so, plain otherwise. */
export function emitYamlKey(key: string): string {
return yamlKeyNeedsQuoting(key) ? escapeYamlUnsafeControls(JSON.stringify(key)) : key
}
/** emitYamlScalar renders one leaf value in "key: <here>" position OR a flow list/map element — quoted (JSON-style) whenever needsQuoting says the plain form is unsafe, plain otherwise. Booleans/numbers/null render via their own JS literal, never through the string quoting path (a real `true`/`false`/`null`/number is safe unquoted by construction — only a STRING value can accidentally spell one of those). */
export function emitYamlScalar(value: YamlPrimitive): string {
if (value === null) return 'null'
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
return needsQuoting(value) ? escapeYamlUnsafeControls(JSON.stringify(value)) : value
}
/** emitYamlFlowList renders a primitive list as one flow-style line — "[]" empty, "[a, b, c]" otherwise — matching the production file's own short-list convention (middleware.yaml: `providers: []`, `models: ["qwen3.8-max", "qwen3.8-flash"]`). */
export function emitYamlFlowList(values: YamlPrimitive[]): string {
if (values.length === 0) return '[]'
return `[${values.map((v) => emitYamlScalar(v)).join(', ')}]`
}
/** emitYamlFlowMap renders a primitive-valued map as one flow-style line — "{}" empty, "{ k: v, k2: v2 }" otherwise (note the padding spaces inside the braces) — matching modelMeta's own production convention (`{ free: true }`, `{ contextTokens: 16384, free: true }`). */
export function emitYamlFlowMap(entries: [string, YamlPrimitive][]): string {
if (entries.length === 0) return '{}'
return `{ ${entries.map(([k, v]) => `${emitYamlKey(k)}: ${emitYamlScalar(v)}`).join(', ')} }`
}
function isPlainObject(value: YamlValue): value is { [key: string]: YamlValue | undefined } {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isPrimitive(value: YamlValue): value is YamlPrimitive {
return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
}
/**
* renderMapLines recursively renders a map's own entries as already-
* indented lines, at INDENT_UNIT * depth spaces. A nested map with at
* least one (non-undefined) key opens on its own "key:" line and recurses
* one level deeper; an empty nested map renders inline as "key: {}"; a
* primitive-only array renders inline as one flow list. A defensive
* fallback (block "- " items, JSON.stringify per item) covers an array
* containing anything else — this schema never produces one, but a
* generic render of the server's redacted config must never throw on a
* shape this module did not anticipate.
*/
function renderMapLines(map: { [key: string]: YamlValue | undefined }, depth: number): string[] {
const indent = INDENT_UNIT.repeat(depth)
const lines: string[] = []
for (const [key, value] of Object.entries(map)) {
if (value === undefined) continue
const keyPrefix = `${indent}${emitYamlKey(key)}:`
if (isPrimitive(value)) {
lines.push(`${keyPrefix} ${emitYamlScalar(value)}`)
} else if (Array.isArray(value)) {
if (value.every(isPrimitive)) {
lines.push(`${keyPrefix} ${emitYamlFlowList(value as YamlPrimitive[])}`)
} else {
lines.push(keyPrefix)
for (const item of value) {
lines.push(`${indent}${INDENT_UNIT}- ${isPrimitive(item) ? emitYamlScalar(item) : JSON.stringify(item)}`)
}
}
} else if (isPlainObject(value)) {
const nestedKeys = Object.keys(value).filter((k) => value[k] !== undefined)
if (nestedKeys.length === 0) {
lines.push(`${keyPrefix} {}`)
} else {
lines.push(keyPrefix)
lines.push(...renderMapLines(value, depth + 1))
}
}
}
return lines
}
/** emitYamlDocument renders a top-level map as block-style YAML, starting at zero indent — ConfigTree.vue's own top-level call against the whole redacted config. */
export function emitYamlDocument(value: { [key: string]: YamlValue | undefined }, depth = 0): string {
return renderMapLines(value, depth).join('\n')
}
/**
* emitConfigSnippet wraps `body` under the fixed "spec.plugin.llmgateway.
* <...sectionPath>" prefix every ChangeHelper YAML snippet shares
* (redesign-plan.md section 3.4: "emits YAML nested at spec.plugin.
* llmgateway.<section>") — e.g. sectionPath ["groups", "eng"] with body
* {limits: {...}} renders the group's limits block at exactly the depth
* the production file itself uses (spec:0 plugin:1 llmgateway:2 groups:3
* eng:4 limits:5 <fields>:6 — 0/2/4/6/8/10/12 spaces).
*/
export function emitConfigSnippet(sectionPath: string[], body: { [key: string]: YamlValue | undefined }): string {
let wrapped: { [key: string]: YamlValue | undefined } = body
const fullPath = ['spec', 'plugin', 'llmgateway', ...sectionPath]
for (let i = fullPath.length - 1; i >= 0; i--) {
wrapped = { [fullPath[i]]: wrapped }
}
return emitYamlDocument(wrapped)
}
/**
* toYamlValue defensively converts an arbitrary JSON value (GET /admin/api/
* config's already-redacted `config: Record<string, unknown>`) into a
* YamlValue this module can render — recursing through plain objects and
* arrays, passing primitives through unchanged, and falling back to
* `String(input)` for anything else (a function, a Symbol — never actually
* present in a JSON-decoded response, but this must not throw even if the
* server's shape someday surprises it).
*/
export function toYamlValue(input: unknown): YamlValue {
if (input === null || typeof input === 'string' || typeof input === 'number' || typeof input === 'boolean') return input
if (Array.isArray(input)) return input.map(toYamlValue)
if (typeof input === 'object') {
const out: { [key: string]: YamlValue } = {}
for (const [k, v] of Object.entries(input as Record<string, unknown>)) out[k] = toYamlValue(v)
return out
}
return String(input)
}