From 0768133c6a1ffe43a4e5f301fa05189735a3283b Mon Sep 17 00:00:00 2001 From: jtenniswood Date: Wed, 2 Sep 2026 11:33:51 +0100 Subject: [PATCH] feat(studio): provider management, model router, gateway + controller provider routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-config half of settings, consolidated with its server side. Provider list/add/test/remove and the active-provider switch run through the local controller against the daemon's auth file — server-side ownership; booleans and key-shape probes cross the wire, never key material (the provider-auth suite has pinned that logic since the server-tier PR). Built-in providers take base-URL overrides; custom gateway providers are validated. The model-router editor offers the daemon's whole model inventory; the MCP gateway stays user-entered but always validated with HTTPS-only egress. The About-this-daemon card lands with server-info.ts, its only consumer. local-controller.mjs grows /providers* + /model-router GET/PUT and reaches its final state; workspace.spec.ts reaches its final five-surface state (the provider spec pins the external-mode deployment-owned posture end to end). Co-Authored-By: Claude Fable 5 --- docs/design/PRODUCTION-READINESS.md | 2 +- studio/scripts/local-controller.mjs | 457 ++++++++++++- .../provider/[providerName]/page.tsx | 181 +++++ .../_components/about-daemon-card.tsx | 90 +++ .../_components/add-provider-dialog.tsx | 545 +++++++++++++++ .../settings/_components/gateway-section.tsx | 111 +++ .../_components/model-router-section.test.ts | 77 +++ .../_components/model-router-section.tsx | 637 ++++++++++++++++++ .../_components/provider-section.test.tsx | 255 +++++++ .../settings/_components/provider-section.tsx | 316 +++++++++ .../_components/runtime-status-line.tsx | 52 ++ .../settings/_components/settings-card.tsx | 4 +- .../settings/_components/settings-sections.ts | 26 +- .../app/workspace/settings/gateway/page.tsx | 15 + .../workspace/settings/model-router/page.tsx | 15 + .../settings/provider/[providerName]/page.tsx | 11 + .../app/workspace/settings/provider/page.tsx | 23 + .../agent/hooks/use-harness-runtime.ts | 184 +++++ .../agent/hooks/use-provider-management.ts | 203 ++++++ studio/src/lib/harness/server-info.ts | 46 ++ studio/src/lib/model-preferences.test.ts | 37 + studio/src/lib/model-preferences.ts | 81 +++ studio/tests/e2e/workspace.spec.ts | 11 + user-docs/building/what-you-get/studio.md | 17 +- 24 files changed, 3389 insertions(+), 7 deletions(-) create mode 100644 studio/src/app/workspace/provider/[providerName]/page.tsx create mode 100644 studio/src/app/workspace/settings/_components/about-daemon-card.tsx create mode 100644 studio/src/app/workspace/settings/_components/add-provider-dialog.tsx create mode 100644 studio/src/app/workspace/settings/_components/gateway-section.tsx create mode 100644 studio/src/app/workspace/settings/_components/model-router-section.test.ts create mode 100644 studio/src/app/workspace/settings/_components/model-router-section.tsx create mode 100644 studio/src/app/workspace/settings/_components/provider-section.test.tsx create mode 100644 studio/src/app/workspace/settings/_components/provider-section.tsx create mode 100644 studio/src/app/workspace/settings/_components/runtime-status-line.tsx create mode 100644 studio/src/app/workspace/settings/gateway/page.tsx create mode 100644 studio/src/app/workspace/settings/model-router/page.tsx create mode 100644 studio/src/app/workspace/settings/provider/[providerName]/page.tsx create mode 100644 studio/src/app/workspace/settings/provider/page.tsx create mode 100644 studio/src/features/agent/hooks/use-harness-runtime.ts create mode 100644 studio/src/features/agent/hooks/use-provider-management.ts create mode 100644 studio/src/lib/harness/server-info.ts create mode 100644 studio/src/lib/model-preferences.test.ts create mode 100644 studio/src/lib/model-preferences.ts diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index 4efb97b875..ba655dcc63 100644 --- a/docs/design/PRODUCTION-READINESS.md +++ b/docs/design/PRODUCTION-READINESS.md @@ -46,7 +46,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do | mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) | | ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) | | Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) | -| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ⛔ provider/model-router/gateway settings · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | +| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ✅ provider management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ⛔ external-mode OIDC sign-in · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | | _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — | ## Security diff --git a/studio/scripts/local-controller.mjs b/studio/scripts/local-controller.mjs index 54724a7a67..9d29bd7549 100644 --- a/studio/scripts/local-controller.mjs +++ b/studio/scripts/local-controller.mjs @@ -21,9 +21,12 @@ import { validSkillName, } from "../src/lib/controller-security.mjs"; import { + customProviderProbeURL, KNOWN_AUTH_PROVIDERS, listAuthFileProviders, listSettingsProviders, + removeAuthFileProvider, + validProviderName, } from "../src/lib/provider-auth.mjs"; const here = dirname(fileURLToPath(import.meta.url)); @@ -143,6 +146,142 @@ async function listSelectableProviderNames() { } return names; } + +// ── Provider management ───────────────────────────────────────────────────── +// The provider inventory, guided add, key test, and removal are controller- +// owned for the same reason the skills routes are: mecated reads auth.yaml +// once at startup and has no HTTP write API for it. Every route keeps Studio +// rule 3 intact — a credential is read SERVER-SIDE here for exactly one +// outbound probe or removed from the file; no response body ever carries a +// key, not even a redacted preview, and there is no route that ACCEPTS one. + +/** + * One cheap authenticated read per testable provider, mirroring the base + * URLs the daemon itself defaults to (internal/cliconfig: the controller + * spawns mecated without --*-base-url overrides, so these defaults are what + * the key will actually be used against). OpenRouter's /models is public + * (the daemon's own lister is deliberately keyless), so its keyed metadata + * endpoint /key is the probe there. + */ +const providerKeyProbes = { + openrouter: (key) => ({ + url: "https://openrouter.ai/api/v1/key", + headers: { Authorization: `Bearer ${key}` }, + }), + openai: (key) => ({ + url: "https://api.openai.com/v1/models", + headers: { Authorization: `Bearer ${key}` }, + }), + anthropic: (key) => ({ + url: "https://api.anthropic.com/v1/models?limit=1", + headers: { "x-api-key": key, "anthropic-version": "2023-06-01" }, + }), + opencode: (key) => ({ + url: "https://opencode.ai/zen/go/v1/models", + headers: { Authorization: `Bearer ${key}` }, + }), +}; + +/** + * The named provider's api_key value, read server-side for the one outbound + * key probe. Deliberately controller-local (NOT in provider-auth.mjs, which + * the browser bundle imports) and deliberately api_key-only: openai-codex's + * oauth block is not key-testable. The value is never logged or echoed. + */ +function readProviderCredential(text, name) { + const lines = String(text ?? "").split("\n"); + const providersAt = lines.findIndex((line) => /^providers:\s*$/.test(line)); + if (providersAt === -1) return ""; + let inBlock = false; + for (const line of lines.slice(providersAt + 1)) { + if (/^\S/.test(line)) break; // dedented past the providers block + const key = line.match(/^ {2}([A-Za-z0-9_-]+):/); + if (key) { + inBlock = key[1] === name; + continue; + } + if (!inBlock) continue; + const credential = line.match(/^\s+api_key:\s*(.+)$/); + if (!credential) continue; + let value = credential[1].trim(); + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.endsWith(quote)) { + value = value.slice(1, -1); + } + return value.startsWith("#") ? "" : value; + } + return ""; +} + +/** Provider error text, bounded and de-control-charred before it reaches a + * response body (it is provider-authored, not ours). */ +function clampProviderError(text) { + return ( + String(text ?? "") + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 300) + ); +} + +/** + * The one keyed probe request for a CUSTOM (settings-defined, ADR 0238) + * provider: a models listing against ITS configured base URL, with the + * header shape its api_flavor dictates. Controller-local for the same reason + * readProviderCredential is — it carries the key. + */ +function customProviderKeyProbe(definition, key) { + const url = customProviderProbeURL(definition.apiFlavor, definition.baseURL); + if (definition.apiFlavor === "anthropic-messages") { + return { + url, + headers: { "x-api-key": key, "anthropic-version": "2023-06-01" }, + }; + } + return { url, headers: { Authorization: `Bearer ${key}` } }; +} + +/** + * Runs the one bounded probe ({url, headers}) for a provider's stored key. + * Returns the JSON the route answers with: + * {ok:true} | {ok:false, status, rejected?, error}. + * A 401/403 is the provider saying the KEY is bad; anything else (5xx, + * timeout, DNS) is an infrastructure answer, reported distinctly so a red + * "key rejected" dot is never shown for a provider outage. + */ +async function probeProviderKey(probe) { + let response; + try { + response = await fetch(probe.url, { + headers: { Accept: "application/json", ...probe.headers }, + redirect: "manual", // never replay the credential to a redirect target + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + return { + ok: false, + status: 0, + error: clampProviderError(error?.message || "provider unreachable"), + }; + } + const body = await response.text().catch(() => ""); + if (response.ok) return { ok: true }; + if (response.status === 401 || response.status === 403) { + return { + ok: false, + status: response.status, + rejected: true, + error: `key rejected (HTTP ${response.status})`, + }; + } + return { + ok: false, + status: response.status, + error: clampProviderError(body) || `HTTP ${response.status}`, + }; +} // ── Workspace skills management ──────────────────────────────────────────── // The daemon has no HTTP write API for skills (Studio shipped them read-only; // authoring is the ADR-0233 backlog item), and it resolves the skills dir @@ -509,6 +648,17 @@ async function detectToolhiveGateway() { const preferredKind = () => activeProviderOverride || (toolhiveReady ? "toolhive" : "mock"); +/** Whether `kind` is safe to hand to startMecatl right now: the two + * synthetic kinds (mock always, toolhive only while the gateway answers), a + * provider that actually has a block in auth.yaml, or a settings-defined + * custom provider (ADR 0238) — never an arbitrary string, so a typo can't + * reach mecated's fail-fast --default-provider check and crash the child. */ +function isSelectableProviderKind(kind, selectableNames) { + if (kind === "mock") return true; + if (kind === "toolhive") return toolhiveReady; + return selectableNames.includes(kind); +} + function normalizeModelRouter(input) { const classifierModel = typeof input?.classifierModel === "string" @@ -565,6 +715,45 @@ function normalizeModelRouter(input) { }; } +const yamlString = (value) => JSON.stringify(String(value)); + +function renderModelRouterYAML(config) { + const categories = config.categories + .map((category) => + [ + ` - name: ${yamlString(category.name)}`, + ` description: ${yamlString(category.description)}`, + ` model: ${yamlString(category.model)}`, + ].join("\n"), + ) + .join("\n"); + return [ + "# Managed by Mecatl Studio. This is loaded at the trusted CLI/operator tier.", + "models:", + " slots:", + ` router: ${yamlString(config.classifierModel)}`, + " router:", + ` disabled: ${config.enabled ? "false" : "true"}`, + " classifier-slot: router", + ` default-category: ${yamlString(config.defaultCategory)}`, + " categories:", + categories, + "", + ].join("\n"); +} + +async function persistModelRouter(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const yamlTemp = `${routerSettingsFile}.tmp`; + const jsonTemp = `${routerStateFile}.tmp`; + await writeFile(yamlTemp, renderModelRouterYAML(config), { mode: 0o600 }); + await writeFile(jsonTemp, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await rename(yamlTemp, routerSettingsFile); + await rename(jsonTemp, routerStateFile); +} + async function loadModelRouter() { try { return normalizeModelRouter( @@ -1350,6 +1539,233 @@ const server = http.createServer(async (request, response) => { ); return; } + // Provider inventory: names + key-present booleans from auth.yaml, plus the + // settings-defined custom providers (ADR 0238) — a keyless custom provider + // has no auth.yaml block, so the auth scan alone would hide it. Never + // values. Like the skill routes (and unlike /status) this is NOT in the + // header-free read-only allowlist — it needs the server-set studio header. + if (request.method === "GET" && requestURL.pathname === "/providers") { + const authRows = listAuthFileProviders(await readAuthFileText()); + const custom = await listCustomSettingsProviders(); + const customNames = new Set(custom.map((definition) => definition.name)); + const providers = authRows + .filter((row) => !customNames.has(row.name)) + .map((row) => ({ + name: row.name, + configured: true, + keyPresent: row.keyPresent, + source: "auth.yaml", + testable: Object.hasOwn(providerKeyProbes, row.name), + })); + for (const definition of custom) { + const keyed = definition.authMethod === "api_key"; + const authRow = authRows.find((row) => row.name === definition.name); + providers.push({ + name: definition.name, + configured: true, + // A keyless (auth.method: none) provider needs no credential — its + // requirement is satisfied by configuration alone. + keyPresent: keyed ? Boolean(authRow?.keyPresent) : true, + source: + keyed && authRow ? "settings.yaml + auth.yaml" : "settings.yaml", + testable: + keyed && + customProviderProbeURL(definition.apiFlavor, definition.baseURL) !== + "", + }); + } + response.end(JSON.stringify({ providers })); + return; + } + // The provider kinds the daemon understands, with the guided-add snippet + // (a placeholder — this route never sees a real credential). + if (request.method === "GET" && requestURL.pathname === "/providers/known") { + response.end(JSON.stringify({ known: KNOWN_AUTH_PROVIDERS })); + return; + } + // Live provider switch: POST /providers/active { kind }. Unlike + // MECATL_STUDIO_PROVIDER (fixed for the process's lifetime), this + // reassigns activeProviderOverride and restarts mecated on the spot — the + // Studio settings UI's "switch to mock" / "switch to " control. + // Checked BEFORE the /providers/{name} regex below, which would otherwise + // match "active" as a provider name and 404 it first. + if ( + request.method === "POST" && + requestURL.pathname === "/providers/active" + ) { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const input = JSON.parse( + (await readBody(request, 4096)).toString("utf8"), + ); + const kind = + typeof input?.kind === "string" ? input.kind.trim().toLowerCase() : ""; + const selectableNames = await listSelectableProviderNames(); + if (!kind || !isSelectableProviderKind(kind, selectableNames)) { + throw Object.assign( + new Error( + kind === "toolhive" + ? "The ToolHive LLM gateway is not reachable right now" + : `"${kind || "(empty)"}" is not mock, toolhive, a provider configured in ${authFile}, or a custom provider defined in the operator settings`, + ), + { statusCode: 400 }, + ); + } + await queueRestart(async () => { + const previous = activeProviderOverride; + activeProviderOverride = kind; + try { + await startMecatl(preferredKind()); + } catch (error) { + activeProviderOverride = previous; + await startMecatl(preferredKind()); + throw error; + } + }); + response.end( + JSON.stringify({ + ok: true, + provider, + selectedProvider: activeProviderOverride, + }), + ); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not switch provider", + ); + } + return; + } + // Key test + removal: POST /providers/{name}/test, DELETE /providers/{name}. + const providerRoute = requestURL.pathname.match( + /^\/providers\/([^/]+?)(?:\/(test))?$/, + ); + if (providerRoute) { + const [, rawName, action] = providerRoute; + let name = rawName; + try { + name = decodeURIComponent(rawName); + } catch { + // Malformed escape: the grammar below rejects percent-shaped names. + } + if (!validProviderName(name)) { + jsonError(response, 400, "not a valid provider name"); + return; + } + if (request.method === "POST" && action === "test") { + // ONE cheap authenticated call with the STORED key, made entirely + // server-side. The key never appears in the response, the logs, or an + // error message; the probe is bounded (10s) and never follows a + // redirect with the credential attached. A settings-defined custom + // provider (ADR 0238) is probed against ITS OWN base URL with the + // header shape its api_flavor dictates. + const definition = (await listCustomSettingsProviders()).find( + (candidate) => candidate.name === name, + ); + let buildProbe = null; + if (definition) { + if (definition.authMethod !== "api_key") { + jsonError( + response, + 400, + `"${name}" uses auth.method none — there is no key to test.`, + ); + return; + } + if ( + customProviderProbeURL(definition.apiFlavor, definition.baseURL) === + "" + ) { + jsonError( + response, + 400, + `Key testing is not supported for "${name}" — its api_flavor or base_url cannot be probed; mecated will report an auth problem on first use instead.`, + ); + return; + } + buildProbe = (key) => customProviderKeyProbe(definition, key); + } else if (Object.hasOwn(providerKeyProbes, name)) { + buildProbe = providerKeyProbes[name]; + } else { + jsonError( + response, + 400, + `Key testing is not supported for "${name}" — mecated will report an auth problem on first use instead.`, + ); + return; + } + const key = readProviderCredential(await readAuthFileText(), name); + if (!key) { + jsonError( + response, + 400, + `No api_key found for providers.${name} in ${authFile}`, + ); + return; + } + response.end(JSON.stringify(await probeProviderKey(buildProbe(key)))); + return; + } + if (request.method === "DELETE" && !action) { + // Removing a provider block is a conservative line-range cut of the + // named top-level key (provider-auth.mjs), written temp-file+rename + // with owner-only permissions, then a daemon restart so the change is + // real. Removing the LAST provider is allowed: mecated runs on the + // offline mock without providers (the state the user already sees on + // first run) — the UI's confirm warns, the controller doesn't refuse. + // If the restart then fails (e.g. MECATL_STUDIO_PROVIDER still names + // the removed provider), the removal STANDS — the operator asked for + // the credential to be gone — and the startup error surfaces both in + // this response and on /status.startupError. + try { + await queueRestart(async () => { + const current = await readAuthFileText(); + const { text, removed } = removeAuthFileProvider(current, name); + if (!removed) { + // A settings-defined provider has no auth.yaml block to cut — + // its definition lives in an operator-owned settings file the + // controller never edits. + const settingsDefined = (await listCustomSettingsProviders()).some( + (definition) => definition.name === name, + ); + throw Object.assign( + new Error( + settingsDefined + ? `"${name}" is defined in the operator settings (providers: section), not ${authFile} — remove it from the settings file, then restart the daemon.` + : `No provider named "${name}" in ${authFile}`, + ), + { statusCode: settingsDefined ? 409 : 404 }, + ); + } + const temp = `${authFile}.tmp`; + await writeFile(temp, text, { mode: 0o600 }); + await rename(temp, authFile); + await startMecatl(preferredKind()); + }); + response.end(JSON.stringify({ ok: true, restarted: true })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Provider removal failed", + ); + } + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not found" })); + return; + } // The disabled-skill inventory. Like every controller route this sits // behind requestIsAllowed (loopback Host + allowlisted Origin + the // server-set studio header) — the Next server proxy is the only caller. @@ -1670,7 +2086,19 @@ const server = http.createServer(async (request, response) => { } return; } - if (request.method !== "POST" || requestURL.pathname !== "/mcp") { + if (request.method === "GET" && requestURL.pathname === "/model-router") { + response.end( + JSON.stringify({ + config: modelRouterConfig, + managedBy: operatorSettingsActive ? "operator-settings" : "studio", + }), + ); + return; + } + if ( + request.method !== "POST" || + !["/mcp", "/model-router"].includes(requestURL.pathname) + ) { response.statusCode = 404; response.end(JSON.stringify({ error: "not found" })); return; @@ -1688,6 +2116,33 @@ const server = http.createServer(async (request, response) => { const input = JSON.parse( (await readBody(request, 16_384)).toString("utf8"), ); + if (requestURL.pathname === "/model-router") { + if (operatorSettingsActive) + throw new Error( + "Routing is managed by the imported operator settings. Update the complete settings file to preserve its aliases, slots, and guardrails.", + ); + const nextConfig = normalizeModelRouter(input); + await queueRestart(async () => { + const previousConfig = modelRouterConfig; + modelRouterConfig = nextConfig; + await persistModelRouter(nextConfig); + try { + await startMecatl(preferredKind()); + } catch (error) { + modelRouterConfig = previousConfig; + if (previousConfig) await persistModelRouter(previousConfig); + else + await Promise.all([ + rm(routerSettingsFile, { force: true }), + rm(routerStateFile, { force: true }), + ]); + await startMecatl(preferredKind()); + throw error; + } + }); + response.end(JSON.stringify({ ok: true, config: modelRouterConfig })); + return; + } if (!/^[A-Za-z0-9_]+$/.test(input.name || "")) throw new Error( "Gateway name may contain only letters, numbers, and underscores", diff --git a/studio/src/app/workspace/provider/[providerName]/page.tsx b/studio/src/app/workspace/provider/[providerName]/page.tsx new file mode 100644 index 0000000000..4916c1e75d --- /dev/null +++ b/studio/src/app/workspace/provider/[providerName]/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useMemo } from "react"; +import { + directed, + SortableHead, + useTableSort, +} from "@/components/sortable-head"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + type HarnessModel, + useHarnessRuntime, +} from "@/features/agent/hooks/use-harness-runtime"; +import { useDisabledModels } from "@/lib/model-preferences"; +import { pageTitleClass } from "@/lib/typography"; +import { cn } from "@/lib/utils"; +import { + Note, + OfflineNote, + SettingsCard, +} from "../../settings/_components/settings-card"; + +/** "128000" → "128k"; 0 stays an honest em dash (window unknown). */ +function formatContext(tokens: number): string { + if (tokens <= 0) return "—"; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; + return String(tokens); +} + +/** + * One provider's models from the daemon's live inventory (`GET /v1/models`, + * filtered by provider id), with a per-model switch. HONEST SCOPE (see + * model-preferences.ts): the daemon has no per-model disable knob — the + * operator `models.allowlist` caps project-tier model BINDINGS, not the + * inventory — so the switch is a Studio-side preference that hides the model + * from Studio's own pickers. Other clients can still ask the daemon for it, + * and the note below the table says so. + */ +export default function ProviderModelsPage() { + const params = useParams<{ providerName: string }>(); + const providerName = decodeURIComponent(params.providerName ?? ""); + const runtime = useHarnessRuntime(); + const { disabled, setModelEnabled } = useDisabledModels(); + const sort = useTableSort<"name" | "id" | "context" | "enabled">("name"); + + const models = useMemo( + () => + runtime.models.filter( + (model) => model.providerId === providerName && model.id, + ), + [runtime.models, providerName], + ); + + const sorted = useMemo(() => { + const primary = (a: HarnessModel, b: HarnessModel) => { + switch (sort.key) { + case "id": + return a.id.localeCompare(b.id); + case "context": + return a.contextLimit - b.contextLimit; + case "enabled": + return Number(disabled.has(b.id)) - Number(disabled.has(a.id)); + default: + return a.displayName.localeCompare(b.displayName); + } + }; + // Display name stays the direction-independent tiebreak for stability. + return [...models].sort( + (a, b) => + directed(sort.dir, primary(a, b)) || + a.displayName.localeCompare(b.displayName), + ); + }, [models, sort.key, sort.dir, disabled]); + + return ( +
+
+ + + Back + +

