From 7127982aa622e226f90b6847a77f8f747a37aaef Mon Sep 17 00:00:00 2001 From: jsaigou Date: Sun, 6 Sep 2026 13:45:04 +0900 Subject: [PATCH] feat(web): offerings CRUD on the Models page + enable/disable toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models -> Offerings was previously read-only, so there was no way to add an offering or repoint one at a different model/variant without going to Settings -> Catalog -> Offerings. Extract OfferingForm into components/catalog/OfferingForm.tsx (now shared by both surfaces) and give admins full create/edit/delete plus visibility into disabled offerings on the Models page. Also adds a quick per-row Enable/Disable toggle next to Edit/Delete — since the update endpoint is a full replace rather than a merge, the toggle resends the whole record with only `enabled` flipped. No backend changes required. Claude-Session: https://claude.ai/code/session_01Wo5BP532fo77c4NysdguC1 --- web/src/components/CatalogPanel.tsx | 156 +------------ web/src/components/RemoteOfferings.tsx | 237 ++++++++++++++++---- web/src/components/catalog/OfferingForm.tsx | 164 ++++++++++++++ web/src/styles/theme.css | 2 +- 4 files changed, 354 insertions(+), 205 deletions(-) create mode 100644 web/src/components/catalog/OfferingForm.tsx diff --git a/web/src/components/CatalogPanel.tsx b/web/src/components/CatalogPanel.tsx index cbd17ac..2b5a63b 100644 --- a/web/src/components/CatalogPanel.tsx +++ b/web/src/components/CatalogPanel.tsx @@ -14,6 +14,7 @@ import { preferredOfferingIds } from "../lib/offeringPreference"; import { providerIconSlug } from "../lib/providerPresets"; import { useErrorState } from "../lib/useErrorState"; import { subjectLabel } from "./catalog/BenchmarkForm"; +import { OfferingForm, useCatalogProviders } from "./catalog/OfferingForm"; import { ConfirmButton } from "./ConfirmButton"; import { Icon } from "./Icon"; import { IconPicker } from "./IconPicker"; @@ -49,7 +50,6 @@ import { useDeleteCatalogService, useDeleteCatalogVariant, useModelFiles, - useProviders, useUpdateCatalogConfig, useUpdateCatalogFamily, useUpdateCatalogGenealogy, @@ -744,160 +744,6 @@ function OfferingsSection({ canAdmin }: { canAdmin: boolean }) { ); } -// Provider list for the Offering form dropdown + the offerings table. Uses -// the /api/v1/providers endpoint (router_providers table). country / -// data_residency_group are Provider-level facts (ADR-0003), exposed by the -// endpoint since the multi-provider sprint (2026-08-06); enabled drives the -// "provider disabled" styling + the preferred-primary computation. -interface CatalogProviderRef { - name: string; - country: string; - data_residency_group: string; - enabled: boolean; -} - -function useCatalogProviders(): CatalogProviderRef[] { - const providers = useProviders(); - return (providers.data?.providers ?? []).map((p) => ({ - name: p.name, - country: p.country ?? "", - data_residency_group: p.data_residency_group ?? "", - enabled: p.enabled, - })); -} - -function OfferingForm({ - existing, - models, - variants, - providers, - onSubmit, - onCancel, - pending, - isError = false, -}: { - existing?: CatalogOffering; - models: CatalogModel[]; - variants: CatalogVariant[]; - providers: CatalogProviderRef[]; - onSubmit: (draft: Partial, id?: number) => void; - onCancel: () => void; - pending: boolean; - isError?: boolean; -}) { - const [modelId, setModelId] = useState(existing?.model_id ?? 0); - const [variantId, setVariantId] = useState(existing?.variant_id ?? 0); - const [provider, setProvider] = useState(existing?.provider ?? ""); - const [wireModel, setWireModel] = useState(existing?.wire_model ?? ""); - const [priceIn, setPriceIn] = useState(existing?.price_in_per_1m ?? 0); - const [priceOut, setPriceOut] = useState(existing?.price_out_per_1m ?? 0); - const [priceCachedIn, setPriceCachedIn] = useState(existing?.price_cached_in_per_1m ?? null); - const [currency, setCurrency] = useState(existing?.currency ?? "USD"); - const [contextLength, setContextLength] = useState(existing?.context_length ?? 0); - const [enabled, setEnabled] = useState(existing?.enabled ?? true); - const [priority, setPriority] = useState(existing?.priority ?? 100); - - const modelVariants = variants.filter((v) => v.model_id === modelId); - const selectedProvider = providers.find((p) => p.name === provider); - - function submit() { - onSubmit( - { - model_id: modelId, - variant_id: variantId, - provider, - wire_model: wireModel, - price_in_per_1m: priceIn, - price_out_per_1m: priceOut, - price_cached_in_per_1m: priceCachedIn, - currency, - context_length: contextLength, - enabled, - priority, - }, - existing?.id, - ); - } - - return ( -
- - - - - - - - - - - - {selectedProvider?.country && ( -
- {countryFlag(selectedProvider.country)} Data residency: {selectedProvider.country} - {selectedProvider.data_residency_group ? ` · group: ${selectedProvider.data_residency_group}` : ""} -
- )} -
- - -
-
- ); -} - // ── Models ─────────────────────────────────────────────────────────────────── // ── Taxonomy (Sprint I — genealogy/family editing + icon hierarchy) ──────── diff --git a/web/src/components/RemoteOfferings.tsx b/web/src/components/RemoteOfferings.tsx index 99eaad6..4242a11 100644 --- a/web/src/components/RemoteOfferings.tsx +++ b/web/src/components/RemoteOfferings.tsx @@ -1,6 +1,6 @@ -// components/RemoteOfferings.tsx — read-only display of store.Offering rows -// (MODEL CATALOG Phase 2). Lives on the Models page so operators can see -// what remote offerings exist WITHOUT Settings access (Console UX path A). +// components/RemoteOfferings.tsx — display of store.Offering rows (MODEL +// CATALOG Phase 2). Lives on the Models page so operators can see what +// remote offerings exist WITHOUT Settings access (Console UX path A). // // Multi-provider sprint (2026-08-06): offerings of the same catalog model // are ONE entry — the router presents exactly one model per group (the @@ -10,98 +10,216 @@ // up twice (the glm-5.2-on-aiand-and-qwen case). Alternatives collapse // into an expandable detail; the wire names still route via a0 as aliases // converging on the primary. - +// +// Offerings CRUD sprint (2026-09-06): this page was read-only — no way to +// add an offering or change which model/variant one serves — forcing every +// edit through Settings → Catalog. Admins now get full CRUD here, sharing +// the exact form Settings → Catalog → Offerings uses +// (components/catalog/OfferingForm.tsx), plus visibility into disabled +// offerings (otherwise a freshly-created-but-disabled row would vanish +// with no way back). Non-admin viewers still see the original read-only, +// enabled-only view. +import { useState } from "react"; +import { OfferingForm, useCatalogProviders } from "./catalog/OfferingForm"; import { countryFlag } from "../lib/format"; +import { groupOfferingsByModel, preferredOfferingIds } from "../lib/offeringPreference"; import { presetFor, providerIconSlug } from "../lib/providerPresets"; -import { useCatalogModels, useCatalogOfferings, useProviders } from "../lib/queries"; +import { + useCatalogModels, + useCatalogOfferings, + useCatalogVariants, + useCreateCatalogOffering, + useDeleteCatalogOffering, + useProviders, + useUpdateCatalogOffering, +} from "../lib/queries"; +import { useSession } from "../lib/session"; +import { useErrorState } from "../lib/useErrorState"; +import { ConfirmButton } from "./ConfirmButton"; import { Icon } from "./Icon"; import type { CatalogOffering, Provider } from "../lib/types"; export function RemoteOfferings() { + const { canAdmin } = useSession(); const offerings = useCatalogOfferings(); const models = useCatalogModels(); + const variants = useCatalogVariants(); const providers = useProviders(); + const formProviders = useCatalogProviders(); + const create = useCreateCatalogOffering(); + const update = useUpdateCatalogOffering(); + const remove = useDeleteCatalogOffering(); + + const [editing, setEditing] = useState(null); + const { error, showError, clearError } = useErrorState(); + if (!offerings.data || !models.data) return null; - const enabled = offerings.data.filter((o) => o.enabled); - if (enabled.length === 0) return null; + const all = offerings.data; + const visible = canAdmin ? all : all.filter((o) => o.enabled); + if (!canAdmin && visible.length === 0) return null; + const modelList = models.data; + const variantList = variants.data ?? []; const providerList = providers.data?.providers ?? []; const providerByName = (name: string) => providerList.find((p) => p.name === name); const providerEnabled = (name: string) => providerByName(name)?.enabled ?? true; - // Group by catalog model; within a group, order exactly like the router - // (priority, provider name, id) so the first routable row IS the primary - // a0 presents and serves. - const groups = new Map(); - for (const o of enabled) { - const g = groups.get(o.model_id); - if (g) g.push(o); - else groups.set(o.model_id, [o]); + // Same primary-selection rule as the router (offeringPreference.ts, + // mirrors router.SelectOfferingChain) — shared so this badge can never + // drift from Settings → Routing's, and a disabled row can never win it. + const preferredIds = preferredOfferingIds(all, providerEnabled); + const groups = [...groupOfferingsByModel(visible).values()].map((group) => { + const routable = group.filter((o) => o.enabled && providerEnabled(o.provider)); + const anchor = routable[0] ?? group[0]; + return [anchor, ...group.filter((o) => o.id !== anchor.id)]; + }); + + const editingOffering = editing === "new" ? null : all.find((o) => o.id === editing); + + function handleSubmit(draft: Partial, id?: number) { + clearError(); + // Delayed close so SaveButton's success flash has time to paint. + if (id) { + update.mutate({ id, o: draft }, { + onSuccess: () => setTimeout(() => setEditing(null), 700), + onError: showError, + }); + } else { + create.mutate(draft, { + onSuccess: () => setTimeout(() => setEditing(null), 700), + onError: showError, + }); + } + } + + function handleDelete(id: number) { + remove.mutate(id, { onError: showError }); + } + + // UpdateOffering is a full replace at the store layer, not a merge — the + // handler writes every column straight from the request body, so a quick + // toggle must resend the whole record (mirrors OfferingForm's submit()) + // rather than a bare {enabled} patch, or it would silently blank the rest + // of the row (the exact "Full-replace curl verification hazard" from the + // 2026-08-05 incident, but reachable from this button too if skipped). + function handleToggleEnabled(o: CatalogOffering) { + clearError(); + update.mutate({ + id: o.id, + o: { + model_id: o.model_id, + variant_id: o.variant_id, + provider: o.provider, + wire_model: o.wire_model, + price_in_per_1m: o.price_in_per_1m, + price_out_per_1m: o.price_out_per_1m, + price_cached_in_per_1m: o.price_cached_in_per_1m, + currency: o.currency, + context_length: o.context_length, + enabled: !o.enabled, + priority: o.priority, + }, + }, { onError: showError }); } - const orderedGroups = [...groups.values()].map((g) => - [...g].sort((a, b) => a.priority - b.priority || a.provider.localeCompare(b.provider) || a.id - b.id), - ); return (

