From eac0862d683bde4cff7a3d2966abe99fc97e5a6a Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 22 Aug 2026 01:36:16 +0100 Subject: [PATCH] feat(admin,webui): metadata exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- admin.go | 69 ++++++++++++++-- admin_modelmeta_test.go | 104 +++++++++++++++++++++++++ webui/src/components/ModelChip.vue | 71 +++++++++++++++-- webui/src/components/ProvidersView.vue | 41 +++++++++- webui/src/lib/format.spec.ts | 70 ++++++++++++++++- webui/src/lib/format.ts | 74 ++++++++++++++++++ webui/src/types/api.ts | 27 +++++++ 7 files changed, 438 insertions(+), 18 deletions(-) create mode 100644 admin_modelmeta_test.go diff --git a/admin.go b/admin.go index 0049d89..a7b5dd7 100644 --- a/admin.go +++ b/admin.go @@ -261,6 +261,16 @@ func sanitizeProviderErr(lastErr, rawBaseURL string) string { // sanitizeBaseURL before this view is ever built. type adminProviderView struct { 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*/ // Failures* counters below, keyed by upstream model id (Feature A, // v0.22) — every entry in Models gets one, even a model with zero @@ -280,8 +290,7 @@ type adminProviderView struct { // Never nil for a real provider — even zero known models marshals as // "[]", not omitted, so the dashboard can tell "no models yet" apart // from a field a stale client build does not know how to read. - Models []string `json:"models"` - ModelCount int `json:"modelCount"` + Models []string `json:"models"` // AttemptsDay/FailuresDay/AttemptsMinute/FailuresMinute are this // provider's current-window upstream-attempt counters (Feature A, // v0.22 — see limiter.recordProviderAttempt, limits.go): every @@ -298,6 +307,7 @@ type adminProviderView struct { FailuresDay int64 `json:"failuresDay"` AttemptsMinute int64 `json:"attemptsMinute"` FailuresMinute int64 `json:"failuresMinute"` + ModelCount int `json:"modelCount"` // DiscoveryEnabled mirrors providerSnapshot.discoveryEnabled (Feature // 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 @@ -322,6 +332,40 @@ type adminModelRateView struct { 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. // LastErrAt's json tag omits "omitempty": encoding/json never treats a // struct value (time.Time) as "empty" regardless of its fields, so the @@ -372,8 +416,16 @@ type adminGroupView struct { // /admin/api/overview (spec §5, v0.2): the alias->target pair exactly as // an operator wrote it. No secrets involved. type adminAliasView struct { - Alias string `json:"alias"` - Target string `json:"target"` + // 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"` + Target string `json:"target"` } // adminOverviewResponse is the full body of GET /admin/api/overview (spec @@ -423,6 +475,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { for i, s := range snaps { pc := providerLevelCounters[i] modelRates := make(map[string]adminModelRateView, len(s.models)) + modelMeta := make(map[string]adminModelMetaView, len(s.models)) for _, model := range s.models { mc := modelCounters[mi] mi++ @@ -430,6 +483,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { AttemptsDay: mc.attemptsDay, FailuresDay: mc.failuresDay, } + modelMeta[model] = buildAdminModelMetaView(g.registry.resolveMetaFor(s.name, model)) } providers[i] = adminProviderView{ Name: s.name, @@ -445,6 +499,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { AttemptsMinute: pc.attemptsMinute, FailuresMinute: pc.failuresMinute, ModelRates: modelRates, + ModelMeta: modelMeta, } } @@ -478,7 +533,11 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { aliasSnaps := g.registry.aliasSnapshot() aliases := make([]adminAliasView, len(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{ diff --git a/admin_modelmeta_test.go b/admin_modelmeta_test.go new file mode 100644 index 0000000..487c01d --- /dev/null +++ b/admin_modelmeta_test.go @@ -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) + } +} diff --git a/webui/src/components/ModelChip.vue b/webui/src/components/ModelChip.vue index cb742f7..5625e78 100644 --- a/webui/src/components/ModelChip.vue +++ b/webui/src/components/ModelChip.vue @@ -4,13 +4,31 @@ import { computed, onUnmounted, ref, useTemplateRef } from 'vue' import { Badge } from '@/components/ui/badge' import { copyText } from '@/lib/clipboard' +import { formatContextWindow, formatModelCostHover } from '@/lib/format' /** - * ModelChip is one clickable, copyable model-id chip in a provider's - * expanded model list (ProvidersView.vue's provider-model accordion). id - * is the full routable id (lib/format.ts's routableModelId) — what - * actually gets copied, so a pasted value works straight into a - * `model: "..."` request field. + * ModelChip is one clickable, copyable model-id chip, used anywhere a + * resolvable model or alias id renders: a provider's expanded model list + * (ProvidersView.vue's accordion), the model-alias table, and a group's + * usage-detail model list (UsageView.vue — passed with no metadata props + * 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 * 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 * 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('label') const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle') @@ -50,13 +73,44 @@ const icon = { selected: faCheck, failed: faTriangleExclamation, } -const title = computed(() => ({ +const baseTitle = computed>(() => ({ idle: `Copy ${props.id}`, copied: 'Copied', selected: 'Selected — press Ctrl/Cmd+C', 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(() => { clearTimeout(resetTimer) }) @@ -69,7 +123,8 @@ onUnmounted(() => { variant="secondary" 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' : ''" - :title="title[state]" + :title="title" + :aria-label="title" @click="onClick" > {{ id }} diff --git a/webui/src/components/ProvidersView.vue b/webui/src/components/ProvidersView.vue index 63dcb0e..cc33dad 100644 --- a/webui/src/components/ProvidersView.vue +++ b/webui/src/components/ProvidersView.vue @@ -21,11 +21,11 @@ import { CardTitle, } from '@/components/ui/card' 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 { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand' 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 // 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) } +/** 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) --- // // The alias id gets the ModelChip copy treatment (an alias name IS the @@ -171,7 +179,13 @@ const aliasColumns: ColumnDef[] = [ id: 'alias', header: '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', @@ -301,7 +315,26 @@ const aliasEmptyMessage = computed(() =>
- + + + + {{ formatContextWindow(modelMetaFor(p, m).contextTokens as number) }} +