+ {providerName} models +

+ + {!runtime.live ? ( + + ) : models.length === 0 ? ( + + {runtime.isLoading + ? "Reading the model inventory…" + : `The daemon lists no models for “${providerName}”. A provider added to auth.yaml surfaces its models after a daemon restart.`} + + ) : ( +
+ + + + + + + Capabilities + + + + + + {sorted.map((model) => { + const enabled = !disabled.has(model.id); + return ( + + +

+ {model.displayName} +

+

+ {model.id} +

+
+ + {formatContext(model.contextLimit)} + + + {[ + model.image ? "image" : "", + model.reasoning ? "reasoning" : "", + ] + .filter(Boolean) + .join(" · ") || "text"} + + + + setModelEnabled(model.id, next) + } + aria-label={`Show ${model.displayName} in Studio's model pickers`} + /> + +
+ ); + })} +
+
+
+ )} +
+
+
+ ); +} diff --git a/studio/src/app/workspace/settings/_components/about-daemon-card.tsx b/studio/src/app/workspace/settings/_components/about-daemon-card.tsx new file mode 100644 index 0000000000..5e360e5ad1 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/about-daemon-card.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Copy } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { useRuntimeStatus } from "@/features/agent/runtime-status"; +import { + fetchHarnessServerInfo, + type HarnessServerInfo, +} from "@/lib/harness/server-info"; +import { SettingsCard } from "./settings-card"; + +/** + * The daemon's safe identity (ADR 0245): opaque build id, composition + * family, the sanitized endpoint of the selected provider, and the + * operator's deployment label — the only daemon-side identity available in + * external mode, and the payload a bug report wants. Renders nothing against + * an older daemon (the probe 404s). + */ +export function AboutDaemonCard({ + selectedProviderId, +}: { + /** Names the provider whose sanitized endpoint the probe should project. */ + selectedProviderId?: string; +}) { + const { connected, deployment } = useRuntimeStatus(); + const [info, setInfo] = useState(null); + + useEffect(() => { + if (!connected) return; + const controller = new AbortController(); + fetchHarnessServerInfo(selectedProviderId, controller.signal) + .then((doc) => { + if (!controller.signal.aborted) setInfo(doc); + }) + .catch(() => { + if (!controller.signal.aborted) setInfo(null); + }); + return () => controller.abort(); + }, [connected, selectedProviderId]); + + if (!info) return null; + + const rows: [string, string][] = []; + if (info.buildId) rows.push(["Build", info.buildId]); + if (info.serverImplementation) + rows.push(["Implementation", info.serverImplementation]); + if (info.providerEndpoint) + rows.push(["Provider endpoint", info.providerEndpoint]); + if (deployment) rows.push(["Deployment", deployment]); + + const debugBlob = rows.map(([k, v]) => `${k}: ${v}`).join("\n"); + + return ( + +
+
+ {rows.map(([label, value]) => ( +
+ {label} + + {value} + +
+ ))} +
+
+ +
+
+
+ ); +} diff --git a/studio/src/app/workspace/settings/_components/add-provider-dialog.tsx b/studio/src/app/workspace/settings/_components/add-provider-dialog.tsx new file mode 100644 index 0000000000..fc7f15dba8 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/add-provider-dialog.tsx @@ -0,0 +1,545 @@ +"use client"; + +import { Check, Copy, Plus } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { + HarnessProviderInfo, + KnownHarnessProvider, +} from "@/lib/harness/client"; +import { + CUSTOM_PROVIDER_API_FLAVORS, + customProviderAuthSnippet, + customProviderSettingsSnippet, + providerOverrideSnippet, + validCustomProviderBaseURL, + validCustomProviderId, +} from "@/lib/provider-auth.mjs"; + +/** The synthetic Select value for the custom-gateway flow — double + * underscores keep it outside the daemon's provider-id grammar, so it can + * never collide with a real kind. */ +const CUSTOM_KIND = "__custom__"; + +/** Human labels for the daemon's closed api_flavor enum (ADR 0238). */ +const FLAVOR_LABELS: Record = { + "openai-responses": "OpenAI Responses", + "openai-chat-completions": "OpenAI Chat Completions", + "anthropic-messages": "Anthropic Messages", +}; + +/** + * Guided provider add, with deliberately NO key input anywhere (Studio rule + * 3: credentials never cross the browser/controller boundary): pick a kind, + * copy the exact snippet — `` placeholder and all — into the file + * on the daemon's machine, then Re-check reads the inventory back through + * the controller and offers the restart that makes mecated see it. + * + * Two flows share that shape: + * - a BUILT-IN kind copies one auth.yaml block; + * - "Custom gateway" (ADR 0238: an operator-defined provider) collects the + * NON-secret definition — id, API flavor, base URL, default model, auth + * method — and emits the settings `providers:` block plus, for api_key + * auth, the auth.yaml key block. The id/URL/model fields hold identifiers, + * never credentials; the key still travels only by hand into auth.yaml. + */ +export function AddProviderDialog({ + known, + configured, + authFile, + operatorSettings, + reload, + restartDaemon, + restarting, +}: { + known: KnownHarnessProvider[]; + configured: string[]; + /** The auth.yaml path on the controller's machine (from /status). */ + authFile: string; + /** True when an imported operator-settings.yaml drives the daemon — its + * providers: section, if any, wins over the user-global settings file. */ + operatorSettings: boolean; + /** Re-reads the inventory; resolves to the fresh rows. */ + reload: () => Promise; + /** Restarts the daemon so the new block takes effect. */ + restartDaemon: () => Promise; + restarting: boolean; +}) { + const [open, setOpen] = useState(false); + const [kind, setKind] = useState(""); + const [copied, setCopied] = useState(""); + const [checking, setChecking] = useState(false); + const [checked, setChecked] = useState<"appeared" | "missing" | null>(null); + + // The custom-gateway definition (non-secret by construction). + const [customId, setCustomId] = useState(""); + const [customFlavor, setCustomFlavor] = useState( + CUSTOM_PROVIDER_API_FLAVORS[0], + ); + const [customBaseURL, setCustomBaseURL] = useState(""); + const [customModel, setCustomModel] = useState(""); + const [customAuth, setCustomAuth] = useState<"api_key" | "none">("api_key"); + // Optional base-URL override for a BUILT-IN provider (provider_overrides). + const [overrideURL, setOverrideURL] = useState(""); + + const selected = known.find((provider) => provider.name === kind) ?? null; + const isCustom = kind === CUSTOM_KIND; + const alreadyConfigured = new Set(configured); + const path = authFile || "~/.config/mecatl/auth.yaml"; + // settings.yaml lives beside auth.yaml under the same XDG rule, so the + // guided-copy destination is derivable without another status field. + const settingsPath = path.endsWith("auth.yaml") + ? `${path.slice(0, -"auth.yaml".length)}settings.yaml` + : "~/.config/mecatl/settings.yaml"; + + const customIdValid = validCustomProviderId(customId); + const customURLValid = validCustomProviderBaseURL(customBaseURL); + const customModelValid = customModel.trim() !== ""; + const customComplete = customIdValid && customURLValid && customModelValid; + const customTaken = customIdValid && alreadyConfigured.has(customId); + const targetName = isCustom ? customId : (selected?.name ?? ""); + const targetLabel = isCustom + ? customId || "the custom provider" + : (selected?.label ?? ""); + + const settingsSnippet = customComplete + ? customProviderSettingsSnippet({ + id: customId, + baseURL: customBaseURL.trim(), + defaultModel: customModel.trim(), + apiFlavor: customFlavor, + authMethod: customAuth, + }) + : ""; + const authSnippet = customComplete ? customProviderAuthSnippet(customId) : ""; + const overrideSnippet = selected + ? providerOverrideSnippet(selected.name, overrideURL) + : ""; + + function handleOpenChange(next: boolean) { + setOpen(next); + if (next) { + setKind(""); + setCopied(""); + setChecked(null); + setCustomId(""); + setCustomFlavor(CUSTOM_PROVIDER_API_FLAVORS[0]); + setCustomBaseURL(""); + setCustomModel(""); + setCustomAuth("api_key"); + } + } + + async function copyText(which: string, text: string) { + try { + await navigator.clipboard.writeText(text); + setCopied(which); + setTimeout(() => setCopied(""), 2_000); + } catch { + // Clipboard unavailable — the block is selectable text either way. + } + } + + async function recheck() { + if (!targetName) return; + setChecking(true); + try { + const rows = await reload(); + setChecked( + rows.some((row) => row.name === targetName) ? "appeared" : "missing", + ); + } finally { + setChecking(false); + } + } + + const showSteps = selected !== null || (isCustom && customComplete); + + return ( + <> + + + + + Add a provider + + Studio never handles API keys — you add the key to the + daemon’s own config file in three quick steps. + + +
+
+ + {/* Controlled for its whole lifetime ("" shows the + placeholder) — `kind || undefined` would flip it from + uncontrolled to controlled on first pick, which Radix + rightly warns about. */} + +
+ + {isCustom && ( +
+

+ Describe the gateway + + The definition is not a secret — it names the endpoint and + wire protocol. Any API key is still added by hand, in the + next step. + +

+
+ + { + setCustomId(event.target.value.trim()); + setChecked(null); + }} + /> + {customId !== "" && !customIdValid && ( +

+ Lower-case letters, digits, and hyphens (start with a + letter, max 63 characters); built-in names like + “openai” are reserved. +

+ )} + {customTaken && ( +

+ A provider named “{customId}” is already + configured. +

+ )} +
+
+ + +
+
+ + { + setCustomBaseURL(event.target.value); + setChecked(null); + }} + /> + {customBaseURL !== "" && !customURLValid && ( +

+ An HTTPS URL without credentials, query, or fragment. +

+ )} +
+
+ + { + setCustomModel(event.target.value); + setChecked(null); + }} + /> +

