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