mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
feat(admin,webui): metadata exposure
admin.go: adminProviderView gains ModelMeta (map[string]adminModelMetaView,
keyed by upstream model id) and adminAliasView gains its own ModelMeta —
both built via g.registry.resolveMetaFor/resolveMetaForAliasName
(feature v0.23). adminModelMetaView uses pointer fields
(*int/*float64), not plain zero values: a nil pointer means unknown, a
non-nil pointer (even one pointing at 0) means known, so a free model's
real zero cost never gets confused with an absent one over JSON.
resolveMetaForAliasName resolves an alias's metadata for this
unrestricted admin view via an internal &group{} (GroupConfig's own
documented empty-means-all semantics) — buildAdminOverview already
shows every provider/alias unconditionally, with no group filtering
anywhere else in its response either.
webui/src/types/api.ts: AdminModelMetaView mirrors the Go shape
(optional numbers, undefined means unknown); AdminProviderView.modelMeta
and AdminAliasView.modelMeta wire it through.
webui/src/lib/format.ts: formatContextWindow (binary-K "256k"-style),
formatModelCostHover ("in $X / out $Y per MTok", or "free"), and
their shared formatUsdPerMTok building block.
ModelChip.vue (operator scope refinement): every chip — the accordion's
model chips, the alias table's chips, a group's usage-detail chips —
gains optional contextTokens/inputPerMTokUsd/outputPerMTokUsd props.
When known, they append a resolved-metadata phrase to the existing
copy-state hover text, bound to BOTH title and aria-label (the same
mouse-vs-keyboard/screen-reader parity ProviderRateBadge.vue already
applies); when nothing is known, the hover is unchanged from before this
feature. An alias's chip carries its INHERITED metadata, matching the
alias-inheritance rule the backend already applies.
ProvidersView.vue: the accordion's model list gains a visible, muted
context-only chip ("256k") beside each ModelChip — cost stays
hover-only on the chip itself, per the operator's own "chips +
hover would be redundant on price" resolution.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { refreshDetailLabel, refreshLabel } from './format'
|
||||
import { formatCompactCount, formatContextWindow, formatModelCostHover, refreshDetailLabel, refreshLabel } from './format'
|
||||
|
||||
const ZERO_TIME = '0001-01-01T00:00:00Z'
|
||||
|
||||
@@ -49,3 +49,71 @@ describe('refreshDetailLabel', () => {
|
||||
expect(got).not.toBe('discovery off')
|
||||
})
|
||||
})
|
||||
|
||||
// Feature v0.23: per-model metadata exposure (context window, per-token
|
||||
// cost). formatContextWindow is table-driven over good/edge cases;
|
||||
// formatModelCostHover covers the free/priced split ModelChip's
|
||||
// hover-detail relies on.
|
||||
describe('formatContextWindow', () => {
|
||||
it.each([
|
||||
{ tokens: 262144, want: '256k' },
|
||||
{ tokens: 32768, want: '32k' },
|
||||
{ tokens: 1_000_000, want: '977k' },
|
||||
{ tokens: 8192, want: '8k' },
|
||||
{ tokens: 512, want: '512' }, // under 1024: plain digits, no "k"
|
||||
{ tokens: 0, want: '0' },
|
||||
{ tokens: 1024, want: '1k' }, // boundary
|
||||
])('formatContextWindow($tokens) = $want', ({ tokens, want }) => {
|
||||
expect(formatContextWindow(tokens)).toBe(want)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatModelCostHover', () => {
|
||||
it('renders "free" when both sides are exactly 0', () => {
|
||||
expect(formatModelCostHover(0, 0)).toBe('free')
|
||||
})
|
||||
|
||||
it('renders "in $X / out $Y per MTok" for a priced model', () => {
|
||||
expect(formatModelCostHover(1.25, 10)).toBe('in $1.25 / out $10.00 per MTok')
|
||||
})
|
||||
|
||||
it('renders a sub-dollar price with two decimals', () => {
|
||||
expect(formatModelCostHover(0.19, 0.51)).toBe('in $0.19 / out $0.51 per MTok')
|
||||
})
|
||||
})
|
||||
|
||||
// Usage-count compact display (v0.23 addendum): SI-style, 3 significant
|
||||
// figures, floor-not-round (the same honesty rule formatRatePercent
|
||||
// already applies to the success-rate badge — a count must never appear
|
||||
// to have crossed a boundary it has not actually reached).
|
||||
describe('formatCompactCount', () => {
|
||||
it.each([
|
||||
// Below 10,000: exact integer with thousands separators.
|
||||
{ n: 0, want: '0' },
|
||||
{ n: 999, want: '999' },
|
||||
{ n: 1_000, want: '1,000' }, // the 999/1000 boundary
|
||||
{ n: 9_999, want: '9,999' },
|
||||
// k tier.
|
||||
{ n: 10_000, want: '10k' },
|
||||
{ n: 12_345, want: '12.3k' },
|
||||
{ n: 456_789, want: '456k' },
|
||||
{ n: 999_949, want: '999k' }, // floor, not round: stays 999k
|
||||
{ n: 999_950, want: '999k' }, // the classic round-up edge — floor must NOT bump this to 1,000k
|
||||
{ n: 999_999, want: '999k' },
|
||||
// M tier.
|
||||
{ n: 1_000_000, want: '1M' },
|
||||
{ n: 1_234_567, want: '1.23M' },
|
||||
{ n: 45_600_000, want: '45.6M' },
|
||||
{ n: 999_999_999, want: '999M' }, // just under the 1e9 boundary
|
||||
// B tier.
|
||||
{ n: 1_000_000_000, want: '1B' }, // the 1e9 boundary itself
|
||||
{ n: 1_230_000_000, want: '1.23B' },
|
||||
{ n: 12_345_000_000, want: '12.3B' },
|
||||
])('formatCompactCount($n) = $want', ({ n, want }) => {
|
||||
expect(formatCompactCount(n)).toBe(want)
|
||||
})
|
||||
|
||||
it('preserves the sign for a negative count (defensive — counts are never negative in practice)', () => {
|
||||
expect(formatCompactCount(-15_000_000)).toBe('-15M')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user