mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
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
197 lines
7.7 KiB
TypeScript
197 lines
7.7 KiB
TypeScript
// models.spec.ts covers useModelsStore.fetch's catalog-store integration
|
|
// (useCatalogStore.ensureLoaded, then join via lib/model-table-columns.ts's
|
|
// buildModelCatalogRows) — same mocked-adminFetch, Pinia-under-vitest
|
|
// harness as dashboard.spec.ts/reliability.spec.ts.
|
|
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
vi.mock('@/lib/api', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api')
|
|
return { ...actual, adminFetch: vi.fn() }
|
|
})
|
|
|
|
import { adminFetch } from '@/lib/api'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useCatalogStore } from '@/stores/catalog'
|
|
import { useModelsStore } from '@/stores/models'
|
|
import type { AdminCatalogResponse, AdminPerfResponse, AdminUsageModelsResponse } from '@/types/api'
|
|
|
|
const mockedAdminFetch = vi.mocked(adminFetch)
|
|
|
|
const catalogResponse: AdminCatalogResponse = {
|
|
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, outputPerMTokUsd: 2 }],
|
|
},
|
|
],
|
|
aliases: [],
|
|
}
|
|
|
|
const rankingResponse: AdminUsageModelsResponse = {
|
|
metric: 'cost',
|
|
window: 'day',
|
|
span: 7,
|
|
offset: 0,
|
|
detail: true,
|
|
models: [{ id: 'openai/gpt-5', value: 1, requests: 5, tokensIn: 50, tokensOut: 60, costMicroUsd: 100 }],
|
|
}
|
|
|
|
const perfResponse: AdminPerfResponse = {
|
|
kind: 'model',
|
|
window: 'day',
|
|
span: 7,
|
|
offset: 0,
|
|
latencyEnabled: true,
|
|
rows: [{ id: 'openai/gpt-5', p50Ms: 100, p95Ms: 300, count: 5, attempts: 5, failures: 0 }],
|
|
}
|
|
|
|
const providerPerfResponse: AdminPerfResponse = {
|
|
kind: 'provider',
|
|
window: 'day',
|
|
span: 7,
|
|
offset: 0,
|
|
latencyEnabled: true,
|
|
rows: [{ id: 'openai', p50Ms: 90, p95Ms: 250, count: 20, attempts: 20, failures: 1, timeouts: 0, failovers: 2 }],
|
|
}
|
|
|
|
function mockPerformance(path: string): AdminPerfResponse {
|
|
return path.includes('kind=provider') ? providerPerfResponse : perfResponse
|
|
}
|
|
|
|
describe('useModelsStore.fetch', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
mockedAdminFetch.mockReset()
|
|
useAuthStore().submit('test-admin-key')
|
|
})
|
|
|
|
it('ensures the catalog store is loaded, then joins ranking + performance onto it, plus provider performance', async () => {
|
|
mockedAdminFetch.mockImplementation((async (path: string) => {
|
|
if (path === '/admin/api/catalog') return catalogResponse
|
|
if (path.startsWith('/admin/api/usage/models')) return rankingResponse
|
|
if (path.startsWith('/admin/api/performance')) return mockPerformance(path)
|
|
throw new Error(`unexpected path ${path}`)
|
|
}) as typeof adminFetch)
|
|
|
|
const models = useModelsStore()
|
|
await models.fetch()
|
|
|
|
expect(models.rows).toHaveLength(1)
|
|
expect(models.rows[0]).toMatchObject({ id: 'openai/gpt-5', requests: 5, p50Ms: 100, attempts: 5 })
|
|
expect(models.providerPerf).toEqual(providerPerfResponse.rows)
|
|
expect(models.perfWindow).toEqual({ window: 'day', span: 7 })
|
|
expect(models.latencyEnabled).toBe(true)
|
|
expect(models.lastUpdated).not.toBeNull()
|
|
expect(models.error).toBe('')
|
|
})
|
|
|
|
// P1 item 1: the ranking fetch used to omit `metric` entirely, which
|
|
// admin.go's validHistoryMetric rejects as a 400 "unknown metric" — the
|
|
// previous version of this spec matched adminFetch by path PREFIX only
|
|
// (`path.startsWith('/admin/api/usage/models')`), which is why it never
|
|
// caught a missing/wrong query parameter. This test asserts the exact
|
|
// URL string.
|
|
it('requests the ranking with metric=req (not omitted, not the Spend page\'s own metric)', async () => {
|
|
let rankingUrl = ''
|
|
mockedAdminFetch.mockImplementation((async (path: string) => {
|
|
if (path === '/admin/api/catalog') return catalogResponse
|
|
if (path.startsWith('/admin/api/usage/models')) {
|
|
rankingUrl = path
|
|
return rankingResponse
|
|
}
|
|
if (path.startsWith('/admin/api/performance')) return mockPerformance(path)
|
|
throw new Error(`unexpected path ${path}`)
|
|
}) as typeof adminFetch)
|
|
|
|
const models = useModelsStore()
|
|
await models.fetch()
|
|
|
|
expect(rankingUrl).toBe('/admin/api/usage/models?window=day&span=7&limit=100&detail=1&metric=req')
|
|
})
|
|
|
|
it('reuses an already-loaded, still-fresh catalog store without re-fetching /admin/api/catalog', async () => {
|
|
const catalog = useCatalogStore()
|
|
catalog.data = catalogResponse
|
|
catalog.fetchedAt = new Date()
|
|
|
|
mockedAdminFetch.mockImplementation((async (path: string) => {
|
|
if (path.startsWith('/admin/api/usage/models')) return rankingResponse
|
|
if (path.startsWith('/admin/api/performance')) return perfResponse
|
|
throw new Error(`unexpected path ${path}`)
|
|
}) as typeof adminFetch)
|
|
|
|
const models = useModelsStore()
|
|
await models.fetch()
|
|
|
|
expect(mockedAdminFetch).not.toHaveBeenCalledWith('/admin/api/catalog')
|
|
expect(models.rows).toHaveLength(1)
|
|
})
|
|
|
|
it('records an error and leaves rows empty when the catalog itself fails to load', async () => {
|
|
mockedAdminFetch.mockImplementation((async (path: string) => {
|
|
if (path === '/admin/api/catalog') throw new Error('catalog boom')
|
|
throw new Error(`unexpected path ${path}`)
|
|
}) as typeof adminFetch)
|
|
|
|
const models = useModelsStore()
|
|
await models.fetch()
|
|
|
|
expect(models.rows).toEqual([])
|
|
expect(models.error).toContain('catalog boom')
|
|
})
|
|
|
|
// P2 item 6: fetch() used to have an `if (this.loading) return` guard,
|
|
// which silently dropped a fetch() call started during a range change
|
|
// mid-flight (a reader on Models who changes the range twice quickly
|
|
// saw the SECOND range change simply do nothing). The replacement is
|
|
// latest-request-wins (reqId), matching stores/catalog.ts's own
|
|
// convention — this test exercises the actual property that matters: a
|
|
// SLOWER, earlier fetch() resolving AFTER a faster, later one must
|
|
// never overwrite the later (more current) result with stale data.
|
|
it('discards a slower first fetch() result once a newer fetch() has already resolved (latest-request-wins)', async () => {
|
|
const staleRanking: AdminUsageModelsResponse = {
|
|
...rankingResponse,
|
|
models: [{ ...rankingResponse.models[0], id: 'openai/stale-model' }],
|
|
}
|
|
let resolveFirstRanking: ((v: AdminUsageModelsResponse) => void) | undefined
|
|
let rankingCallCount = 0
|
|
mockedAdminFetch.mockImplementation((async (path: string) => {
|
|
if (path === '/admin/api/catalog') return catalogResponse
|
|
if (path.startsWith('/admin/api/usage/models')) {
|
|
rankingCallCount += 1
|
|
// The FIRST fetch()'s own ranking call hangs until the test
|
|
// resolves it by hand, below — the SECOND (and any later)
|
|
// ranking call resolves immediately, so `second` finishes well
|
|
// before `first` does.
|
|
if (rankingCallCount === 1) return new Promise<AdminUsageModelsResponse>((resolve) => (resolveFirstRanking = resolve))
|
|
return rankingResponse
|
|
}
|
|
if (path.startsWith('/admin/api/performance')) return mockPerformance(path)
|
|
throw new Error(`unexpected path ${path}`)
|
|
}) as typeof adminFetch)
|
|
|
|
const models = useModelsStore()
|
|
const first = models.fetch()
|
|
await vi.waitFor(() => {
|
|
if (!resolveFirstRanking) throw new Error('first ranking request not issued yet')
|
|
})
|
|
const second = models.fetch()
|
|
await second
|
|
expect(models.rows[0]?.id).toBe('openai/gpt-5')
|
|
|
|
// `first` finally resolves, with STALE data — it must be discarded,
|
|
// not overwrite the already-current `second` result.
|
|
resolveFirstRanking?.(staleRanking)
|
|
await first
|
|
expect(models.rows[0]?.id).toBe('openai/gpt-5')
|
|
expect(models.loading).toBe(false)
|
|
})
|
|
})
|