+ Required by the daemon — the model used when a session does + not pick one. +

+
+
+ + +
+
+ )} + + {isCustom && customComplete && !customTaken && ( + <> +
+

+ 2. Add this to the operator settings + + Paste the block into{" "} + {settingsPath} (merge + it under an existing{" "} + providers: key if one + exists). + {operatorSettings && + " You are running with an imported operator settings file — if it already defines a providers: section, add the entry THERE instead: that file wins the whole section."} + +

+ void copyText("settings", settingsSnippet)} + /> +
+ {customAuth === "api_key" && ( +
+

+ 3. Add the key to the credentials file + + Paste into {path}{" "} + under its providers:{" "} + key, and swap{" "} + <YOUR_KEY> for + your real key. + +

+ void copyText("auth", authSnippet)} + /> +
+ )} + + )} + + {selected && ( +
+

+ 2. Add this to the config file + + {selected.note} Paste the snippet into{" "} + {path} under its{" "} + providers: key, and swap{" "} + <YOUR_KEY> for your + real key. + +

+ void copyText("known", selected.snippet)} + /> + {/* provider_overrides (ADR 0238): route this built-in through + a gateway/proxy without redefining it — optional, and a + settings.yaml (operator-tier) block, unlike the key. */} +
+ + setOverrideURL(event.target.value)} + placeholder="https://gateway.example/v1" + autoComplete="off" + spellCheck={false} + /> + {overrideSnippet && ( + <> +

+ Add to {settingsPath} + {operatorSettings && + " — or the imported operator settings file, which wins the whole section"} + : +

+ + void copyText("override", overrideSnippet) + } + /> + + )} +
+
+ )} + + {showSteps && + !customTaken && + (checked === "appeared" ? ( +

+ {targetLabel} found ✓ — + restart the daemon to start using it. In-flight runs end with + the restart. +

+ ) : ( +

+ {isCustom && customAuth === "api_key" ? "4" : "3"}. Save the + file{isCustom && customAuth === "api_key" ? "s" : ""}, then + Re-check + {checked === "missing" && ( + + Not found yet — make sure the file is saved on the + daemon’s machine, then try again. + + )} +

+ ))} +
+ + + {showSteps && !customTaken && checked !== "appeared" && ( + + )} + {showSteps && checked === "appeared" && ( + + )} + +
+
+ + ); +} + +/** One copyable, read-only snippet block (shared by both flows). */ +function CopyableSnippet({ + text, + copied, + onCopy, +}: { + text: string; + copied: boolean; + onCopy: () => void; +}) { + return ( +
+
+        {text}
+      
+ +
+ ); +} diff --git a/studio/src/app/workspace/settings/_components/gateway-section.tsx b/studio/src/app/workspace/settings/_components/gateway-section.tsx new file mode 100644 index 0000000000..9bd12b65a0 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/gateway-section.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { useHarnessRuntime } from "@/features/agent/hooks/use-harness-runtime"; +import { + ExternalManagedNote, + OfflineNote, + SettingsCard, + SettingsRow, +} from "./settings-card"; + +type Runtime = ReturnType; + +/** A suggestion only — the field is fully editable. */ +const SUGGESTED_GATEWAY_URL = "https://connector-gateway.stacklok.dev/gw/mcp"; + +/** + * Connects an MCP gateway by name and URL. The URL is validated by the + * controller (HTTPS only, no credentials in the URL), and its errors are + * surfaced verbatim through the shared runtime error. Both connect paths + * restart the daemon; a failed handshake rolls the previous gateway back. + */ +export function GatewaySection({ runtime }: { runtime: Runtime }) { + const [name, setName] = useState(""); + const [url, setUrl] = useState(SUGGESTED_GATEWAY_URL); + + const gateway = runtime.status?.gateway ?? null; + const busy = runtime.busy === "gateway"; + const ready = Boolean(name.trim() && url.trim()); + + const startOAuth = () => { + if (!ready || busy) return; + // Opened synchronously: a popup created after an await is blocked. + const popup = window.open( + "about:blank", + "mecatl-gateway-oauth", + "width=520,height=680", + ); + if (!popup) return; + void runtime.connectGatewayOAuth(name.trim(), url.trim(), { + setUrl: (target) => { + popup.location.href = target; + }, + isClosed: () => popup.closed, + }); + }; + + const connectedRow = gateway ? ( + +
+

{gateway.name}

+ + {gateway.url} + +
+
+ ) : null; + + return ( + + {!runtime.live ? ( + + ) : runtime.mode === "external" ? ( +
+ {connectedRow && ( +
{connectedRow}
+ )} + +
+ ) : ( + // A plain stacked form (labels above full-width fields, no dividers) + // — this card is one configure-then-connect action, not a row list. +
+ {connectedRow} +
+ + setName(event.target.value)} + placeholder="connector-gateway" + className="font-mono" + /> +
+
+ + setUrl(event.target.value)} + placeholder={SUGGESTED_GATEWAY_URL} + className="font-mono" + /> +
+ +
+ )} +
+ ); +} diff --git a/studio/src/app/workspace/settings/_components/model-router-section.test.ts b/studio/src/app/workspace/settings/_components/model-router-section.test.ts new file mode 100644 index 0000000000..7fef1b12e3 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/model-router-section.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { categoryProblem } from "./model-router-section"; + +/** + * Pins the category dialog's validation gate: the rules mirror the + * controller's own (name grammar, description bound, model required, + * no duplicate names), so a bad category can never be saved into the + * routing draft in the first place. + */ + +const valid = { + name: "routine", + description: "Mechanical edits, quick lookups, formatting.", + model: "openai/gpt-test", +}; +const noOthers = new Set(); + +describe("categoryProblem", () => { + it("accepts a complete category", () => { + expect(categoryProblem(valid, noOthers)).toBeNull(); + }); + + it("requires a name", () => { + expect(categoryProblem({ ...valid, name: " " }, noOthers)).toMatch( + /name/i, + ); + }); + + it("enforces the name grammar", () => { + for (const bad of ["9routine", "has space", "dot.name", "-lead"]) { + expect(categoryProblem({ ...valid, name: bad }, noOthers)).toMatch( + /start with a letter/i, + ); + } + }); + + it("compares names case-insensitively (uppercase input is normalized, not rejected)", () => { + expect(categoryProblem({ ...valid, name: "Routine" }, noOthers)).toBeNull(); + }); + + it("caps the name at 40 characters", () => { + expect( + categoryProblem({ ...valid, name: `a${"b".repeat(39)}` }, noOthers), + ).toBeNull(); + expect( + categoryProblem({ ...valid, name: `a${"b".repeat(40)}` }, noOthers), + ).toMatch(/start with a letter/i); + }); + + it("rejects a name another category already holds, whatever the casing", () => { + const taken = new Set(["routine"]); + expect(categoryProblem({ ...valid, name: " Routine " }, taken)).toMatch( + /already named/i, + ); + // The caller excludes the edited category's own name, so an unchanged + // edit stays valid — pinned by the empty-set case. + expect(categoryProblem(valid, noOthers)).toBeNull(); + }); + + it("requires a description and bounds it at 300 characters", () => { + expect(categoryProblem({ ...valid, description: " " }, noOthers)).toMatch( + /describe what belongs/i, + ); + expect( + categoryProblem({ ...valid, description: "x".repeat(300) }, noOthers), + ).toBeNull(); + expect( + categoryProblem({ ...valid, description: "x".repeat(301) }, noOthers), + ).toMatch(/at most 300/i); + }); + + it("requires a model", () => { + expect(categoryProblem({ ...valid, model: " " }, noOthers)).toMatch( + /choose a model/i, + ); + }); +}); diff --git a/studio/src/app/workspace/settings/_components/model-router-section.tsx b/studio/src/app/workspace/settings/_components/model-router-section.tsx new file mode 100644 index 0000000000..1b95ebb6ec --- /dev/null +++ b/studio/src/app/workspace/settings/_components/model-router-section.tsx @@ -0,0 +1,637 @@ +"use client"; + +import { Ellipsis, Split } from "lucide-react"; +import { useId, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import type { useHarnessRuntime } from "@/features/agent/hooks/use-harness-runtime"; +import { useDisabledModels } from "@/lib/model-preferences"; +import { + ExternalManagedNote, + Note, + OfflineNote, + SettingsCard, + SettingsRow, +} from "./settings-card"; + +type Runtime = ReturnType; + +interface DraftCategory { + /** Stable react key — tier names are editable, so they cannot key the list. */ + key: string; + name: string; + description: string; + model: string; +} + +/** The controller's category-name grammar: a letter, then up to 39 more + * letters/digits/dashes/underscores (compared case-insensitively). */ +const categoryNamePattern = /^[a-z][a-z0-9_-]{0,39}$/; +const maxCategoryDescription = 300; + +/** Names compare case-insensitively everywhere (grammar, duplicates, the + * default-category match), so one normalization is shared by all of them. */ +const normalizeName = (name: string) => name.trim().toLowerCase(); + +/** + * Per-category validation for the add/edit dialog, mirroring the controller's + * own rules so a bad category can never be saved INTO the draft — which keeps + * the outer form message about the things only the whole draft knows (the + * 2–8 count, the classifier, the default). `otherNames` holds the normalized + * names of every OTHER category, so an edit never collides with itself. + * Exported for its vitest. + */ +export function categoryProblem( + category: { name: string; description: string; model: string }, + otherNames: ReadonlySet, +): string | null { + const name = normalizeName(category.name); + if (!name) return "Give the category a name."; + if (!categoryNamePattern.test(name)) { + return "Names start with a letter, then letters, numbers, dashes or underscores."; + } + if (otherNames.has(name)) { + return `Another category is already named "${name}".`; + } + if (!category.description.trim()) { + return "Describe what belongs here — it is what the classifier matches on."; + } + if (category.description.length > maxCategoryDescription) { + return `Keep the description at most ${maxCategoryDescription} characters.`; + } + if (!category.model.trim()) return "Choose a model for this category."; + return null; +} + +/** + * Whole-draft validation, mirroring the controller's own rules so a mistake + * surfaces here rather than after a daemon restart has already been + * attempted. The dialog gate above means the per-category arm is defensive — + * in practice this reports the count, the classifier, or a missing default. + */ +function routingProblem(draft: { + classifierModel: string; + defaultCategory: string; + categories: DraftCategory[]; +}): string | null { + if (!draft.classifierModel.trim()) return "Choose a classifier model."; + if (draft.categories.length < 2 || draft.categories.length > 8) { + return "Routing needs between 2 and 8 categories."; + } + const seen = new Set(); + for (const category of draft.categories) { + const problem = categoryProblem(category, seen); + if (problem) { + return `Category "${normalizeName(category.name) || "(unnamed)"}": ${problem}`; + } + seen.add(normalizeName(category.name)); + } + if (!seen.has(normalizeName(draft.defaultCategory))) { + return "Pick a default category from a category's menu."; + } + return null; +} + +function ModelSelect({ + id, + value, + onChange, + models, + placeholder, +}: { + id: string; + value: string; + onChange: (next: string) => void; + models: { id: string; displayName: string }[]; + placeholder: string; +}) { + // A saved model can fall out of the inventory (provider changed since the + // save); keep it selectable so opening the form doesn't silently blank it. + const options = + !value || models.some((model) => model.id === value) + ? models + : [{ id: value, displayName: value }, ...models]; + return ( + + ); +} + +/** + * The add/edit category dialog: name + model + description with modal-local + * state, validated by `categoryProblem` before it may write back into the + * draft. Follows the skill dialogs' shell (left-aligned header, pill footer + * buttons, full-screen under 500px). + */ +function CategoryDialog({ + initial, + models, + takenNames, + onSave, + onClose, +}: { + /** The category being edited, or null when adding a new one. */ + initial: { name: string; description: string; model: string } | null; + models: { id: string; displayName: string }[]; + /** Normalized names of every OTHER category in the draft. */ + takenNames: ReadonlySet; + onSave: (next: { name: string; description: string; model: string }) => void; + onClose: () => void; +}) { + const fieldId = useId(); + const [name, setName] = useState(initial?.name ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + const [model, setModel] = useState(initial?.model ?? ""); + + // Validation is quiet: an incomplete category just keeps Save disabled. + const problem = categoryProblem({ name, description, model }, takenNames); + + return ( + !open && onClose()}> + + + + {initial ? "Edit category" : "Add category"} + + + +
+
+ + setName(event.target.value)} + /> +
+
+ + +
+
+ +