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
+63 -8
View File
@@ -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<HTMLElement>('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<Record<typeof state.value, string>>(() => ({
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"
>
<span ref="label" class="select-text">{{ id }}</span>
+37 -4
View File
@@ -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<AdminAliasView, unknown>[] = [
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(() =>
</dl>
<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">
<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
(SHOULD-2, v0.22 review round): AdminModelRateView no