From e569b48a8da0e13af51219efc1f6e1fbc05d5b3e Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Thu, 15 Oct 2026 20:25:32 +0800 Subject: [PATCH 1/3] feat(swiss): browse and deploy from catalog schema v1 and v2 swissd now lists both schemas in one shape (models, each variant at its own pin) and resolves one variant per model request, so the pages read that instead of the v1 index and entry: - catalog, model, deploy and upgrade pages, in swiss and inferences, work for v1 and v2; a version is the chosen variant's, from its history - v1 catalogs are marked "v1 legacy"; the profile forms choose a schema per catalog, unset reading v1 and new rows defaulting to v2 - the republish warning compares only within the plan's own schema - the catalog diff leaves image.digest out, as compose does - supported recipe totals count each variant at its current pin Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKNsbFYDPWXEhNNsECxveo --- web/src/modules/inferences/CatalogPage.tsx | 20 ++- web/src/modules/inferences/ModelPage.tsx | 84 ++++++----- .../modules/inferences/SiteProfilePage.tsx | 7 + .../modules/inferences/catalog-lib.test.ts | 21 +-- web/src/modules/inferences/catalog-lib.ts | 4 +- .../inferences/components/DeploySheet.tsx | 64 +++++--- .../inferences/components/ProfileForm.tsx | 17 ++- .../modules/inferences/dashboard-lib.test.ts | 61 +++----- web/src/modules/inferences/dashboard-lib.ts | 11 +- web/src/modules/inferences/locales/en-US.json | 12 +- web/src/modules/inferences/locales/zh-CN.json | 12 +- .../swiss/components/CatalogChoice.tsx | 21 ++- .../swiss/components/CatalogModelDiff.test.ts | 36 ++--- .../swiss/components/CatalogModelDiff.tsx | 26 ++-- .../modules/swiss/components/ProfileForm.tsx | 16 +- .../modules/swiss/components/VariantCard.tsx | 13 +- web/src/modules/swiss/lib/api.ts | 141 +++++++++++------- web/src/modules/swiss/lib/catalog.ts | 88 +++++++---- web/src/modules/swiss/routes/Catalog.tsx | 54 ++++--- web/src/modules/swiss/routes/Deploy.tsx | 89 ++++++++--- web/src/modules/swiss/routes/Model.tsx | 108 ++++---------- web/src/modules/swiss/routes/Upgrade.test.tsx | 55 ++++--- web/src/modules/swiss/routes/Upgrade.tsx | 128 +++++++++------- web/src/shell/pages/Home.health.test.tsx | 2 +- web/src/shell/pages/Home.test.tsx | 51 ++----- 25 files changed, 645 insertions(+), 496 deletions(-) diff --git a/web/src/modules/inferences/CatalogPage.tsx b/web/src/modules/inferences/CatalogPage.tsx index bdefa40..66ff695 100644 --- a/web/src/modules/inferences/CatalogPage.tsx +++ b/web/src/modules/inferences/CatalogPage.tsx @@ -24,7 +24,7 @@ import { } from "@modelsphere/ui"; import { Boxes, Library, MoreHorizontal, TriangleAlert } from "lucide-react"; import { useModulePath } from "@/shell"; -import { api } from "@swiss/lib/api"; +import { api, type CatalogInfo } from "@swiss/lib/api"; import { facetsOf, formatUplift, sorters, type Facets } from "@swiss/lib/catalog"; import { useCatalogChoice } from "@swiss/components/CatalogChoice"; import { useT } from "@/modules/inferences/i18n"; @@ -41,7 +41,7 @@ export function CatalogPage() { queryFn: () => api.catalog(choice.selected), enabled: !!choice.selected, }); - const all = useMemo(() => catalog.data?.index.models.map(facetsOf) ?? [], [catalog.data]); + const all = useMemo(() => catalog.data?.models.map(facetsOf) ?? [], [catalog.data]); const options = useMemo(() => facetOptions(all), [all]); const f = filtersFrom(params); const set = (patch: Partial) => setParams((prev) => withFilters(prev, patch), { replace: true }); @@ -70,11 +70,16 @@ export function CatalogPage() { {choice.several && ( ({ value: c.name, label: c.name }))} + options={choice.catalogs.map((c) => ({ value: c.name, label: c.name, description: c.schema === "v1" ? t("catalog.legacy") : undefined }))} value={choice.selected} onValueChange={(v) => v && choice.choose(v)} /> )} + {catalog.data?.schema === "v1" && ( + + {t("catalog.legacy")} + + )} {facet("family")} {facet("engine")} {facet("hardware")} @@ -181,7 +186,7 @@ function ModelCard({ x, catalog, to, onDeploy }: { x: Facets; catalog: string; t {m.source.hf} - v{m.latest} + {x.version && v{x.version}} {t("catalog.variants", { n: x.variants.length })} @@ -207,7 +212,7 @@ function ModelCard({ x, catalog, to, onDeploy }: { x: Facets; catalog: string; t ); } -function NoCatalog({ catalogs, choose }: { catalogs: { name: string; source: string; default?: boolean }[]; choose: (name: string) => void }) { +function NoCatalog({ catalogs, choose }: { catalogs: CatalogInfo[]; choose: (name: string) => void }) { const t = useT(); const p = useModulePath(); if (catalogs.length === 0) { @@ -231,6 +236,11 @@ function NoCatalog({ catalogs, choose }: { catalogs: { name: string; source: str ))} diff --git a/web/src/modules/inferences/ModelPage.tsx b/web/src/modules/inferences/ModelPage.tsx index 40adeb4..8856dfc 100644 --- a/web/src/modules/inferences/ModelPage.tsx +++ b/web/src/modules/inferences/ModelPage.tsx @@ -6,7 +6,6 @@ import { AlertDescription, Badge, Button, - DataSelect, DetailHeader, Empty, EmptyHeader, @@ -21,8 +20,8 @@ import { } from "@modelsphere/ui"; import { TriangleAlert } from "lucide-react"; import { useModulePath } from "@/shell"; -import { api, type Node, type Variant } from "@swiss/lib/api"; -import { comparison, formatUplift, httpLink, reportLink, variantKind, workloadSummary, type Kind } from "@swiss/lib/catalog"; +import { api, type ModelVariant, type Node } from "@swiss/lib/api"; +import { comparison, formatUplift, httpLink, reportLink, sharedVersion, variantKind, workloadSummary, type Kind } from "@swiss/lib/catalog"; import { gpuModels, vendorLabel } from "@swiss/lib/gpu"; import { useCatalogChoice } from "@swiss/components/CatalogChoice"; import { useT } from "@/modules/inferences/i18n"; @@ -35,19 +34,19 @@ export function ModelPage() { const navigate = useNavigate(); const { name = "" } = useParams(); const [params, setParams] = useSearchParams(); - const version = params.get("version") ?? undefined; const tab = params.get("tab") === "info" ? "info" : "variants"; const choice = useCatalogChoice(); + // The default variant resolved, for what only its file says: the served name. const model = useQuery({ - queryKey: ["model", name, version, choice.selected], - queryFn: () => api.model(name, version, choice.selected), + queryKey: ["model", choice.selected, name, "", ""], + queryFn: () => api.model(name, { catalog: choice.selected }), enabled: !!choice.selected, }); const index = useQuery({ queryKey: ["catalog", choice.selected], queryFn: () => api.catalog(choice.selected), enabled: !!choice.selected }); const nodes = useQuery({ queryKey: ["nodes"], queryFn: api.nodes, retry: false }); const cluster = useQuery({ queryKey: ["cluster"], queryFn: api.cluster }); const [deploying, setDeploying] = useState(null); - const onDeploy = cluster.data?.allowDeploy ? (x: { model: string; catalog: string; version?: string; variant?: string }) => setDeploying({ kind: "deploy", ...x }) : undefined; + const onDeploy = cluster.data?.allowDeploy ? (x: { model: string; catalog: string; variant?: string }) => setDeploying({ kind: "deploy", ...x }) : undefined; const setParam = (k: string, v?: string) => setParams((prev) => { @@ -74,43 +73,41 @@ export function ModelPage() { ); } - const e = model.data.entry; - const im = index.data?.index.models.find((m) => m.name === name); - const versions = im?.versions.map((v) => v.version) ?? [e.version]; - const deprecated = im?.deprecated; - const cmp = comparison(e.variants, e.version, im?.tuning); - const site = index.data?.index.site; - const variants = [...e.variants].sort((a, b) => Number(!!b.default) - Number(!!a.default)); + const { model: m, variant: resolved, schema } = model.data; + const deprecated = m.deprecated; + const cmp = comparison(m.variants, m.tuning); + const site = index.data?.site; + const variants = [...m.variants].sort((a, b) => Number(!!b.default) - Number(!!a.default)); + // On v1 every variant is at its entry's version; on v2 each is at its own. + const shared = sharedVersion(variants); + const digest = shared ? variants[0].digest : undefined; return page( setParam("tab", v === "info" ? "info" : undefined)} className="flex-col gap-4"> onDeploy({ model: name, catalog: choice.selected, version }) }] + ? [{ key: "deploy", label: t("model.deploy"), onClick: () => onDeploy({ model: name, catalog: choice.selected }) }] : undefined } meta={[ - { label: t("model.fields.family"), value: e.family || "-" }, - { label: t("model.fields.source"), value: {e.source.hf} }, - { label: t("model.fields.license"), value: e.license || "-" }, + { label: t("model.fields.family"), value: m.family || "-" }, + { label: t("model.fields.source"), value: {m.source.hf} }, + { label: t("model.fields.version"), value: shared ?? t("model.perVariant") }, { - label: t("model.fields.version"), + label: t("model.fields.schema"), value: - versions.length > 1 ? ( - setParam("version", v === im?.latest ? undefined : v)} - options={versions.map((v) => ({ value: v, label: v === im?.latest ? `${v} (${t("model.latest")})` : v }))} - /> + schema === "v1" ? ( + + {t("catalog.legacy")} + ) : ( - e.version + schema ), }, ]} @@ -136,11 +133,12 @@ export function ModelPage() { onDeploy({ model: name, catalog: choice.selected, version, variant: v.id }) : undefined} + onDeploy={onDeploy ? () => onDeploy({ model: name, catalog: choice.selected, variant: v.id }) : undefined} /> ))} @@ -153,13 +151,12 @@ export function ModelPage() { columns={1} labelClassName="w-28 shrink-0" items={[ - { label: t("model.fields.source"), value: [e.source.hf, e.source.revision].filter(Boolean).join(" @ ") }, - { label: t("model.fields.servedName"), value: e.servedName }, - { label: t("model.fields.license"), value: e.license }, - { label: t("model.fields.version"), value: e.version }, - { label: t("model.fields.digest"), value: e.digest && {e.digest} }, - { label: t("model.fields.tags"), value: e.tags?.length ? e.tags.join(", ") : undefined }, - { label: t("model.fields.sizeLabel"), value: e.source.sizeGiB ? t("model.fields.size", { n: e.source.sizeGiB }) : undefined }, + { label: t("model.fields.source"), value: [m.source.hf, m.source.revision].filter(Boolean).join(" @ ") }, + { label: t("model.fields.servedName"), value: resolved.servedName }, + { label: t("model.fields.version"), value: shared }, + { label: t("model.fields.digest"), value: digest && {digest} }, + { label: t("model.fields.tags"), value: m.tags?.length ? m.tags.join(", ") : undefined }, + { label: t("model.fields.sizeLabel"), value: m.source.sizeGiB ? t("model.fields.size", { n: m.source.sizeGiB }) : undefined }, ].filter((i) => i.value)} /> @@ -172,13 +169,16 @@ export function ModelPage() { function VariantCard({ v, kind, + version, uplift, report, nodes, onDeploy, }: { - v: Variant; + v: ModelVariant; kind: Kind; + // The variant's own version, when the model's variants are not all at one. + version?: string; uplift: ReturnType; report?: string; nodes?: Node[]; @@ -193,7 +193,13 @@ function VariantCard({ title={ {v.id} + {version && v{version}} {v.default && {t("model.default")}} + {v.deprecated && ( + + {t("catalog.deprecated")} + + )} {kind.optimized && {t("model.optimized")}} {kind.baseline && {t("model.baseline")}} {v.engine} diff --git a/web/src/modules/inferences/SiteProfilePage.tsx b/web/src/modules/inferences/SiteProfilePage.tsx index 2bb69aa..b18a471 100644 --- a/web/src/modules/inferences/SiteProfilePage.tsx +++ b/web/src/modules/inferences/SiteProfilePage.tsx @@ -99,6 +99,13 @@ function ProfileView({ p }: { p: SiteProfile }) { {c.name} {c.default && {f("catalogDefault")}} + {c.schemaVersion === "v2" ? ( + v2 + ) : ( + + {t("catalog.legacy")} + + )} {c.url} ))} diff --git a/web/src/modules/inferences/catalog-lib.test.ts b/web/src/modules/inferences/catalog-lib.test.ts index 454ae75..83c0814 100644 --- a/web/src/modules/inferences/catalog-lib.test.ts +++ b/web/src/modules/inferences/catalog-lib.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { IndexModel, Node } from "@swiss/lib/api"; +import type { CatalogModel, Node } from "@swiss/lib/api"; import { facetsOf } from "@swiss/lib/catalog"; import { activeFilters, @@ -13,22 +13,15 @@ import { withoutFilters, } from "@/modules/inferences/catalog-lib"; -const model = (over: Partial): IndexModel => ({ +const model = (over: Partial): CatalogModel => ({ name: "qwen3-8b", family: "qwen", tags: ["chat"], source: { hf: "Qwen/Qwen3-8B" }, - latest: "1.0", - versions: [ - { - version: "1.0", - path: "p", - digest: "d", - variants: [ - { id: "h100x1", engine: "sglang", default: true, chart: { name: "sglang", version: "0.7.1" }, requires: { gpus: 1, gpuProduct: ["NVIDIA-H100-80GB-HBM3"] } }, - { id: "v100x2", engine: "vllm", chart: { name: "vllm", version: "0.6.2" }, requires: { gpus: 2 } }, - ], - }, + defaultVariant: "h100x1", + variants: [ + { id: "h100x1", version: "1.0", digest: "d", engine: "sglang", default: true, chart: { name: "sglang", version: "0.7.1" }, requires: { gpus: 1, gpuProduct: ["NVIDIA-H100-80GB-HBM3"] } }, + { id: "v100x2", version: "1.0", digest: "d", engine: "vllm", chart: { name: "vllm", version: "0.6.2" }, requires: { gpus: 2 } }, ], ...over, }); @@ -78,7 +71,7 @@ describe("facetOptions", () => { }); describe("fitness", () => { - const v = model({}).versions[0]!.variants[0]!; + const v = model({}).variants[0]!; it("is unknown without the node list", () => { expect(fitness(v, undefined)).toBeNull(); }); diff --git a/web/src/modules/inferences/catalog-lib.ts b/web/src/modules/inferences/catalog-lib.ts index cd67aba..42ec5ab 100644 --- a/web/src/modules/inferences/catalog-lib.ts +++ b/web/src/modules/inferences/catalog-lib.ts @@ -1,4 +1,4 @@ -import type { IndexVariant, Node } from "@swiss/lib/api"; +import type { ModelVariant, Node } from "@swiss/lib/api"; import type { Facets, SortKey } from "@swiss/lib/catalog"; import { matchesVendor } from "@swiss/lib/gpu"; @@ -92,7 +92,7 @@ export interface Fit { // Whether the cluster has nodes a variant can land on right now: schedulable, // enough GPUs of the right vendor and product, as many as it spans. -export function fitness(v: Pick, nodes?: Node[]): Fit | null { +export function fitness(v: Pick, nodes?: Node[]): Fit | null { if (!nodes) return null; const matching = nodes.filter( (n) => diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index 4d592e5..37ce6b5 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -26,7 +26,7 @@ import { } from "@modelsphere/ui"; import { ArrowRight, TriangleAlert } from "lucide-react"; import { useModulePath } from "@/shell"; -import { api, deployApi, type ApplyResult, type DiffResult, type IndexModel, type Plan } from "@swiss/lib/api"; +import { api, deployApi, type ApplyResult, type CatalogModel, type DiffResult, type Plan } from "@swiss/lib/api"; import { EMPTY, formFromPlan, imageOf, planRequest, type Form } from "@swiss/components/DeploySettings"; import { releaseCatalog } from "@swiss/components/CatalogChoice"; import { Provenance } from "@swiss/components/Provenance"; @@ -81,20 +81,29 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const indexes = useQueries({ queries: catalogs.map((c) => ({ queryKey: ["catalog", c.name], queryFn: () => api.catalog(c.name), enabled: upgrade || c.name === ownCatalog })), }); - const modelIn = (name?: string): IndexModel | undefined => - indexes[catalogs.findIndex((c) => c.name === name)]?.data?.index.models.find((m) => m.name === modelName); + const modelIn = (name?: string): CatalogModel | undefined => + indexes[catalogs.findIndex((c) => c.name === name)]?.data?.models.find((m) => m.name === modelName); const { movable } = upgrade ? movableCatalogs(catalogs, recorded ?? {}, ownCatalog, (c) => modelIn(c)?.source.hf) : { movable: [] as string[] }; const im = modelIn(catalogName); - const versions = im?.versions.map((v) => v.version) ?? []; - const listed = im?.versions.find((v) => v.version === (version || (upgrade ? recorded?.version : undefined) || im.latest))?.variants ?? []; - const keptVariant = variant || (upgrade ? recorded?.variant : listed.find((v) => v.default)?.id ?? listed[0]?.id) || ""; + // The variants as listed now; a version is the chosen variant's, from its + // history: on v2 versions are per variant, on v1 the model's. + const listed = im?.variants ?? []; + const keptVariant = variant || (upgrade ? recorded?.variant : im?.defaultVariant ?? listed[0]?.id) || ""; + const latest = listed.find((x) => x.id === keptVariant)?.version; + const history = useQuery({ + queryKey: ["history", catalogName, modelName, keptVariant], + queryFn: () => api.history(modelName!, { catalog: catalogName, variant: keptVariant }), + enabled: !!modelName && !!catalogName && !!latest, + retry: false, + }); + const versions = history.data?.revisions.map((r) => r.version) ?? (latest ? [latest] : []); const entry = useQuery({ - queryKey: ["model", modelName, version, catalogName], - queryFn: () => api.model(modelName!, version || undefined, catalogName), + queryKey: ["model", catalogName, modelName, keptVariant, version], + queryFn: () => api.model(modelName!, { catalog: catalogName, variant: keptVariant || undefined, version: version || undefined }), enabled: !!modelName && !!catalogName, }); - const v = entry.data?.entry.variants.find((x) => x.id === keptVariant) ?? entry.data?.entry.variants[0]; + const v = entry.data?.variant; const chartRange = !!v && isChartRange(v.chart.version); const chartList = useQuery({ queryKey: ["chart-versions", catalogName, modelName, version, keptVariant], @@ -195,26 +204,17 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( )} {!upgrade && ( - + )}
- - { - setVersion(x === KEEP || (!upgrade && x === im?.latest) ? "" : x); - setVariant(""); - setChartVersion(""); - }} - options={[...(upgrade ? [keepOption(t("deploy.fields.keep", { v: recorded?.version ?? "-" }))] : []), ...versions.map((x) => ({ value: x, label: x === im?.latest ? `${x} (${t("model.latest")})` : x }))]} - /> - { setVariant(x === KEEP ? "" : x); + // On v2 another variant has versions of its own. + setVersion(""); setChartVersion(""); }} options={[ @@ -223,6 +223,28 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( ]} /> + {/* Empty is the variant's latest, on an upgrade as on a deploy. */} + + { + setVersion(x === latest ? "" : x); + setChartVersion(""); + }} + options={versions.map((x) => ({ + value: x, + label: + x === latest + ? `${x} (${t("model.latest")})` + : upgrade && x === recorded?.version && keptVariant === recorded?.variant + ? `${x} (${t("deploy.fields.deployed")})` + : x, + }))} + /> +
{chartRange && ( {(p.catalogs ?? []).map((c, i) => ( -
+
set({ catalogs: p.catalogs!.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })} placeholder={f("catalogName")} aria-label={f("catalogName")} /> set({ catalogs: p.catalogs!.map((x, j) => (j === i ? { ...x, url: e.target.value } : x)) })} placeholder={f("catalogUrl")} aria-label={f("catalogUrl")} className="font-mono text-xs" /> + {/* Unset is v1, so v1 is written as unset. */} + set({ catalogs: p.catalogs!.map((x, j) => (j === i ? { ...x, schemaVersion: v === "v2" ? "v2" : undefined } : x)) })} + options={[ + { value: "v1", label: f("catalogSchemaV1") }, + { value: "v2", label: f("catalogSchemaV2") }, + ]} + />
))} - set({ catalogs: [...(p.catalogs ?? []), { name: "", url: "" }] })} /> + set({ catalogs: [...(p.catalogs ?? []), { name: "", url: "", schemaVersion: "v2" }] })} /> {input(f("chartRepo"), p.chartRepo, (v) => set({ chartRepo: str(v) }), { hint: f("chartRepoHint"), placeholder: "https://modelsphere.github.io/helm-charts", mono: true })} {input(f("chartPath"), p.chartPath, (v) => set({ chartPath: str(v) }), { placeholder: f("chartPathHint"), mono: true })} diff --git a/web/src/modules/inferences/dashboard-lib.test.ts b/web/src/modules/inferences/dashboard-lib.test.ts index b9b9895..aabf526 100644 --- a/web/src/modules/inferences/dashboard-lib.test.ts +++ b/web/src/modules/inferences/dashboard-lib.test.ts @@ -19,54 +19,37 @@ const catalog: CatalogResponse = { name: "default", ref: "main", source: "https://catalog.example", - index: { - apiVersion: "catalog.swiss/v1", - count: 1, - models: [ - { - name: "alpha", - source: { hf: "org/alpha" }, - latest: "2", - versions: [ - { - version: "1", - path: "alpha-1.yaml", - digest: "old", - variants: [{ id: "base", engine: "vllm", chart: { name: "vllm", version: "1" }, requires: { gpus: 1 } }], - }, - { - version: "2", - path: "alpha-2.yaml", - digest: "new", - variants: [ - { id: "base", engine: "vllm", chart: { name: "vllm", version: "2" }, requires: { gpus: 1 } }, - { id: "tuned", engine: "sglang", chart: { name: "sglang", version: "2" }, requires: { gpus: 2 } }, - ], - }, - ], - }, - ], - }, + schema: "v2", + models: [ + { + name: "alpha", + source: { hf: "org/alpha" }, + defaultVariant: "base", + variants: [ + { id: "base", version: "2", digest: "b2", engine: "vllm", chart: { name: "vllm", version: "2" }, requires: { gpus: 1 } }, + { id: "tuned", version: "1", digest: "t1", engine: "sglang", chart: { name: "sglang", version: "2" }, requires: { gpus: 2 } }, + ], + }, + ], }; describe("supported catalog totals", () => { - it("counts every supported version recipe, not a sample or only the latest", () => - expect(supportedTotals([catalog])).toEqual({ models: 1, recipes: 3 })); + it("counts every variant at the pin its catalog lists now", () => + expect(supportedTotals([catalog])).toEqual({ models: 1, recipes: 2 })); it("does not double-count mirrored catalog entries", () => - expect(supportedTotals([catalog, { ...catalog, name: "mirror" }])).toEqual({ models: 1, recipes: 3 })); + expect(supportedTotals([catalog, { ...catalog, name: "mirror" }])).toEqual({ models: 1, recipes: 2 })); it("does not count deprecated models as current support", () => { - const old = { - ...catalog, - index: { ...catalog.index, models: catalog.index.models.map((m) => ({ ...m, deprecated: "retired" })) }, - }; + const old = { ...catalog, models: catalog.models.map((m) => ({ ...m, deprecated: "retired" })) }; expect(supportedTotals([old])).toEqual({ models: 0, recipes: 0 }); }); - it("distinguishes different model sources and recipe revisions", () => { - const other = { + it("distinguishes different model sources and recipe pins", () => { + const other = { ...catalog, models: catalog.models.map((m) => ({ ...m, source: { hf: "other/alpha" } })) }; + const moved = { ...catalog, - index: { ...catalog.index, models: catalog.index.models.map((m) => ({ ...m, source: { hf: "other/alpha" } })) }, + models: catalog.models.map((m) => ({ ...m, variants: m.variants.map((v) => ({ ...v, version: "3", digest: "x" })) })), }; - expect(supportedTotals([catalog, other])).toEqual({ models: 2, recipes: 6 }); + expect(supportedTotals([catalog, other])).toEqual({ models: 2, recipes: 4 }); + expect(supportedTotals([catalog, moved])).toEqual({ models: 1, recipes: 4 }); }); }); diff --git a/web/src/modules/inferences/dashboard-lib.ts b/web/src/modules/inferences/dashboard-lib.ts index 203d531..e777f03 100644 --- a/web/src/modules/inferences/dashboard-lib.ts +++ b/web/src/modules/inferences/dashboard-lib.ts @@ -19,18 +19,19 @@ export interface DeploymentTimeline { complete: boolean; } +// A recipe is a variant at the pin its catalog lists now. Older versions are +// left out: a v2 index lists only current pins, and counting history would +// cost a fetch per variant. export function supportedTotals(catalogs: CatalogResponse[]): { models: number; recipes: number } { const models = new Set(); const recipes = new Set(); for (const catalog of catalogs) { - for (const model of catalog.index.models) { + for (const model of catalog.models) { if (model.deprecated) continue; const identity = [model.name, model.source.hf, model.source.revision ?? ""]; models.add(JSON.stringify(identity)); - for (const version of model.versions) { - for (const variant of version.variants) { - recipes.add(JSON.stringify([...identity, version.version, version.digest, variant.id])); - } + for (const variant of model.variants) { + recipes.add(JSON.stringify([...identity, variant.id, variant.version, variant.digest])); } } } diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index 0bc65d6..1a39c19 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -278,6 +278,8 @@ "noCatalog": "No model catalog is configured.", "noCatalogAction": "Add one in the site profile", "choose": "Several catalogs are configured. Pick one:", + "legacy": "v1 legacy", + "legacyHint": "Catalog schema v1 is the read-only legacy index, and what a catalog with no schemaVersion reads. Set schemaVersion: v2 on it in the site profile.", "default": "Default" }, "model": { @@ -293,12 +295,14 @@ "license": "License", "version": "Version", "digest": "Digest", + "schema": "Schema", "tags": "Tags", "size": "{n} GiB", "sizeLabel": "Size" }, "version": "Version", "latest": "latest", + "perVariant": "per variant", "default": "Default", "optimized": "Tuned", "baseline": "Baseline", @@ -401,6 +405,9 @@ "catalog": "Catalog", "version": "Model version", "variant": "Variant", + "variantVersion": "Variant version", + "versionError": "Could not list versions: {error}", + "deployed": "deployed", "chartVersion": "Chart version", "serviceId": "Service name", "serviceIdHint": "Names the helm release, the route, the scaler and the SLO", @@ -647,11 +654,14 @@ "gpusPerNodeHint": "A full GPU node, used to derive cache.maxSlotsPerNode", "chartDefault": "the chart's default", "catalogs": "Catalogs", - "catalogsHint": "Each an https base or an absolute path, under a name. With several, pages open in the default one. Set here they win over swissd's config file", + "catalogsHint": "Each an https base or an absolute path, under a name. With several, pages open in the default one. Set here they win over swissd's config file. Schema v2 is recommended; v1 is the read-only legacy index, and what a catalog with no schema set reads", "catalogName": "Name", "catalogUrl": "URL", "catalogDefault": "Default", "catalogAdd": "Add catalog", + "catalogSchema": "Schema", + "catalogSchemaV1": "v1 · legacy", + "catalogSchemaV2": "v2 · recommended", "chartRepo": "Chart repo", "chartRepoHint": "An https helm repo or oci:// registry, e.g. https://modelsphere.github.io/helm-charts; empty means a local chart path", "chartPath": "Chart path", diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index cab3e6e..15b7f5b 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -267,6 +267,8 @@ "noCatalog": "还没有配置模型目录。", "noCatalogAction": "去站点配置添加", "choose": "已配置多个目录,请选择一个:", + "legacy": "v1 旧版", + "legacyHint": "目录 schema v1 是只读的旧版索引,未设置 schemaVersion 的目录按 v1 读取。请在站点配置里把它设为 schemaVersion: v2。", "default": "默认" }, "model": { @@ -282,12 +284,14 @@ "license": "许可证", "version": "版本", "digest": "Digest", + "schema": "Schema", "tags": "标签", "size": "{n} GiB", "sizeLabel": "大小" }, "version": "版本", "latest": "最新", + "perVariant": "按变体", "default": "默认", "optimized": "调优", "baseline": "基线", @@ -390,6 +394,9 @@ "catalog": "目录", "version": "模型版本", "variant": "变体", + "variantVersion": "变体版本", + "versionError": "无法列出版本:{error}", + "deployed": "已部署", "chartVersion": "Chart 版本", "serviceId": "服务名", "serviceIdHint": "作为 helm release、路由、伸缩器和 SLO 的名字", @@ -636,11 +643,14 @@ "gpusPerNodeHint": "一个满配 GPU 节点的卡数,用来推算 cache.maxSlotsPerNode", "chartDefault": "chart 默认值", "catalogs": "模型目录", - "catalogsHint": "每项是 https 地址或绝对路径,以名字区分。配置多个时页面默认打开标记为默认的那个;这里的设置优先于 swissd 的配置文件", + "catalogsHint": "每项是 https 地址或绝对路径,以名字区分。配置多个时页面默认打开标记为默认的那个;这里的设置优先于 swissd 的配置文件。推荐 schema v2;v1 是只读的旧版索引,未设置 schema 时按 v1 读取", "catalogName": "名称", "catalogUrl": "地址", "catalogDefault": "默认", "catalogAdd": "添加目录", + "catalogSchema": "Schema", + "catalogSchemaV1": "v1 · 旧版", + "catalogSchemaV2": "v2 · 推荐", "chartRepo": "Chart 仓库", "chartRepoHint": "https 的 helm 仓库或 oci:// 仓库,例如 https://modelsphere.github.io/helm-charts;留空表示使用本地 chart 路径", "chartPath": "Chart 路径", diff --git a/web/src/modules/swiss/components/CatalogChoice.tsx b/web/src/modules/swiss/components/CatalogChoice.tsx index 4c4175e..ec63b17 100644 --- a/web/src/modules/swiss/components/CatalogChoice.tsx +++ b/web/src/modules/swiss/components/CatalogChoice.tsx @@ -1,7 +1,7 @@ import { Link, useSearchParams } from "@swiss/lib/host"; import { useQuery } from "@tanstack/react-query"; import { Library } from "lucide-react"; -import { api, type CatalogInfo } from "@swiss/lib/api"; +import { api, type CatalogInfo, type CatalogSchema } from "@swiss/lib/api"; import { Badge } from "@swiss/components/ui/badge"; import { Empty } from "@swiss/components/States"; @@ -101,6 +101,7 @@ export function CatalogGate({ {c.name} {c.default && default} + {c.url} {!c.ref && could not be read right now} @@ -134,7 +135,9 @@ export function CatalogSwitch({ > {catalogs.map((c) => ( ))} @@ -154,3 +157,17 @@ export function CatalogBadge({ name, show }: { name: string; show: boolean }) { ); } + +// v1 is marked: the legacy schema, read-only. v2 is what a catalog should be +// set to, so it needs no badge. +export function SchemaBadge({ schema }: { schema?: CatalogSchema }) { + if (schema !== "v1") return null; + return ( + + v1 legacy + + ); +} diff --git a/web/src/modules/swiss/components/CatalogModelDiff.test.ts b/web/src/modules/swiss/components/CatalogModelDiff.test.ts index ae7dddb..8f4ca1f 100644 --- a/web/src/modules/swiss/components/CatalogModelDiff.test.ts +++ b/web/src/modules/swiss/components/CatalogModelDiff.test.ts @@ -1,37 +1,27 @@ import { describe, expect, it } from "vitest"; -import type { Entry } from "@swiss/lib/api"; +import type { Variant } from "@swiss/lib/api"; import { catalogValues, lineDiff, modelText } from "./CatalogModelDiff"; -const entry = { - apiVersion: "catalog.swiss/v1", - name: "m", +const variant = { + id: "v", version: "1.0.0", + digest: "sha256:def", servedName: "served", - source: { hf: "org/m" }, - variants: [ - { - id: "v", - engine: "sglang", - chart: { name: "sglang", version: "0.8.0" }, - requires: { gpus: 2 }, - image: { repository: "repo", tag: "t", digest: "sha256:abc" }, - values: { extraArgs: ["--tp-size=2"], model: { name: "from-values" } }, - }, - ], -} as Entry; + engine: "sglang", + chart: { name: "sglang", version: "0.8.0" }, + requires: { gpus: 2 }, + image: { repository: "repo", tag: "t", digest: "sha256:abc" }, + values: { extraArgs: ["--tp-size=2"], model: { name: "from-values" } }, +} as Variant; describe("catalogValues", () => { - it("projects a variant the way the catalog layer does", () => { - expect(catalogValues(entry, "v").tree).toEqual({ + it("projects a variant the way the catalog layer does, image digest left out", () => { + expect(catalogValues(variant).tree).toEqual({ extraArgs: ["--tp-size=2"], model: { name: "served", gpus: "2" }, - image: { repository: "repo", tag: "t", digest: "sha256:abc" }, + image: { repository: "repo", tag: "t" }, }); }); - - it("is empty when the variant is no longer listed", () => { - expect(catalogValues(entry, "missing")).toEqual({}); - }); }); describe("modelText", () => { diff --git a/web/src/modules/swiss/components/CatalogModelDiff.tsx b/web/src/modules/swiss/components/CatalogModelDiff.tsx index a8519df..b8210e4 100644 --- a/web/src/modules/swiss/components/CatalogModelDiff.tsx +++ b/web/src/modules/swiss/components/CatalogModelDiff.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { api, type Entry } from "@swiss/lib/api"; +import { api, type Variant } from "@swiss/lib/api"; import { toYaml } from "@swiss/lib/yaml"; import { Badge } from "@swiss/components/ui/badge"; import { Dialog } from "@swiss/components/ui/dialog"; @@ -34,15 +34,15 @@ export function CatalogModelDiff({ }) { const ready = !!model && !!version && !!variant; const entry = useQuery({ - queryKey: ["catalog-model", catalog, model, version], - queryFn: () => api.model(model!, version, catalog), + queryKey: ["catalog-model", catalog, model, variant, version], + queryFn: () => api.model(model!, { catalog, variant, version }), enabled: open && ready, retry: false, staleTime: Infinity, }); - const published = entry.data ? catalogValues(entry.data.entry, variant!) : undefined; - const publishedVariant = entry.data?.entry.variants.find((item) => item.id === variant); + const publishedVariant = entry.data?.variant; + const published = publishedVariant ? catalogValues(publishedVariant) : undefined; const diff = published?.tree && publishedVariant ? lineDiff( @@ -71,10 +71,6 @@ export function CatalogModelDiff({ ) : published?.error ? (

{published.error}

- ) : published && !published.tree ? ( -

- The catalog no longer lists variant {variant} on version {version}. -

) : (
@@ -97,14 +93,11 @@ export function CatalogModelDiff({ ); } -export function catalogValues( - entry: Entry, - variantId: string, -): { tree?: Record; error?: string } { - const variant = entry.variants.find((item) => item.id === variantId); - if (!variant) return {}; +// The catalog layer compose writes for a variant. A variant's image.digest stays +// out, as it does there: the charts' schema rejects it. +export function catalogValues(variant: Variant): { tree?: Record; error?: string } { const tree: Record = structuredClone(variant.values ?? {}); - const name = setPath(tree, "model.name", entry.servedName || entry.name); + const name = setPath(tree, "model.name", variant.servedName); if (name) return { error: name }; const gpus = setPath(tree, "model.gpus", String(variant.requires.gpus)); if (gpus) return { error: gpus }; @@ -112,7 +105,6 @@ export function catalogValues( for (const [key, value] of [ ["image.repository", variant.image.repository], ["image.tag", variant.image.tag], - ["image.digest", variant.image.digest], ] as const) { if (!value) continue; const err = setPath(tree, key, value); diff --git a/web/src/modules/swiss/components/ProfileForm.tsx b/web/src/modules/swiss/components/ProfileForm.tsx index fd6f96d..cfee7cb 100644 --- a/web/src/modules/swiss/components/ProfileForm.tsx +++ b/web/src/modules/swiss/components/ProfileForm.tsx @@ -84,7 +84,7 @@ export function ProfileForm({ > @@ -291,7 +291,7 @@ export function ProfileForm({ ); } -type CatalogRepo = { name: string; url: string; default?: boolean }; +type CatalogRepo = NonNullable[number]; // Catalogs is the list a page selects from. swissd refuses the profile over a // row without a name or url, a name that is not lowercase, a name or url listed @@ -348,6 +348,16 @@ function Catalogs({ placeholder="https://modelsphere.github.io/model-catalog/" aria-label="catalog url" /> + {/* Unset is v1, so v1 is written as unset. */} + {/* At most one default: checking one clears the others. */}