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
+64 -5
View File
@@ -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{