diff --git a/web/src/modules/inferences/components/ProfileForm.test.tsx b/web/src/modules/inferences/components/ProfileForm.test.tsx
new file mode 100644
index 0000000..d562189
--- /dev/null
+++ b/web/src/modules/inferences/components/ProfileForm.test.tsx
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ProfileForm } from "./ProfileForm";
+
+describe("variant inputs", () => {
+ it("lists the profile's answers as rows, a string that reads as a number quoted", () => {
+ const html = renderToStaticMarkup(
+
+ {}} submitted={false} />
+ ,
+ );
+ for (const v of ["a.master", "mc:1", "a.port", "8000", "a.tag", ""8000""]) expect(html).toContain(`value="${v}"`);
+ });
+});
diff --git a/web/src/modules/inferences/components/ProfileForm.tsx b/web/src/modules/inferences/components/ProfileForm.tsx
index 0b407ef..407b011 100644
--- a/web/src/modules/inferences/components/ProfileForm.tsx
+++ b/web/src/modules/inferences/components/ProfileForm.tsx
@@ -4,6 +4,7 @@ import { Plus, X } from "lucide-react";
import type { RouteAuth, SiteProfile } from "@swiss/lib/api";
import { useT } from "@/modules/inferences/i18n";
import { fromRows, markDefault, toRows, withNodes, type Row } from "@/modules/inferences/profile-lib";
+import { answersText, parseAnswers } from "@swiss/lib/inputs";
import { GpuTable } from "@/modules/inferences/components/GpuResources";
import { catalogErrors, FEATURES, featureOn, withFeature, type CatalogError } from "@swiss/lib/profile";
@@ -20,7 +21,7 @@ export interface TokenField {
onChange: (v: string) => void;
}
-const GROUPS = ["placement", "catalog", "model", "routing", "auth", "scaler", "pdScale", "defaults", "sites"] as const;
+const GROUPS = ["placement", "catalog", "model", "inputs", "routing", "auth", "scaler", "pdScale", "defaults", "sites"] as const;
export function ProfileForm({
value: p,
@@ -126,6 +127,11 @@ export function ProfileForm({
>,
)}
+ {group(
+ "inputs",
+
set({ inputs: parseAnswers(o) })} />,
+ )}
+
{group(
"routing",
<>
diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json
index 07d323d..17e95cd 100644
--- a/web/src/modules/inferences/locales/en-US.json
+++ b/web/src/modules/inferences/locales/en-US.json
@@ -578,6 +578,21 @@
"scheduler": "Scheduler",
"localPath": "Model path",
"localPathHint": "Overrides the site profile's path template",
+ "inputExample": "e.g. {example}",
+ "inputRequired": "Required: no default or site answer",
+ "inputRemember": "Save to the site profile",
+ "inputRememberHint": "Every variant that declares this key, for any model, will take it",
+ "inputReplaces": "replaces the site's answer {answer}",
+ "inputFrom": {
+ "release": "Release",
+ "site": "Site profile",
+ "variant": "Variant default"
+ },
+ "inputProblem": {
+ "string": "Must match {pattern}",
+ "integer": "Enter a whole number",
+ "boolean": "Enter true or false"
+ },
"image": "Override image",
"imageHint": "Off uses the catalog's tag and the site's mirror",
"imageValue": "Image (repository:tag)",
@@ -684,6 +699,10 @@
"back": "Back to edit",
"compose": "Compose plan",
"composing": "Composing…",
+ "siteProfile": "Site profile",
+ "siteProfileHint": "Saved once the deploy applies:",
+ "siteSaved": "Saved to the site profile: {keys}",
+ "siteNotSaved": "Deployed; the site profile was not saved: {error}",
"install": "Deploy",
"apply": "Upgrade",
"rollback": "Roll back",
@@ -816,6 +835,7 @@
"placement": "Placement",
"catalog": "Catalogs, charts and images",
"model": "Model paths",
+ "inputs": "Variant inputs",
"routing": "Routing",
"auth": "Entrypoint auth",
"scaler": "Cache and scaler",
@@ -828,6 +848,7 @@
"placement": "Where releases land. The scheduler and priority class apply before the deploy form merges, so a deploy can override either.",
"catalog": "Where the models come from, and the registries the catalog deliberately does not name.",
"model": "Built from the catalog's source.hf. Variables: hf (the full repo id), org and name (its halves), model (the catalog entry name); see the field's example for the syntax.",
+ "inputs": "Answers to the inputs catalog variants declare, by chart key. Only a variant that declares a key takes its answer, and a deploy can still override it. A string takes the model path variables.",
"routing": "The openresty entrypoint every model publishes a route on.",
"auth": "Used only by the serving and health checks. This document is a ConfigMap, so it names a Secret rather than holding the key.",
"pdScale": "swissd's PD slice autoscaler, for releases planned with PD on and the form SLO on. Field names follow the LLMScaler's.",
@@ -900,6 +921,8 @@
"pathTemplate": "Path template",
"overrides": "Path overrides",
"overridesHint": "Keyed by catalog model name, for weights that do not sit where the template says",
+ "inputs": "Answers",
+ "inputsHint": "8000 reads as a number and true as a boolean; quote one to keep it a string",
"key": "Key",
"value": "Value",
"add": "Add",
diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json
index 9da1ae8..29d506e 100644
--- a/web/src/modules/inferences/locales/zh-CN.json
+++ b/web/src/modules/inferences/locales/zh-CN.json
@@ -567,6 +567,21 @@
"scheduler": "调度器",
"localPath": "模型路径",
"localPathHint": "覆盖站点配置里的路径模板",
+ "inputExample": "例如 {example}",
+ "inputRequired": "必填:变体没有默认值,站点配置也没有答案",
+ "inputRemember": "保存到站点配置",
+ "inputRememberHint": "任何模型中声明了该键的变体都会采用它",
+ "inputReplaces": "替换站点现有答案 {answer}",
+ "inputFrom": {
+ "release": "当前发布",
+ "site": "站点配置",
+ "variant": "变体默认值"
+ },
+ "inputProblem": {
+ "string": "需匹配 {pattern}",
+ "integer": "请输入整数",
+ "boolean": "请输入 true 或 false"
+ },
"image": "覆盖镜像",
"imageHint": "关闭时使用目录里的 tag 和站点的镜像仓库",
"imageValue": "镜像(repository:tag)",
@@ -673,6 +688,10 @@
"back": "返回修改",
"compose": "生成计划",
"composing": "生成中…",
+ "siteProfile": "站点配置",
+ "siteProfileHint": "部署生效后保存:",
+ "siteSaved": "已保存到站点配置:{keys}",
+ "siteNotSaved": "已部署,但站点配置未保存:{error}",
"install": "确认部署",
"apply": "确认升级",
"rollback": "确认回滚",
@@ -805,6 +824,7 @@
"placement": "调度",
"catalog": "目录、Chart 与镜像",
"model": "模型路径",
+ "inputs": "变体输入",
"routing": "路由",
"auth": "入口鉴权",
"scaler": "缓存与伸缩",
@@ -817,6 +837,7 @@
"placement": "发布到哪里。调度器和优先级类在部署表单合并之前生效,部署时可以覆盖。",
"catalog": "模型从哪来,以及目录刻意不写的 chart 和镜像仓库。",
"model": "根据目录的 source.hf 生成路径。可用变量:hf(完整 repo id)、org 和 name(它的两段)、model(目录里的模型名),写法见输入框里的示例。",
+ "inputs": "按 chart 键回答目录变体声明的输入。只有声明了该键的变体会采用,部署时仍可覆盖。字符串可使用模型路径的变量。",
"routing": "每个模型发布路由的 openresty 入口。",
"auth": "只用于服务检查。这份配置是 ConfigMap,所以只写 Secret 的名字,不保存密钥本身。",
"pdScale": "swissd 的 PD slice 自动伸缩,作用于开启 PD 且表单开启 SLO 的发布。字段名沿用 LLMScaler。",
@@ -889,6 +910,8 @@
"pathTemplate": "路径模板",
"overrides": "路径覆盖",
"overridesHint": "按目录里的模型名,给不在模板路径下的权重单独指定路径",
+ "inputs": "答案",
+ "inputsHint": "8000 读作数字,true 读作布尔值;加引号则保持为字符串",
"key": "键",
"value": "值",
"add": "添加",
diff --git a/web/src/modules/inferences/profile-lib.test.ts b/web/src/modules/inferences/profile-lib.test.ts
index 5dfb1d8..0b3a9f5 100644
--- a/web/src/modules/inferences/profile-lib.test.ts
+++ b/web/src/modules/inferences/profile-lib.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { SiteProfile } from "@swiss/lib/api";
import { fromRows, markDefault, profileErrors, toRows, withNodes } from "@/modules/inferences/profile-lib";
+import { answersText, parseAnswers } from "@swiss/lib/inputs";
describe("key-value rows", () => {
it("round-trips a table and drops rows without a key", () => {
@@ -51,3 +52,12 @@ describe("nodes", () => {
});
});
+
+describe("input answers", () => {
+ it("maps a profile's answers to rows and back", () => {
+ const inputs = { "a.master": "mc:1", "a.port": 8000, "a.on": true };
+ expect(parseAnswers(answersText(inputs))).toEqual(inputs);
+ expect(parseAnswers(fromRows(toRows(answersText(inputs))))).toEqual(inputs);
+ expect(parseAnswers(fromRows([]))).toBeUndefined();
+ });
+});
diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx
index 3a6fcaf..b0fb374 100644
--- a/web/src/modules/swiss/components/DeploySettings.tsx
+++ b/web/src/modules/swiss/components/DeploySettings.tsx
@@ -1,9 +1,11 @@
import { useState } from "react";
import { ChevronDown, Plus, RefreshCw, TriangleAlert, X } from "lucide-react";
-import type { ApplyFlags, ClusterInfo, Plan, PlanRequest, ProfileResponse, Variant } from "@swiss/lib/api";
+import type { ApplyFlags, ClusterInfo, Input as CatalogInput, Plan, PlanRequest, ProfileResponse, Variant } from "@swiss/lib/api";
import { cn } from "@swiss/lib/utils";
import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache";
import { productChoices, unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu";
+import { CHECK_MS, inputError, inputProblem, rememberable, setInputOverrides, type InputChoice, type InputRow } from "@swiss/lib/inputs";
+import { useDebounced } from "@swiss/lib/useDebounced";
import { FEATURES, featureOn } from "@swiss/lib/profile";
import { layersOf } from "@swiss/components/Provenance";
import {
@@ -50,6 +52,8 @@ export interface Form {
createNamespace: boolean;
serviceId: string;
localPath: string;
+ // By chart path, as typed. Empty is the row's answer (InputRow).
+ inputs: Record;
scaler: boolean;
// The LLMScaler's bounds. Empty leaves the chart's own.
scalerMin: string;
@@ -105,6 +109,7 @@ export const EMPTY: Form = {
createNamespace: false,
serviceId: "",
localPath: "",
+ inputs: {},
scaler: true,
scalerMin: "",
scalerMax: "",
@@ -181,6 +186,7 @@ export function DeploySettings({
serviceIdPlaceholder,
localPathDefault,
localPathPlaceholder,
+ inputs,
image,
supportedGPUs,
clusterGPUs,
@@ -194,6 +200,8 @@ export function DeploySettings({
// archived plan verbatim, so an editable field beside it would promise a
// change that the rollback would then not make.
readOnly = false,
+ remember,
+ onRemember,
}: {
form: Form;
onChange: (patch: Partial
);
}
diff --git a/web/src/modules/swiss/routes/SiteProfile.tsx b/web/src/modules/swiss/routes/SiteProfile.tsx
index bab8ff4..9c29aba 100644
--- a/web/src/modules/swiss/routes/SiteProfile.tsx
+++ b/web/src/modules/swiss/routes/SiteProfile.tsx
@@ -11,6 +11,7 @@ import { ErrorState, Loading } from "@swiss/components/States";
import { Code } from "@swiss/components/ui/code";
import { toYaml } from "@swiss/lib/yaml";
import { GpuTable } from "@swiss/components/GpuResources";
+import { answerText } from "@swiss/lib/inputs";
export function SiteProfile() {
const [editing, setEditing] = useState(false);
@@ -150,6 +151,17 @@ export function SiteProfile() {
)}
+