Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Console is a Go backend-for-frontend with a React UI compiled into the same bina

- **Identity and access control.** Users, roles and login history stored as Kubernetes CRDs; OAuth2 password login with HS256 tokens; Kubernetes-RBAC-style roles that gate both pages and backend APIs. The seeded administrator is asked to set a password on first login.
- **Model deployment.** With [Swiss](https://github.com/modelsphere/swiss) on the cluster, browse the model catalog and deploy, upgrade and uninstall models, with a diff before every change and the GPU nodes they run on.
- **Canary experiments.** When the Canary backend is configured, each experiment arm selects its own v2 model catalog, then a model and variant within it. The catalog selector defaults to `upstreamv2` when available, otherwise the supported environment default or first supported catalog. Changing catalogs clears the arm's previous model, variant, chart pin and YAML overrides; v1 catalogs cannot be selected.
- **Playground.** Streaming chat with the models deployed in Model Serving, full sampling parameters, per-answer TTFT, tokens/s and cache hit rate, reasoning output, and "view code" for cURL, Python and Node.js.
- **OpenAI-compatible router.** Programs call `/v1` with API keys that administrators issue — with expiry and optional per-model scope. Keys are stored hashed; usage is exported as Prometheus metrics.
- **Gateway discovery.** The inference entrypoint, routes and gateway key are read from the cluster (Swiss's site profile or the route ConfigMap) and followed as they change — no URLs or keys copied into configuration.
Expand Down
9 changes: 7 additions & 2 deletions web/src/modules/canary/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ChartVersionsSource,
CatalogIndexV2,
CatalogVariant,
CatalogInfo,
LLMCanary,
ProductionTarget,
} from "../types";
Expand All @@ -25,13 +26,14 @@ export interface CanaryApi {
}

export interface CatalogApi {
index(): Promise<CatalogIndexV2>;
list(): Promise<CatalogInfo[]>;
index(catalog?: string): Promise<CatalogIndexV2>;
variant(
model: string,
id: string,
version?: string,
): Promise<CatalogVariant | null>;
variants(): Promise<CatalogVariant[]>;
variants(catalog?: string): Promise<CatalogVariant[]>;
chartVersions(source: ChartVersionsSource): Promise<ChartVersions>;
}

Expand Down Expand Up @@ -66,6 +68,9 @@ export const fixtureCanaryApi: CanaryApi = {
};

export const fixtureCatalogApi: CatalogApi = {
async list() {
return [{ name: catalogIndexV2.name, schema: "v2", default: true }];
},
async index() {
if (catalogIndexV2.schema !== "v2") {
throw new Error("catalog schema v1 is not supported");
Expand Down
36 changes: 36 additions & 0 deletions web/src/modules/canary/lib/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { changeArmCatalog, defaultCatalog } from "./catalog";
import type { ArmDraft } from "../types";

describe("arm catalog selection", () => {
it("prefers upstreamv2 even when another catalog is the environment default", () => {
expect(defaultCatalog([
{ name: "upstream", schema: "v1" },
{ name: "inner", schema: "v2", default: true },
{ name: "upstreamv2", schema: "v2" },
])).toBe("upstreamv2");
});

it("uses a supported environment default or first supported catalog when upstreamv2 is absent", () => {
expect(defaultCatalog([{ name: "old", schema: "v1", default: true }, { name: "inner", schema: "v2" }])).toBe("inner");
expect(defaultCatalog([{ name: "first", schema: "v2" }, { name: "inner", schema: "v2", default: true }])).toBe("inner");
expect(defaultCatalog([{ name: "old", schema: "v1" }])).toBe("");
expect(defaultCatalog([])).toBe("");
});

it("clears source-specific configuration even when both catalogs contain the same model and variant names", () => {
const arm: ArmDraft = {
name: "baseline", replicaCount: 2, pricingKey: "custom",
source: { catalog: "upstreamv2", model: "same-model", variant: "same-variant", version: "1.0.0", digest: "sha256:old" },
chart: { name: "sglang", version: "0.8.0" },
layers: { catalog: { model: { gpus: 8 } }, form: { replicaCount: 2 }, edit: { image: { tag: "old" } } },
};
const switched = changeArmCatalog(arm, "inner");
expect(switched.source).toEqual({ catalog: "inner", model: "", variant: "", version: "" });
expect(switched.chart).toEqual({ name: "", version: "" });
expect(switched.layers).toEqual({ form: { replicaCount: 2 } });
expect(switched).toMatchObject({ name: "baseline", replicaCount: 2, pricingKey: "custom" });
expect(arm.source?.catalog).toBe("upstreamv2");
expect(arm.layers.edit).toBeDefined();
});
});
16 changes: 16 additions & 0 deletions web/src/modules/canary/lib/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ArmDraft, CatalogInfo } from "../types";

export function defaultCatalog(catalogs: CatalogInfo[]): string {
const supported = catalogs.filter((c) => c.schema !== "v1");
return (supported.find((c) => c.name === "upstreamv2") ??
supported.find((c) => c.default) ?? supported[0])?.name ?? "";
}

export function changeArmCatalog(arm: ArmDraft, catalog: string): ArmDraft {
return {
...arm,
source: { catalog, model: "", variant: "", version: "" },
chart: { name: "", version: "" },
layers: { form: { replicaCount: arm.replicaCount } },
};
}
24 changes: 24 additions & 0 deletions web/src/modules/canary/lib/live-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ import { registerCanaryLocales } from "../locales";
import i18next from "i18next";

describe("live transport", () => {
it("loads available catalogs and reads models from the named catalog through authenticated Swiss transport", async () => {
const catalogs = [{ name: "upstreamv2", schema: "v2", default: true }, { name: "inner", schema: "v2" }];
const fetcher = vi.fn(async (path: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify(String(path).endsWith("/cluster") ? { catalogs } : { schema: "v2", name: "inner & test", models: [] })));
const api = createLiveApi(fetcher, "/custom/canary");
expect(await api.catalog.list()).toEqual(catalogs);
expect(await api.catalog.index("inner & test")).toMatchObject({ name: "inner & test", models: [] });
expect(fetcher.mock.calls.map(([path]) => String(path))).toEqual([
"/api/deploy/cluster", "/api/deploy/catalog?catalog=inner+%26+test",
]);
expect(fetcher.mock.calls.every(([, init]) => init?.credentials === "same-origin")).toBe(true);
});
it("preserves independently selected arm catalogs through prepare", async () => {
const fetcher = vi.fn(async (_path: RequestInfo | URL, _init?: RequestInit) => new Response("{}"));
const { emptyDraft } = await import("./emit");
const draft = emptyDraft();
draft.arms = ["upstreamv2", "inner"].map((catalog, i) => ({
name: `arm-${i}`, replicaCount: 1, layers: {}, chart: { name: "sglang", version: "0.8.0" },
source: { catalog, model: "same-model", variant: "same-variant", version: "1.0.0" },
}));
await createLiveApi(fetcher).experiments.prepare(draft);
const payload = JSON.parse(String(fetcher.mock.calls[0][1]?.body));
expect(payload.arms.map((a: { source: { catalog: string } }) => a.source.catalog)).toEqual(["upstreamv2", "inner"]);
});
it("patches only weights and the expected generation through authenticated transport", async () => {
const fetcher = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response("{}"));
await createLiveApi(fetcher).experiments.updateTraffic("a/b", { production: 90, canary: 10, expectedGeneration: 4 });
Expand Down
48 changes: 28 additions & 20 deletions web/src/modules/canary/lib/live-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ChartVersionsSource,
CatalogIndexV2,
CatalogVariant,
CatalogInfo,
LLMCanary,
ProductionTarget,
Experiment,
Expand All @@ -29,8 +30,9 @@ export interface CanaryClient {
get(namespace: string, name: string): Promise<LLMCanary | null>;
};
catalog: {
index(): Promise<CatalogIndexV2>;
variants(): Promise<CatalogVariant[]>;
list(): Promise<CatalogInfo[]>;
index(catalog?: string): Promise<CatalogIndexV2>;
variants(catalog?: string): Promise<CatalogVariant[]>;
chartVersions(source: ChartVersionsSource): Promise<ChartVersions>;
};
swiss: { deployments(): Promise<ProductionTarget[]> };
Expand Down Expand Up @@ -164,7 +166,15 @@ export function createLiveApi(
),
};
const catalog = {
index: () => request<CatalogIndexV2>("/catalog"),
list: async () => (await request<{ catalogs?: CatalogInfo[] }>("/cluster", "GET", undefined, "/api/deploy")).catalogs ?? [],
async index(name?: string): Promise<CatalogIndexV2> {
const index = await request<CatalogIndexV2>(
name ? `/catalog?${new URLSearchParams({ catalog: name })}` : "/catalog",
"GET", undefined, name ? "/api/deploy" : base,
);
if (index.schema !== "v2") throw new Error("catalog schema v2 is required");
return index;
},
async chartVersions(source: ChartVersionsSource): Promise<ChartVersions> {
const query = new URLSearchParams({
variant: source.variant,
Expand All @@ -182,23 +192,8 @@ export function createLiveApi(
throw new CanaryApiError("Invalid Swiss chart versions response", undefined, "badResponse");
return { ...data, versions: [...new Set(data.versions)] };
},
async variants(): Promise<CatalogVariant[]> {
const index = await catalog.index();
if (index.schema !== "v2")
throw new Error("catalog schema v2 is required");
return index.models.flatMap((m) =>
m.variants.map((v) => ({
...v,
apiVersion: "catalog.swiss/v2" as const,
kind: "Variant" as const,
model: m.name,
engine: v.engine as CatalogVariant["engine"],
chart: v.chart as CatalogVariant["chart"],
// Chart constraints are resolved by Swiss at prepare, never guessed by the UI.
resolvedChartVersion: "",
values: {},
})),
);
async variants(name?: string): Promise<CatalogVariant[]> {
return variantsFromIndex(await catalog.index(name));
},
};
return {
Expand Down Expand Up @@ -232,3 +227,16 @@ export function createLiveApi(
},
};
}

export function variantsFromIndex(index: CatalogIndexV2): CatalogVariant[] {
return index.models.flatMap((m) => m.variants.map((v) => ({
...v,
apiVersion: "catalog.swiss/v2" as const,
kind: "Variant" as const,
model: m.name,
engine: v.engine as CatalogVariant["engine"],
chart: v.chart as CatalogVariant["chart"],
resolvedChartVersion: "",
values: {},
})));
}
28 changes: 26 additions & 2 deletions web/src/modules/canary/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,23 @@ export const en = {
environmentUnset: "Not configured (environment default)",
environmentDefaultPort: "Use environment default port",
armsHint:
"Each arm takes one catalog schema v2 variant: chart and values. The traffic split, Gateway, and HTTPRoute are the following steps.",
"Choose a catalog, then a model and variant for each arm. The default catalog is upstreamv2 when available.",
addArm: "Add arm",
removeArm: "Remove",
armName: "Arm name",
model: "Model",
catalog: "Model catalog",
catalogHint: "Changing the catalog clears this arm's model, variant, chart version and YAML overrides. Canary requires catalog schema v2.",
selectCatalog: "Select a model catalog",
selectModel: "Select a model",
searchModels: "Search models",
catalogV1Unsupported: "v1, unsupported for Canary",
catalogLoading: "Loading models…",
catalogsLoading: "Loading model catalogs…",
catalogsEmpty: "No v2 model catalogs are configured. Ask an administrator to configure one.",
catalogFailed: "Could not load models from this catalog.",
catalogRetry: "Retry loading catalog",
catalogEmpty: "No models are available. Select another model catalog.",
variant: "Variant @ version",
chart: "Chart",
chartPin: "Chart {name}",
Expand Down Expand Up @@ -205,11 +217,23 @@ export const zh = {
environmentUnset: "未配置(使用环境默认行为)",
environmentDefaultPort: "使用环境默认端口",
armsHint:
"每个组别选择一个目录 schema v2 变体,只决定 chart 和 values。流量拆分、Gateway 和 HTTPRoute 在后面的步骤里填写。",
"每个组别先选模型目录,再选该目录中的模型和变体。默认使用 upstreamv2(如已配置)。",
addArm: "添加组别",
removeArm: "删除",
armName: "组别名称",
model: "模型",
catalog: "模型目录",
catalogHint: "切换目录会清空当前组别的模型、变体、chart 版本及 YAML 覆盖配置。北极星实验需要 schema v2 目录。",
selectCatalog: "请选择模型目录",
selectModel: "请选择模型",
searchModels: "搜索模型",
catalogV1Unsupported: "v1,不支持北极星实验",
catalogLoading: "正在加载模型…",
catalogsLoading: "正在加载模型目录…",
catalogsEmpty: "尚未配置 v2 模型目录,请联系管理员配置。",
catalogFailed: "无法加载该目录中的模型。",
catalogRetry: "重新加载目录",
catalogEmpty: "没有可用模型,请选择其他模型目录。",
variant: "变体 @ 版本",
chart: "Chart",
chartPin: "Chart {name}",
Expand Down
Loading
Loading