Remote offerings

-
- {orderedGroups.map((group) => { - const routable = group.filter((o) => providerEnabled(o.provider)); - const primary = routable[0] ?? group[0]; - const alternatives = group.filter((o) => o.id !== primary.id); - const m = models.data!.find((m) => m.id === primary.model_id); - return ( - - ); - })} -
+ {error &&
{error}
} + {editing !== null ? ( + { setEditing(null); clearError(); }} + pending={create.isPending || update.isPending} + isError={!!error} + /> + ) : ( + <> +
+ {groups.map((group) => { + const m = modelList.find((m) => m.id === group[0].model_id); + return ( + { setEditing(id); clearError(); }} + onDelete={handleDelete} + onToggleEnabled={handleToggleEnabled} + /> + ); + })} +
+ {visible.length === 0 &&
No offerings. Create one to route to a remote provider.
} + {canAdmin && ( + + )} + + )}
); } function RemoteModelRow({ - primary, - alternatives, + group, + preferredIds, modelName, modelLogo, modelLogoDark, providerByName, + canAdmin, + deletePending, + togglePending, + onEdit, + onDelete, + onToggleEnabled, }: { - primary: CatalogOffering; - alternatives: CatalogOffering[]; + group: CatalogOffering[]; + preferredIds: Set; modelName: string | null; modelLogo: string; modelLogoDark: string; providerByName: (name: string) => Provider | undefined; + canAdmin: boolean; + deletePending: boolean; + togglePending: boolean; + onEdit: (id: number) => void; + onDelete: (id: number) => void; + onToggleEnabled: (o: CatalogOffering) => void; }) { // All providers render (no "+N others" collapse — operator feedback - // 2026-08-14). Router order is preserved by the parent's sort; the primary - // (the offering a0 actually serves) is marked "preferred". - const group = [primary, ...alternatives]; + // 2026-08-14). The anchor (the offering a0 actually serves, or the + // group's own head when nothing is routable) leads; the rest follow in + // router-priority order. return (
{(modelLogo || modelName) && (
{modelLogo ? : } - {modelName ?? primary.wire_model} + {modelName ?? group[0].wire_model}
)} {group.map((o) => { const row = providerByName(o.provider); - const disabled = row !== undefined && !row.enabled; - const preferred = o.id === primary.id; + const providerDisabled = row !== undefined && !row.enabled; + const preferred = preferredIds.has(o.id); return ( -
+
{/* The model name lives in the group head — repeating it on @@ -117,7 +235,8 @@ function RemoteModelRow({ preferred )} - {disabled && disabled — not routing} + {!o.enabled && disabled} + {o.enabled && providerDisabled && disabled — not routing}
{o.price_in_per_1m} {o.currency}/M in · {o.price_out_per_1m} out @@ -125,6 +244,26 @@ function RemoteModelRow({ priority {o.priority}
+ {canAdmin && ( +
+ + + onDelete(o.id)} + warning={`Delete offering "${o.wire_model}"?`} + /> +
+ )}
); })} diff --git a/web/src/components/catalog/OfferingForm.tsx b/web/src/components/catalog/OfferingForm.tsx new file mode 100644 index 0000000..57f7976 --- /dev/null +++ b/web/src/components/catalog/OfferingForm.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { countryFlag } from "../../lib/format"; +import { useProviders } from "../../lib/queries"; +import type { CatalogModel, CatalogOffering, CatalogVariant } from "../../lib/types"; +import { SaveButton } from "../SaveButton"; + +// OfferingForm — moved out of CatalogPanel.tsx (Models-page offerings CRUD +// sprint, 2026-09-06) so RemoteOfferings.tsx (Models → Offerings) can reuse +// the exact same create/edit form Settings → Catalog → Offerings already +// had, instead of duplicating it. Reused verbatim by both call sites. + +// Provider list for the Offering form dropdown + the offerings table. Uses +// the /api/v1/providers endpoint (router_providers table). country / +// data_residency_group are Provider-level facts (ADR-0003), exposed by the +// endpoint since the multi-provider sprint (2026-08-06); enabled drives the +// "provider disabled" styling + the preferred-primary computation. +export interface CatalogProviderRef { + name: string; + country: string; + data_residency_group: string; + enabled: boolean; +} + +export function useCatalogProviders(): CatalogProviderRef[] { + const providers = useProviders(); + return (providers.data?.providers ?? []).map((p) => ({ + name: p.name, + country: p.country ?? "", + data_residency_group: p.data_residency_group ?? "", + enabled: p.enabled, + })); +} + +export function OfferingForm({ + existing, + models, + variants, + providers, + onSubmit, + onCancel, + pending, + isError = false, +}: { + existing?: CatalogOffering; + models: CatalogModel[]; + variants: CatalogVariant[]; + providers: CatalogProviderRef[]; + onSubmit: (draft: Partial, id?: number) => void; + onCancel: () => void; + pending: boolean; + isError?: boolean; +}) { + const [modelId, setModelId] = useState(existing?.model_id ?? 0); + const [variantId, setVariantId] = useState(existing?.variant_id ?? 0); + const [provider, setProvider] = useState(existing?.provider ?? ""); + const [wireModel, setWireModel] = useState(existing?.wire_model ?? ""); + const [priceIn, setPriceIn] = useState(existing?.price_in_per_1m ?? 0); + const [priceOut, setPriceOut] = useState(existing?.price_out_per_1m ?? 0); + const [priceCachedIn, setPriceCachedIn] = useState(existing?.price_cached_in_per_1m ?? null); + const [currency, setCurrency] = useState(existing?.currency ?? "USD"); + const [contextLength, setContextLength] = useState(existing?.context_length ?? 0); + const [enabled, setEnabled] = useState(existing?.enabled ?? true); + const [priority, setPriority] = useState(existing?.priority ?? 100); + + const modelVariants = variants.filter((v) => v.model_id === modelId); + const selectedProvider = providers.find((p) => p.name === provider); + + function submit() { + onSubmit( + { + model_id: modelId, + variant_id: variantId, + provider, + wire_model: wireModel, + price_in_per_1m: priceIn, + price_out_per_1m: priceOut, + price_cached_in_per_1m: priceCachedIn, + currency, + context_length: contextLength, + enabled, + priority, + }, + existing?.id, + ); + } + + return ( +
+ + + + + + + + + + + + {selectedProvider?.country && ( +
+ {countryFlag(selectedProvider.country)} Data residency: {selectedProvider.country} + {selectedProvider.data_residency_group ? ` · group: ${selectedProvider.data_residency_group}` : ""} +
+ )} +
+ + +
+
+ ); +} diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css index 107158e..add779f 100644 --- a/web/src/styles/theme.css +++ b/web/src/styles/theme.css @@ -1208,7 +1208,7 @@ h1,h2,h3 { text-wrap: balance; margin: 0; } .ro-head .icon { width: 20px; height: 20px; border-radius: 5px; } .ro-head .ro-model-name { font-weight: 700; font-size: 13px; } .ro-provider { - display: grid; grid-template-columns: 26px 1fr; gap: 9px; align-items: center; + display: grid; grid-template-columns: 26px 1fr auto; gap: 9px; align-items: center; padding: 6px 9px; background: var(--panel-glass); backdrop-filter: var(--panel-blur);