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:
2026-08-22 01:36:16 +01:00
parent 0c61296c73
commit eac0862d68
7 changed files with 438 additions and 18 deletions
+61 -2
View File
@@ -261,6 +261,16 @@ func sanitizeProviderErr(lastErr, rawBaseURL string) string {
// sanitizeBaseURL before this view is ever built. // sanitizeBaseURL before this view is ever built.
type adminProviderView struct { type adminProviderView struct {
LastRefresh time.Time `json:"lastRefresh"` LastRefresh time.Time `json:"lastRefresh"`
// ModelMeta is this provider's resolved per-model metadata (feature
// v0.23: context window, per-token cost — resolveModelMeta,
// modelmeta.go), keyed by upstream model id. One entry per id in
// Models, unconditionally — never nil, mirroring ModelRates' own
// "never nil" convention below — so the dashboard's provider
// accordion can render a context/cost chip beside each model without
// a second round trip. An individual entry's own fields are omitted
// (not zeroed) when unknown; see adminModelMetaView's own doc
// comment.
ModelMeta map[string]adminModelMetaView `json:"modelMeta"`
// ModelRates is this provider's per-model breakdown of the Attempts*/ // ModelRates is this provider's per-model breakdown of the Attempts*/
// Failures* counters below, keyed by upstream model id (Feature A, // Failures* counters below, keyed by upstream model id (Feature A,
// v0.22) — every entry in Models gets one, even a model with zero // v0.22) — every entry in Models gets one, even a model with zero
@@ -281,7 +291,6 @@ type adminProviderView struct {
// "[]", not omitted, so the dashboard can tell "no models yet" apart // "[]", not omitted, so the dashboard can tell "no models yet" apart
// from a field a stale client build does not know how to read. // from a field a stale client build does not know how to read.
Models []string `json:"models"` Models []string `json:"models"`
ModelCount int `json:"modelCount"`
// AttemptsDay/FailuresDay/AttemptsMinute/FailuresMinute are this // AttemptsDay/FailuresDay/AttemptsMinute/FailuresMinute are this
// provider's current-window upstream-attempt counters (Feature A, // provider's current-window upstream-attempt counters (Feature A,
// v0.22 — see limiter.recordProviderAttempt, limits.go): every // v0.22 — see limiter.recordProviderAttempt, limits.go): every
@@ -298,6 +307,7 @@ type adminProviderView struct {
FailuresDay int64 `json:"failuresDay"` FailuresDay int64 `json:"failuresDay"`
AttemptsMinute int64 `json:"attemptsMinute"` AttemptsMinute int64 `json:"attemptsMinute"`
FailuresMinute int64 `json:"failuresMinute"` FailuresMinute int64 `json:"failuresMinute"`
ModelCount int `json:"modelCount"`
// DiscoveryEnabled mirrors providerSnapshot.discoveryEnabled (Feature // DiscoveryEnabled mirrors providerSnapshot.discoveryEnabled (Feature
// B, v0.22): the Providers tab uses it to tell a provider whose // B, v0.22): the Providers tab uses it to tell a provider whose
// discovery is off apart from one that is on but has not completed its // discovery is off apart from one that is on but has not completed its
@@ -322,6 +332,40 @@ type adminModelRateView struct {
FailuresDay int64 `json:"failuresDay"` FailuresDay int64 `json:"failuresDay"`
} }
// adminModelMetaView is one upstream model's resolved metadata (feature
// v0.23: context window, per-token cost) within its provider —
// adminProviderView.ModelMeta's value type. Every field is a pointer,
// not a plain int/float64: a plain field's "omitempty" cannot tell a
// genuinely unknown value apart from an explicit zero (a free model's
// cost IS 0, and known), so a nil pointer (omitted from the JSON
// response) means "unknown" and a non-nil pointer — even one pointing at
// 0 — means "known", mirroring resolvedModelMeta's own ContextKnown/
// CostKnown bools (modelmeta.go) one for one.
type adminModelMetaView struct {
ContextTokens *int `json:"contextTokens,omitempty"`
InputPerMTokUSD *float64 `json:"inputPerMTokUsd,omitempty"`
OutputPerMTokUSD *float64 `json:"outputPerMTokUsd,omitempty"`
}
// buildAdminModelMetaView converts resolveModelMeta's result into the
// admin API's pointer-based "known vs. unknown" JSON shape (see
// adminModelMetaView's own doc comment for why plain zero values cannot
// do this).
func buildAdminModelMetaView(meta resolvedModelMeta) adminModelMetaView {
var v adminModelMetaView
if meta.ContextKnown {
ctx := meta.ContextTokens
v.ContextTokens = &ctx
}
if meta.CostKnown {
in := microUSDPerMTokToUSD(meta.InputCostPerMTokMicroUSD)
out := microUSDPerMTokToUSD(meta.OutputCostPerMTokMicroUSD)
v.InputPerMTokUSD = &in
v.OutputPerMTokUSD = &out
}
return v
}
// adminRedisView is the redis status line in GET /admin/api/overview. // adminRedisView is the redis status line in GET /admin/api/overview.
// LastErrAt's json tag omits "omitempty": encoding/json never treats a // LastErrAt's json tag omits "omitempty": encoding/json never treats a
// struct value (time.Time) as "empty" regardless of its fields, so the // struct value (time.Time) as "empty" regardless of its fields, so the
@@ -372,6 +416,14 @@ type adminGroupView struct {
// /admin/api/overview (spec §5, v0.2): the alias->target pair exactly as // /admin/api/overview (spec §5, v0.2): the alias->target pair exactly as
// an operator wrote it. No secrets involved. // an operator wrote it. No secrets involved.
type adminAliasView struct { type adminAliasView struct {
// ModelMeta is this alias's own resolved metadata (feature v0.23,
// hover-detail refinement): the alias's INHERITED metadata from its
// resolved target, unless the alias itself carries its own modelMeta
// override (resolveMetaForAliasName, registry.go — same
// alias-inheritance rule listFor's own alias entries use for GET
// /v1/models). All-unknown (every field omitted) when the alias's
// target does not resolve to any known model yet.
ModelMeta adminModelMetaView `json:"modelMeta"`
Alias string `json:"alias"` Alias string `json:"alias"`
Target string `json:"target"` Target string `json:"target"`
} }
@@ -423,6 +475,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
for i, s := range snaps { for i, s := range snaps {
pc := providerLevelCounters[i] pc := providerLevelCounters[i]
modelRates := make(map[string]adminModelRateView, len(s.models)) modelRates := make(map[string]adminModelRateView, len(s.models))
modelMeta := make(map[string]adminModelMetaView, len(s.models))
for _, model := range s.models { for _, model := range s.models {
mc := modelCounters[mi] mc := modelCounters[mi]
mi++ mi++
@@ -430,6 +483,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
AttemptsDay: mc.attemptsDay, AttemptsDay: mc.attemptsDay,
FailuresDay: mc.failuresDay, FailuresDay: mc.failuresDay,
} }
modelMeta[model] = buildAdminModelMetaView(g.registry.resolveMetaFor(s.name, model))
} }
providers[i] = adminProviderView{ providers[i] = adminProviderView{
Name: s.name, Name: s.name,
@@ -445,6 +499,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
AttemptsMinute: pc.attemptsMinute, AttemptsMinute: pc.attemptsMinute,
FailuresMinute: pc.failuresMinute, FailuresMinute: pc.failuresMinute,
ModelRates: modelRates, ModelRates: modelRates,
ModelMeta: modelMeta,
} }
} }
@@ -478,7 +533,11 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
aliasSnaps := g.registry.aliasSnapshot() aliasSnaps := g.registry.aliasSnapshot()
aliases := make([]adminAliasView, len(aliasSnaps)) aliases := make([]adminAliasView, len(aliasSnaps))
for i, a := range aliasSnaps { for i, a := range aliasSnaps {
aliases[i] = adminAliasView(a) // identical underlying field shape (alias, target string), differing only in json tags aliases[i] = adminAliasView{
Alias: a.Alias,
Target: a.Target,
ModelMeta: buildAdminModelMetaView(g.registry.resolveMetaForAliasName(a.Alias)),
}
} }
return adminOverviewResponse{ return adminOverviewResponse{
+104
View File
@@ -0,0 +1,104 @@
package traefikllmgateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestAdminOverview_ModelMeta_KnownAndUnknownFields proves GET
// /admin/api/overview's per-provider ModelMeta map (feature v0.23)
// carries a resolved entry for every known model, with unknown fields
// omitted (nil pointers) rather than zeroed.
func TestAdminOverview_ModelMeta_KnownAndUnknownFields(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
cfg.ModelMeta = map[string]*ModelMetaConfig{
"alpha/a-model-1": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 1_250_000, OutputCostPerMTokMicroUSD: 10_000_000},
}
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminOverviewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
var alpha *adminProviderView
for i := range got.Providers {
if got.Providers[i].Name == "alpha" {
alpha = &got.Providers[i]
}
}
if alpha == nil {
t.Fatalf("providers missing alpha: %+v", got.Providers)
}
known, ok := alpha.ModelMeta["a-model-1"]
if !ok {
t.Fatalf("alpha.ModelMeta missing a-model-1: %+v", alpha.ModelMeta)
}
if known.ContextTokens == nil || *known.ContextTokens != 128000 {
t.Errorf("a-model-1 ContextTokens = %v, want *128000", known.ContextTokens)
}
if known.InputPerMTokUSD == nil || *known.InputPerMTokUSD != 1.25 {
t.Errorf("a-model-1 InputPerMTokUSD = %v, want *1.25", known.InputPerMTokUSD)
}
if known.OutputPerMTokUSD == nil || *known.OutputPerMTokUSD != 10 {
t.Errorf("a-model-1 OutputPerMTokUSD = %v, want *10", known.OutputPerMTokUSD)
}
unknown, ok := alpha.ModelMeta["a-model-2"]
if !ok {
t.Fatalf("alpha.ModelMeta missing a-model-2 entry entirely (must be present, just all-unknown): %+v", alpha.ModelMeta)
}
if unknown.ContextTokens != nil || unknown.InputPerMTokUSD != nil || unknown.OutputPerMTokUSD != nil {
t.Errorf("a-model-2 (nothing known) = %+v, want every field nil", unknown)
}
}
// TestAdminOverview_AliasModelMeta_Inherited proves GET
// /admin/api/overview's alias rows carry the alias's INHERITED metadata
// from its target (feature v0.23, hover-detail refinement).
func TestAdminOverview_AliasModelMeta_Inherited(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
cfg.ModelAliases = map[string]string{"aliased/coding": "alpha/a-model-1"}
cfg.ModelMeta = map[string]*ModelMetaConfig{
"alpha/a-model-1": {ContextTokens: 200000, Free: true},
}
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminOverviewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
var alias *adminAliasView
for i := range got.Aliases {
if got.Aliases[i].Alias == "aliased/coding" {
alias = &got.Aliases[i]
}
}
if alias == nil {
t.Fatalf("aliases missing aliased/coding: %+v", got.Aliases)
}
if alias.ModelMeta.ContextTokens == nil || *alias.ModelMeta.ContextTokens != 200000 {
t.Errorf("alias ContextTokens = %v, want *200000 (inherited)", alias.ModelMeta.ContextTokens)
}
if alias.ModelMeta.InputPerMTokUSD == nil || *alias.ModelMeta.InputPerMTokUSD != 0 {
t.Errorf("alias InputPerMTokUSD = %v, want *0 (inherited free)", alias.ModelMeta.InputPerMTokUSD)
}
}
+63 -8
View File
@@ -4,13 +4,31 @@ import { computed, onUnmounted, ref, useTemplateRef } from 'vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { copyText } from '@/lib/clipboard' import { copyText } from '@/lib/clipboard'
import { formatContextWindow, formatModelCostHover } from '@/lib/format'
/** /**
* ModelChip is one clickable, copyable model-id chip in a provider's * ModelChip is one clickable, copyable model-id chip, used anywhere a
* expanded model list (ProvidersView.vue's provider-model accordion). id * resolvable model or alias id renders: a provider's expanded model list
* is the full routable id (lib/format.ts's routableModelId) — what * (ProvidersView.vue's accordion), the model-alias table, and a group's
* actually gets copied, so a pasted value works straight into a * usage-detail model list (UsageView.vue — passed with no metadata props
* `model: "..."` request field. * there, since a raw group-config glob entry has no resolvable provider/
* model pair to look metadata up against). id is the full routable id
* (lib/format.ts's routableModelId) or an alias name — what actually
* gets copied, so a pasted value works straight into a `model: "..."`
* request field.
*
* contextTokens/inputPerMTokUsd/outputPerMTokUsd are this model's
* resolved metadata (feature v0.23, hover-detail refinement:
* admin.go's adminModelMetaView / adminAliasView.ModelMeta) — all
* optional and independently omittable, exactly mirroring
* AdminModelMetaView's own "undefined means unknown" convention. When
* known, they render as an appended hover-detail phrase (metaDetail
* below) on top of whichever copy-state message title already carries;
* when nothing is known at all, the hover is unchanged from before this
* feature existed. A caller resolving an ALIAS's chip passes the
* alias's own INHERITED metadata (its target's resolved values, unless
* the alias itself has a modelMeta override) — the same values GET
* /v1/models and the admin API already resolve that way.
* *
* The label span (labelRef below) is copyText's select-text fallback * The label span (labelRef below) is copyText's select-text fallback
* target — not a rare edge case here: navigator.clipboard is undefined * target — not a rare edge case here: navigator.clipboard is undefined
@@ -21,7 +39,12 @@ import { copyText } from '@/lib/clipboard'
* always renders (below), so it is populated by the time onClick can * always renders (below), so it is populated by the time onClick can
* ever run — a click handler cannot fire before mount. * ever run — a click handler cannot fire before mount.
*/ */
const props = defineProps<{ id: string }>() const props = defineProps<{
id: string
contextTokens?: number
inputPerMTokUsd?: number
outputPerMTokUsd?: number
}>()
const labelRef = useTemplateRef<HTMLElement>('label') const labelRef = useTemplateRef<HTMLElement>('label')
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle') const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
@@ -50,13 +73,44 @@ const icon = {
selected: faCheck, selected: faCheck,
failed: faTriangleExclamation, failed: faTriangleExclamation,
} }
const title = computed(() => ({ const baseTitle = computed<Record<typeof state.value, string>>(() => ({
idle: `Copy ${props.id}`, idle: `Copy ${props.id}`,
copied: 'Copied', copied: 'Copied',
selected: 'Selected — press Ctrl/Cmd+C', selected: 'Selected — press Ctrl/Cmd+C',
failed: 'Copy failed', failed: 'Copy failed',
})) }))
/**
* metaDetail is the resolved-metadata hover phrase (feature v0.23):
* cost first ("in $0.19 / out $0.51 per MTok", or "free"), then context
* ("256k context"), joined with " · " — either half is independently
* omitted when its props are undefined, and the whole phrase is empty
* (never appended to title below) when nothing at all is known, per
* this feature's "fully-unknown models get no metadata hover" rule.
*/
const metaDetail = computed(() => {
const parts: string[] = []
if (props.inputPerMTokUsd !== undefined && props.outputPerMTokUsd !== undefined) {
parts.push(formatModelCostHover(props.inputPerMTokUsd, props.outputPerMTokUsd))
}
if (props.contextTokens !== undefined) {
parts.push(`${formatContextWindow(props.contextTokens)} context`)
}
return parts.join(' · ')
})
/**
* title carries BOTH the copy-state message (unchanged behavior) and,
* when known, the resolved-metadata detail appended after " · " — bound
* to both the mouse-only `title` attribute and `aria-label` (same
* mouse-vs-keyboard/screen-reader parity fix ProviderRateBadge.vue's
* own `detail` computed already applies).
*/
const title = computed(() => {
const base = baseTitle.value[state.value]
return metaDetail.value ? `${base} · ${metaDetail.value}` : base
})
onUnmounted(() => { onUnmounted(() => {
clearTimeout(resetTimer) clearTimeout(resetTimer)
}) })
@@ -69,7 +123,8 @@ onUnmounted(() => {
variant="secondary" variant="secondary"
class="cursor-pointer gap-1 font-mono text-xs font-normal transition-colors hover:bg-accent hover:text-accent-foreground" class="cursor-pointer gap-1 font-mono text-xs font-normal transition-colors hover:bg-accent hover:text-accent-foreground"
:class="state === 'failed' ? 'text-destructive' : ''" :class="state === 'failed' ? 'text-destructive' : ''"
:title="title[state]" :title="title"
:aria-label="title"
@click="onClick" @click="onClick"
> >
<span ref="label" class="select-text">{{ id }}</span> <span ref="label" class="select-text">{{ id }}</span>
+37 -4
View File
@@ -21,11 +21,11 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { useSearchQuery } from '@/composables/useSearchQuery' import { useSearchQuery } from '@/composables/useSearchQuery'
import { formatAgo, refreshDetailLabel, refreshLabel, routableModelId } from '@/lib/format' import { formatAgo, formatContextWindow, refreshDetailLabel, refreshLabel, routableModelId } from '@/lib/format'
import { isModelDegraded } from '@/lib/provider-rate' import { isModelDegraded } from '@/lib/provider-rate'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand' import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import type { AdminAliasView, AdminModelRateView, AdminProviderView } from '@/types/api' import type { AdminAliasView, AdminModelMetaView, AdminModelRateView, AdminProviderView } from '@/types/api'
// This component backs the "Providers" tab (App.vue) — a UI-label rename // This component backs the "Providers" tab (App.vue) — a UI-label rename
// only. The data it renders still comes from GET /admin/api/overview // only. The data it renders still comes from GET /admin/api/overview
@@ -161,6 +161,14 @@ function modelIsDegraded(p: AdminProviderView, model: string): boolean {
return isModelDegraded(r.attemptsDay, r.failuresDay) return isModelDegraded(r.attemptsDay, r.failuresDay)
} }
/** ZERO_MODEL_META is modelMetaFor's fallback, mirroring ZERO_MODEL_RATE's own doc comment above — every sub-field undefined reads as "nothing known", not a thrown error, across a version-skewed client/server pair. */
const ZERO_MODEL_META: AdminModelMetaView = {}
/** modelMetaFor looks up one model's resolved metadata within p.modelMeta (feature v0.23), falling back to ZERO_MODEL_META. */
function modelMetaFor(p: AdminProviderView, model: string): AdminModelMetaView {
return p.modelMeta?.[model] ?? ZERO_MODEL_META
}
// --- model aliases (sortable DataTable, operator directive) --- // --- model aliases (sortable DataTable, operator directive) ---
// //
// The alias id gets the ModelChip copy treatment (an alias name IS the // The alias id gets the ModelChip copy treatment (an alias name IS the
@@ -171,7 +179,13 @@ const aliasColumns: ColumnDef<AdminAliasView, unknown>[] = [
id: 'alias', id: 'alias',
header: 'Alias', header: 'Alias',
accessorFn: (a) => a.alias, accessorFn: (a) => a.alias,
cell: ({ row }) => h(ModelChip, { id: row.original.alias }), cell: ({ row }) =>
h(ModelChip, {
id: row.original.alias,
contextTokens: row.original.modelMeta.contextTokens,
inputPerMTokUsd: row.original.modelMeta.inputPerMTokUsd,
outputPerMTokUsd: row.original.modelMeta.outputPerMTokUsd,
}),
}, },
{ {
id: 'target', id: 'target',
@@ -301,7 +315,26 @@ const aliasEmptyMessage = computed(() =>
</dl> </dl>
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5"> <div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5">
<span v-for="m in visibleModels(p)" :key="m" class="inline-flex items-center gap-1"> <span v-for="m in visibleModels(p)" :key="m" class="inline-flex items-center gap-1">
<ModelChip :id="routableModelId(p.name, m)" /> <ModelChip
:id="routableModelId(p.name, m)"
:context-tokens="modelMetaFor(p, m).contextTokens"
:input-per-m-tok-usd="modelMetaFor(p, m).inputPerMTokUsd"
:output-per-m-tok-usd="modelMetaFor(p, m).outputPerMTokUsd"
/>
<!--
Context chip, visible, muted (feature v0.23) — only
when known. Cost stays hover-only on ModelChip itself
(operator directive: chips + hover would otherwise
show the identical price figures twice; context
visible for scanning, price on hover for the exact
numbers).
-->
<span
v-if="modelMetaFor(p, m).contextTokens !== undefined"
class="text-[10px] text-muted-foreground tabular-nums"
>
{{ formatContextWindow(modelMetaFor(p, m).contextTokens as number) }}
</span>
<!-- <!--
attempts-minute/failures-minute deliberately omitted attempts-minute/failures-minute deliberately omitted
(SHOULD-2, v0.22 review round): AdminModelRateView no (SHOULD-2, v0.22 review round): AdminModelRateView no
+69 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' 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' const ZERO_TIME = '0001-01-01T00:00:00Z'
@@ -49,3 +49,71 @@ describe('refreshDetailLabel', () => {
expect(got).not.toBe('discovery off') 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')
})
})
+74
View File
@@ -22,6 +22,80 @@ export function formatCost(micros: number): string {
return `$${(micros / 1_000_000).toFixed(4)}` return `$${(micros / 1_000_000).toFixed(4)}`
} }
/** formatExactInt renders a raw integer count with thousands separators ("1,234,567") — the precise value CompactNumber.vue and the usage chart's tooltip carry alongside a compact rendering, never lost. */
export function formatExactInt(n: number): string {
return n.toLocaleString('en-US')
}
/**
* formatCompactCount renders a raw token/request count as a compact,
* SI-style label: below 10,000 the exact integer with thousands
* separators ("9,999"); at or above that, three significant figures
* with a k/M/B suffix ("12.3k", "456k", "1.23M", "45.6M", "1.23B"),
* trailing zeros trimmed. Every digit beyond the third significant one
* is FLOORED, never rounded — the same honesty rule formatRatePercent
* already applies to the success-rate badge (lib/provider-rate.ts): a
* count of 999,950 must never display as "1,000k", which would imply a
* boundary the raw value has not actually crossed. This is DISPLAY
* only — every caller pairs it with the exact value via a title/
* aria-label (CompactNumber.vue's own doc comment), and no accessor/sort
* key ever calls this; DataTable columns sort on the raw number.
*/
export function formatCompactCount(n: number): string {
const abs = Math.abs(n)
if (abs < 10_000) return formatExactInt(n)
const sign = n < 0 ? '-' : ''
const [divisor, suffix] = abs < 1_000_000 ? [1_000, 'k'] : abs < 1_000_000_000 ? [1_000_000, 'M'] : [1_000_000_000, 'B']
const scaled = abs / divisor
// Three significant figures total: the floored integer part's own
// digit count (1, 2, or 3) decides how many decimal places are left.
const intDigits = Math.floor(scaled) >= 100 ? 3 : Math.floor(scaled) >= 10 ? 2 : 1
const decimals = 3 - intDigits
const factor = 10 ** decimals
const floored = Math.floor(scaled * factor) / factor
let text = floored.toFixed(decimals)
if (decimals > 0) {
text = text.replace(/0+$/, '').replace(/\.$/, '')
}
return `${sign}${text}${suffix}`
}
/**
* formatContextWindow renders a token count as a compact "256k"-style
* label (feature v0.23's context_window/AdminModelMetaView.contextTokens
* unit — plain token count, not bytes) — binary-K (÷1024), matching the
* task brief's own worked example (262144 tokens -> "256k"). Plain
* digits, no suffix, for anything under 1024: a context window that
* small is rare (LM Studio's smallest observed embedding models run
* 512-8192) but must still render as a real number, not "0k".
*/
export function formatContextWindow(tokens: number): string {
if (tokens < 1024) return `${tokens}`
return `${Math.round(tokens / 1024)}k`
}
/** formatUsdPerMTok renders a USD-per-million-tokens float as "$0.19" (2 decimals) — the shared building block for both the visible cost chip and ModelChip's hover detail. */
function formatUsdPerMTok(usd: number): string {
return `$${usd.toFixed(2)}`
}
/**
* formatModelCostHover renders ModelChip's hover-detail cost phrase
* (feature v0.23, hover-detail refinement): "in $0.19 / out $0.51 per
* MTok" for a priced model, or "free" when both sides are exactly 0 (a
* KNOWN zero cost, ModelMetaConfig.Free — distinct from the caller never
* passing a cost at all, which ModelChip's own metaDetail computed
* handles by omitting the phrase entirely rather than calling this).
*/
export function formatModelCostHover(inputUsd: number, outputUsd: number): string {
if (inputUsd === 0 && outputUsd === 0) return 'free'
return `in ${formatUsdPerMTok(inputUsd)} / out ${formatUsdPerMTok(outputUsd)} per MTok`
}
/** formatAgo renders an ISO timestamp as "(Ns ago)", or "" for the unset zero-time sentinel. */ /** formatAgo renders an ISO timestamp as "(Ns ago)", or "" for the unset zero-time sentinel. */
export function formatAgo(iso: string | undefined): string { export function formatAgo(iso: string | undefined): string {
if (!iso || iso === ZERO_TIME) return '' if (!iso || iso === ZERO_TIME) return ''
+27
View File
@@ -27,6 +27,19 @@ export interface AdminModelRateView {
failuresDay: number failuresDay: number
} }
/**
* One upstream model's resolved metadata (admin.go: adminModelMetaView —
* feature v0.23: context window, per-token cost). Every field is
* optional/undefined, not a plain 0 — undefined means "unknown", any
* present value (including 0, a free model's real cost) means "known".
* Never infer "unknown" from a value being falsy; check for undefined.
*/
export interface AdminModelMetaView {
contextTokens?: number
inputPerMTokUsd?: number
outputPerMTokUsd?: number
}
export interface AdminProviderView { export interface AdminProviderView {
name: string name: string
type: string type: string
@@ -36,6 +49,13 @@ export interface AdminProviderView {
/** Sorted explicit∪discovered model ids (admin.go: adminProviderView.Models) — always an array, never omitted, even when empty. */ /** Sorted explicit∪discovered model ids (admin.go: adminProviderView.Models) — always an array, never omitted, even when empty. */
models: string[] models: string[]
modelCount: number modelCount: number
/**
* Resolved metadata (context window, per-token cost — feature v0.23)
* for every id in `models`, keyed by upstream model id. Always
* present (possibly with every sub-field undefined), never omitted —
* mirroring modelRates' own "never nil" convention below.
*/
modelMeta: Record<string, AdminModelMetaView>
/** /**
* discoveryEnabled mirrors the provider's own configured Discovery flag * discoveryEnabled mirrors the provider's own configured Discovery flag
* (admin.go: adminProviderView.DiscoveryEnabled — Feature B, v0.22): the * (admin.go: adminProviderView.DiscoveryEnabled — Feature B, v0.22): the
@@ -85,6 +105,13 @@ export interface AdminGroupView {
export interface AdminAliasView { export interface AdminAliasView {
alias: string alias: string
target: string target: string
/**
* The alias's own resolved metadata (admin.go: adminAliasView.
* ModelMeta — feature v0.23): INHERITED from its target unless the
* alias itself carries its own modelMeta override. Always present
* (possibly with every sub-field undefined).
*/
modelMeta: AdminModelMetaView
} }
/** GET /admin/api/overview (admin.go: adminOverviewResponse). */ /** GET /admin/api/overview (admin.go: adminOverviewResponse). */