diff --git a/packages/webui/server/lib/providers-config.js b/packages/webui/server/lib/providers-config.js index ae7bfacc..484d986f 100644 --- a/packages/webui/server/lib/providers-config.js +++ b/packages/webui/server/lib/providers-config.js @@ -658,4 +658,120 @@ export function _extractPlaintextKey(provider) { return provider && provider.auth && typeof provider.auth.apiKey === "string" ? provider.auth.apiKey : ""; +} + +// ===================================================================== +// Keep-existing-key convention (ticket 03 cross-branch API note) +// ===================================================================== +// +// Background: GET /api/providers returns `apiKeyMasked` (e.g. "sk-aa***bb") +// rather than the plaintext, so a UI that PUTs back what it has on screen +// would send the masked value as the new apiKey — the plaintext would be +// lost on every edit. There is no "unchanged" semantics in the v2 PUT +// contract (ticket 01 deliberately kept the body a full replacement so +// the validation/normalisation path is simple), so the management UI +// ships a convention on top: +// +// * incoming `auth.apiKey` is the empty string OR is absent +// (`undefined`) — both are interpreted as "do not change the +// existing key for this provider id". The handler copies the +// existing key onto the incoming record before +// validation/normalisation. Treating the absent-field case the +// same as empty is intentional: the v2 normaliser coerces a +// missing `auth.apiKey` to `""` anyway, and silently writing +// `""` to disk would wipe a credential the operator never +// intended to change (the field's HTML placeholder carries the +// masked value, not the controlled value, so a UI that drops the +// field is the normal "no change" gesture). +// +// * anything non-empty — including the masked placeholder — is +// treated as the new value. The UI must therefore blank the field +// when the user does not want to overwrite it (the editor renders +// the masked placeholder as the input's placeholder, not its +// value). +// +// * DELIBERATE KEY CLEARING IS NOT POSSIBLE. There is no wire shape +// that results in a stored key becoming empty once one has been +// written. Operators who need to rotate a credential PUT a new +// value; the convention preserves the previous key only when the +// incoming record signals "no change". The documentation +// (`webapp/lib/i18n.ts` "API key" placeholder) carries the same +// caveat in user-visible form. +// +// Layer scope: the convention reads `loadUserLevelProviders()` +// (user-level file only — NOT the merged result), so the env / cwd +// layers' secrets are never materialised into the user-level file +// when an operator edits an env-defined provider. The merged view +// still wins for the engine — `loadProvidersConfig()` keeps the env +// priority — so the operator's edit does not "pin" an env secret to +// disk by accident. +// +// Cross-branch API note: the convention lives on this branch +// (ticket 03) because ticket 01's PUT contract was already merged +// without it. The convention is opt-in — a UI that always sends the +// plaintext only sees normal replacement behaviour. The PUT handler +// is the only place this helper runs, so the rest of the validation +// surface is unchanged. + +/** + * Read JUST the user-level providers file, without the env/cwd merge. + * + * Used by `applyKeepKeyConvention` so the convention's "previous key" + * lookup is scoped to the layer the operator owns — the env/cwd + * layers are deployment-managed, and silently copying one of THEIR + * keys into the user-level file would materialise a deployment + * secret onto operator-managed disk (where the convention can no + * longer rotate it). Returns `[]` when the file is missing or + * malformed, matching the `loadProvidersConfig()` contract. + */ +export function loadUserLevelProviders() { + const userPath = getUserLevelPath(); + const parsed = safeReadJson(userPath); + if (!parsed) return []; + const norm = normaliseConfig(parsed); + return norm && Array.isArray(norm.providers) ? norm.providers : []; +} + +/** + * Apply the keep-existing-key convention. + * + * For every incoming provider whose `auth.apiKey` is empty OR absent + * (the sentinel): + * - if a same-id provider exists in `existing` with a non-empty + * `auth.apiKey`, copy it onto the incoming record; + * - if no such existing provider exists (the incoming record is + * brand new), the empty stays empty and the normal validation + * flow rejects it for `byok` (which is the right behaviour: a + * new byok provider with no key cannot pass a test probe). + * + * The function is pure (no IO). Returns a NEW array — `incoming` is + * not mutated, so the original body still exists for error reporting + * if the caller wants to surface it. + */ +export function applyKeepKeyConvention(existing, incoming) { + const existingById = new Map(); + for (const p of existing || []) { + if (p && p.id) existingById.set(p.id, p); + } + return (incoming || []).map((p) => { + if (!p || typeof p !== "object") return p; + const auth = p.auth && typeof p.auth === "object" ? p.auth : {}; + // The sentinel: empty string (explicit "I typed nothing") OR + // absent (the field was never sent). Both mean "do not change + // the existing key". A non-string apiKey (number, boolean) is + // left untouched — those are upstream mistakes that the normaliser + // will surface as a type mismatch on its own. + const apiKeyIsSentinel = + typeof auth.apiKey === "undefined" || auth.apiKey === ""; + if (!apiKeyIsSentinel) return p; + const previous = existingById.get(p.id); + const previousKey = + previous && previous.auth && typeof previous.auth.apiKey === "string" + ? previous.auth.apiKey + : ""; + return { + ...p, + auth: { ...auth, apiKey: previousKey }, + }; + }); } \ No newline at end of file diff --git a/packages/webui/server/routes/providers.js b/packages/webui/server/routes/providers.js index 19f3ee39..5c887727 100644 --- a/packages/webui/server/routes/providers.js +++ b/packages/webui/server/routes/providers.js @@ -55,6 +55,8 @@ import { writeProvidersConfig, testProvider as runProbe, getUserLevelPath, + applyKeepKeyConvention, + loadUserLevelProviders, normaliseProvider, } from "../lib/providers-config.js"; import { @@ -122,7 +124,36 @@ export async function handlePutProviders(req, res, _ctx) { JSON.stringify({ ok: false, code: "BAD_BODY", error: "body must be a JSON object" }), ); } - const result = writeProvidersConfig(parsed); + // Keep-existing-key convention (ticket 03 cross-branch API note): +// `auth.apiKey` empty OR absent on an incoming provider means "don't +// change the existing key". We copy the user-level file's apiKey +// onto those records before validation, so the masked placeholder +// the UI sends back (and an absent-field body) does not silently +// wipe the plaintext on every edit. See +// lib/providers-config.js#applyKeepKeyConvention. +// +// Layer scope: the "previous key" lookup reads the user-level file +// ONLY (`loadUserLevelProviders`), not the merged catalogue. Without +// this scoping, editing a provider whose key is sourced from the env +// or cwd layer would materialise the deployment secret into the +// user-level file — once written there, the deployment layer can no +// longer rotate it. The merged view still wins for the engine +// (`loadProvidersConfig` priority order), so the visible behaviour +// for the operator is unchanged: an env-defined key still wins at +// read time even after the user edits the provider. +// +// Only applied when `parsed.providers` is actually an array — a missing +// or non-array providers list is an error the original validation +// surfaces as BAD_BODY, and we must not change that behaviour. +const incomingProviders = Array.isArray(parsed.providers) ? parsed.providers : null; +const toWrite = + incomingProviders === null + ? parsed + : { + ...parsed, + providers: applyKeepKeyConvention(loadUserLevelProviders(), incomingProviders), + }; +const result = writeProvidersConfig(toWrite); if (!result.ok) { const status = result.code === "WRITE_FAILED" ? 500 : 400; res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); diff --git a/packages/webui/test/lib/providers-config.test.js b/packages/webui/test/lib/providers-config.test.js index fdc5c891..7b35e431 100644 --- a/packages/webui/test/lib/providers-config.test.js +++ b/packages/webui/test/lib/providers-config.test.js @@ -773,4 +773,228 @@ describe("testProvider — no network for malformed inputs", () => { assert.equal(r.ok, false); assert.equal(r.code, "PROBE_FAILED", "validation passed → fetch attempted → probe failed"); }); +}); + +// --------------------------------------------------------------------- +// applyKeepKeyConvention — ticket 03 keep-existing-key convention. +// --------------------------------------------------------------------- +// +// The UI GETs the masked catalogue (apiKey is `apiKeyMasked: "sk-aa***bb"`), +// then PUTs the same shape back. Without a convention, the masked +// placeholder would replace the plaintext on every edit. The +// convention: an incoming `auth.apiKey === ""` means "do not change the +// existing key for this provider id". The PUT handler is the only caller. + +describe("applyKeepKeyConvention — ticket 03 keep-existing-key convention", () => { + test("incoming apiKey='' copies the existing key when one is on disk", () => { + const existing = [ + { + id: "p1", + label: "P1", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-on-disk-aaaa" }, + models: [], + }, + ]; + const incoming = [ + { + id: "p1", + label: "P1 renamed", + protocol: "openai", + auth: { type: "byok", apiKey: "" }, // sentinel + models: [{ id: "m" }], + }, + ]; + const merged = providersConfig.applyKeepKeyConvention(existing, incoming); + assert.equal(merged.length, 1); + assert.equal(merged[0].auth.apiKey, "sk-realkey-on-disk-aaaa"); + // Other fields untouched by the convention. + assert.equal(merged[0].label, "P1 renamed"); + assert.deepEqual(merged[0].models, [{ id: "m" }]); + }); + + test("incoming apiKey non-empty is NOT replaced (the user is overwriting)", () => { + const existing = [ + { id: "p1", auth: { type: "byok", apiKey: "sk-on-disk" } }, + ]; + const incoming = [ + { id: "p1", auth: { type: "byok", apiKey: "sk-new-plaintext" } }, + ]; + const merged = providersConfig.applyKeepKeyConvention(existing, incoming); + assert.equal(merged[0].auth.apiKey, "sk-new-plaintext"); + }); + + test("incoming apiKey='' with NO existing record keeps empty (new provider fails byok validation)", () => { + // The provider is brand new — there is nothing to keep. The empty + // key stays, and the normal validation rejects a `byok` record + // with an empty apiKey. This is the right behaviour: a UI that + // hits Save without filling the key must not silently inherit + // some other provider's credential. + const merged = providersConfig.applyKeepKeyConvention( + [{ id: "other", auth: { type: "byok", apiKey: "sk-something" } }], + [{ id: "brand-new", auth: { type: "byok", apiKey: "" } }], + ); + assert.equal(merged[0].id, "brand-new"); + assert.equal(merged[0].auth.apiKey, ""); + }); + + test("absent auth.apiKey on an incoming record copies the user-level key", () => { + // The convention treats BOTH `auth.apiKey === ""` AND a missing + // `auth.apiKey` field as the sentinel. A PUT that drops the key + // field is the normal "no change" gesture from the editor form + // (which only ever sets the controlled value when the user types). + // Without this, the API would silently wipe a stored credential + // whenever a caller forgot to send the field — the v2 normaliser + // coerces a missing apiKey to "" anyway, so the on-disk write + // would land as empty. Pinning the behaviour here so a future + // "absent !== empty" change does not regress to the silent wipe. + const merged = providersConfig.applyKeepKeyConvention( + [{ id: "p1", auth: { type: "byok", apiKey: "sk-disk" } }], + [{ id: "p1", auth: { type: "byok" } }], + ); + assert.equal(merged[0].auth.apiKey, "sk-disk", "absent key inherits from user-level"); + }); + + test("absent auth.apiKey with NO existing record keeps the empty key", () => { + // Brand-new provider with no key — the empty stays empty, and + // the normal validation flow rejects a `byok` record with an + // empty key. Coding-plan records (which allow empty keys) pass + // through unchanged. + const merged = providersConfig.applyKeepKeyConvention( + [{ id: "other", auth: { type: "byok", apiKey: "sk-something" } }], + [{ id: "brand-new", auth: { type: "byok" } }], + ); + assert.equal(merged[0].id, "brand-new"); + assert.equal(merged[0].auth.apiKey, ""); + }); + + test("returns a new array — the incoming body is not mutated", () => { + const incoming = [ + { id: "p1", auth: { type: "byok", apiKey: "" } }, + ]; + const merged = providersConfig.applyKeepKeyConvention( + [{ id: "p1", auth: { type: "byok", apiKey: "sk-disk" } }], + incoming, + ); + assert.notEqual(merged, incoming, "new array"); + assert.equal(incoming[0].auth.apiKey, "", "incoming untouched"); + }); + + test("a provider with no matching id in existing keeps its empty key", () => { + const merged = providersConfig.applyKeepKeyConvention( + [{ id: "other", auth: { type: "byok", apiKey: "sk-disk" } }], + [{ id: "brand-new", auth: { type: "byok", apiKey: "" } }], + ); + assert.equal(merged[0].auth.apiKey, ""); + }); + + test("absent auth.apiKey on existing record: copies the user-layer key", () => { + // Acceptance hardening (ticket 03 round 2): a PUT whose body + // omits the `auth.apiKey` field used to fall through validation + // and silently land on disk as "" — wiping the stored key. The + // convention now treats absent the same as empty. + const merged = providersConfig.applyKeepKeyConvention( + [{ id: "p1", auth: { type: "byok", apiKey: "sk-on-disk-aaaa" } }], + [{ id: "p1", auth: { type: "byok" } }], + ); + assert.equal(merged[0].auth.apiKey, "sk-on-disk-aaaa"); + }); + + test("absent auth.apiKey with auth itself omitted: still copies the user-layer key", () => { + // Defensive: the helper must not throw when `auth` is entirely + // absent from the incoming record. A bug here would 500 the + // entire PUT handler on a malformed body. + const merged = providersConfig.applyKeepKeyConvention( + [{ id: "p1", auth: { type: "byok", apiKey: "sk-on-disk" } }], + [{ id: "p1", protocol: "openai", models: [] }], + ); + assert.equal(merged[0].auth.apiKey, "sk-on-disk"); + }); + + test("deliberate key clearing is impossible: no input shape wipes a stored key", () => { + // The contract: once a key is on disk, no wire shape can clear + // it. The convention copies the previous key onto EVERY incoming + // shape that lacks an explicit value ("" or absent). A non-empty + // value replaces — there is no API call that means "delete the + // stored key and accept the consequence". Operators who need to + // rotate put a new key; the convention preserves only on "no + // change" gestures. + const existing = [{ id: "p1", auth: { type: "byok", apiKey: "sk-stored" } }]; + const shapes = [ + // explicit empty + { id: "p1", auth: { type: "byok", apiKey: "" } }, + // absent field + { id: "p1", auth: { type: "byok" } }, + // absent auth entirely + { id: "p1", protocol: "openai" }, + ]; + for (const shape of shapes) { + const merged = providersConfig.applyKeepKeyConvention(existing, [shape]); + assert.equal( + merged[0].auth.apiKey, + "sk-stored", + `shape ${JSON.stringify(shape.auth)} should keep existing key`, + ); + } + }); +}); + +// --------------------------------------------------------------------- +// loadUserLevelProviders — user-layer-only loader (ticket 03 round 2). +// --------------------------------------------------------------------- +// +// The convention reads THIS, not the merged `loadProvidersConfig()` +// result, so editing an env-defined provider does not materialise +// the deployment secret onto the operator-managed user-level file. +// The merged view still wins for the engine — only the on-disk +// write is scoped to the user layer. + +describe("loadUserLevelProviders — user-layer-only loader", () => { + test("returns [] when the user-level file is missing", () => { + // beforeEach cleared it. Sanity check on the contract. + assert.deepEqual(providersConfig.loadUserLevelProviders(), []); + }); + + test("returns only the user-level records (not env/cwd)", async () => { + // Seed the user-level file. The cwd layer is empty in this test + // because MCODE_WEBUI_MODELS_CONFIG is unset, so what we seed + // IS the only thing loadUserLevelProviders can see — and it + // must NOT include any merged material from other layers. + writeFileSync( + providersConfig.getUserLevelPath(), + JSON.stringify({ + version: 2, + providers: [ + { id: "u1", auth: { type: "byok", apiKey: "sk-user-only-aaaa" } }, + ], + }), + ); + const result = providersConfig.loadUserLevelProviders(); + assert.equal(result.length, 1); + assert.equal(result[0].id, "u1"); + assert.equal(result[0].auth.apiKey, "sk-user-only-aaaa"); + }); + + test("env-layer key is NOT visible to loadUserLevelProviders", () => { + // Set an env-layer file with a key. The user-level file is + // empty (cleared by beforeEach). loadUserLevelProviders must + // return [] — the env secret does not leak into the user-layer + // loader. The convention then has nothing to copy. + const envPath = join(_tmpCwd, "env-only.json"); + writeFileSync( + envPath, + JSON.stringify({ + providers: [ + { id: "envprov", auth: { type: "byok", apiKey: "sk-env-only-aaaa" } }, + ], + }), + ); + process.env.MCODE_WEBUI_MODELS_CONFIG = envPath; + try { + const result = providersConfig.loadUserLevelProviders(); + assert.deepEqual(result, [], "env-only secrets must not be visible here"); + } finally { + delete process.env.MCODE_WEBUI_MODELS_CONFIG; + } + }); }); \ No newline at end of file diff --git a/packages/webui/test/routes/providers.check.mjs b/packages/webui/test/routes/providers.check.mjs index 65997db4..31324640 100644 --- a/packages/webui/test/routes/providers.check.mjs +++ b/packages/webui/test/routes/providers.check.mjs @@ -299,6 +299,202 @@ describe("handlePutProviders — /api/providers PUT", () => { assert.ok(found, "newprov visible after PUT"); assert.equal(found.models.length, 1); }); + + test("keep-existing-key: empty apiKey in PUT preserves the key on disk", async () => { + // Seed: write a provider with a plaintext key. + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "kp", + label: "KP", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-original-plaintext-aaaa" }, + models: [], + }, + ], + }), + fakeRes(), + {}, + ); + // Edit: PUT the same provider back with apiKey === "" (the + // sentinel). Without the convention the plaintext would be wiped; + // with it, the on-disk key is preserved. + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "kp", + label: "KP renamed", + protocol: "openai", + auth: { type: "byok", apiKey: "" }, + models: [{ id: "m" }], + }, + ], + }), + fakeRes(), + {}, + ); + const onDisk = JSON.parse( + readFileSync(providersConfig.getUserLevelPath(), "utf8"), + ); + const kp = onDisk.providers.find((p) => p.id === "kp"); + assert.equal(kp.auth.apiKey, "sk-original-plaintext-aaaa"); + assert.equal(kp.label, "KP renamed"); + assert.equal(kp.models.length, 1); + }); + + test("keep-existing-key: non-empty apiKey in PUT replaces the key", async () => { + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "kp2", + label: "KP2", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-old-plaintext-aaaa" }, + models: [], + }, + ], + }), + fakeRes(), + {}, + ); + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "kp2", + label: "KP2", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-new-plaintext-bbbb" }, + models: [], + }, + ], + }), + fakeRes(), + {}, + ); + const onDisk = JSON.parse( + readFileSync(providersConfig.getUserLevelPath(), "utf8"), + ); + const kp = onDisk.providers.find((p) => p.id === "kp2"); + assert.equal(kp.auth.apiKey, "sk-new-plaintext-bbbb"); + }); + + test("keep-existing-key: ABSENT auth.apiKey preserves the stored key", async () => { + // Acceptance hardening (ticket 03 round 2): the PUT body drops + // the `auth.apiKey` field entirely. Without the convention's + // absent-field branch, the v2 normaliser would coerce undefined + // to "" and write the file as empty — silently wiping a stored + // credential. The route must keep the previous key. + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "abs", + label: "Absent", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-on-disk-original-aaaa" }, + models: [], + }, + ], + }), + fakeRes(), + {}, + ); + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "abs", + label: "Absent renamed", + protocol: "openai", + auth: { type: "byok" }, // apiKey field is gone + models: [{ id: "m1" }], + }, + ], + }), + fakeRes(), + {}, + ); + const onDisk = JSON.parse( + readFileSync(providersConfig.getUserLevelPath(), "utf8"), + ); + const row = onDisk.providers.find((p) => p.id === "abs"); + assert.equal(row.auth.apiKey, "sk-on-disk-original-aaaa"); + assert.equal(row.label, "Absent renamed"); + assert.equal(row.models.length, 1, "model row is NOT dropped"); + }); + + test("keep-existing-key: env-layer key is NOT materialised to user-level", async () => { + // The convention's "previous key" lookup reads the user-level + // file only — NOT the merged catalogue. Without this scoping, + // editing an env-defined provider would copy the deployment + // secret into the user-level file, materialising it onto disk + // that the operator owns. Pinning here so a future refactor that + // swaps to `loadProvidersConfig().providers` regresses loudly. + const envPath = join(_tmpCwd, "env-only.json"); + writeFileSync( + envPath, + JSON.stringify({ + providers: [ + { + id: "envprov", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-env-secret-only-aaaa" }, + models: [], + }, + ], + }), + ); + process.env.MCODE_WEBUI_MODELS_CONFIG = envPath; + try { + // Simulate a PUT body that targets the env-defined provider + // with the empty sentinel. Without the user-layer scoping, the + // convention would copy the env secret into the user file. + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "envprov", + label: "Env Provider (edited)", + protocol: "openai", + auth: { type: "byok", apiKey: "" }, + models: [{ id: "m1" }], + }, + ], + }), + fakeRes(), + {}, + ); + const onDisk = JSON.parse( + readFileSync(providersConfig.getUserLevelPath(), "utf8"), + ); + const row = onDisk.providers.find((p) => p.id === "envprov"); + // The user-level record MUST NOT carry the env secret. The + // env secret is deployment-managed and stays at the env layer. + assert.equal( + row.auth.apiKey, + "", + "env-layer key must not leak into the user-level file", + ); + // Other fields ARE written (label / models) — the operator + // edits land in user-level as expected; only the key stays + // at the env layer. + assert.equal(row.label, "Env Provider (edited)"); + assert.equal(row.models.length, 1); + } finally { + delete process.env.MCODE_WEBUI_MODELS_CONFIG; + } + }); }); // ===================================================================== diff --git a/packages/webui/webapp/components/composer.tsx b/packages/webui/webapp/components/composer.tsx index 67d6f54b..eb0c7493 100644 --- a/packages/webui/webapp/components/composer.tsx +++ b/packages/webui/webapp/components/composer.tsx @@ -94,7 +94,7 @@ const PERMISSION_MODES: { id: string; key: MessageKey; icon?: IconName; selectab ]; export function Composer({ t, inline = false }: { t: (key: MessageKey) => string; inline?: boolean }) { - const { state } = useSessionContext(); + const { state, providersRevision } = useSessionContext(); // Text, attachments, and the error banner live in the module-scope draft // store (lib/composer-draft.ts) rather than useState: page.tsx swaps this // component between two tree positions when the first conversation line @@ -176,7 +176,7 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string ), ) .catch(() => {}); - }, [modelKey, sessionKey]); + }, [modelKey, sessionKey, providersRevision]); /** * Slash-command completion. diff --git a/packages/webui/webapp/components/panels.tsx b/packages/webui/webapp/components/panels.tsx index a5e5e955..d5979508 100644 --- a/packages/webui/webapp/components/panels.tsx +++ b/packages/webui/webapp/components/panels.tsx @@ -19,6 +19,7 @@ import { matchFilter } from "@/lib/workspace-filter"; import type { Locale, MessageKey } from "@/lib/i18n"; import type { ThemeName } from "@/lib/types"; import { Icon } from "./icons"; +import { ProviderManagementPanel } from "./provider-management"; /** * Right-hand drawer. @@ -108,7 +109,8 @@ export function RightPanel({ type SettingsSection = | "general" | "appearance" - | "connection"; + | "connection" + | "providers"; const SETTINGS_NAV: { group: MessageKey; @@ -129,6 +131,10 @@ const SETTINGS_NAV: { group: "settings.group.management", items: [ { id: "connection", key: "settings.tab.connection", section: "connection" }, + // Provider management (ticket 03) — model providers surface lives + // in the management group, below connection, and is the only + // server-driven section the desktop "用量与模型" group also covers. + { id: "providers", key: "providers.title", section: "providers" }, { id: "account", key: "settings.tab.account" }, ], }, @@ -1430,7 +1436,7 @@ function SettingsPanel({ locale: Locale; setLocale: (locale: Locale) => void; /** Which category to render; undefined means a disabled (unsupported) one. */ - section?: "general" | "appearance" | "connection"; + section?: "general" | "appearance" | "connection" | "providers"; }) { const [snapshot, setSnapshot] = useState(null); const [busy, setBusy] = useState(false); @@ -1582,6 +1588,12 @@ function SettingsPanel({ ), + providers: ( + // The provider management panel owns its own loading / saving + // state — wrapping it in a card here keeps the section chrome + // consistent with the rest of SettingsPanel. + + ), }[section]; return ( diff --git a/packages/webui/webapp/components/provider-management.tsx b/packages/webui/webapp/components/provider-management.tsx new file mode 100644 index 00000000..03a1db55 --- /dev/null +++ b/packages/webui/webapp/components/provider-management.tsx @@ -0,0 +1,937 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Alert as AntAlert, + Input as AntInput, + Select as AntSelect, + Switch as AntSwitch, + Tag as AntTag, + Empty as AntEmpty, + Popconfirm as AntPopconfirm, +} from "antd"; + +import * as api from "@/lib/api"; +import type { MessageKey } from "@/lib/i18n"; +import { useSessionContext } from "@/lib/store"; +import { + blankAuth, + blankModel, + describeTestOutcome, + draftFromView, + draftToWire, + newDraftProvider, + validateModelRow as validateModelRowLib, + validateProviderId, + THINKING_LEVELS, + MODALITIES, + type DraftProvider, + type DraftModel, + type ProviderTestOutcome, +} from "@/lib/provider-management"; + +/** + * Provider management panel (ticket 03). + * + * Lives inside the settings modal as a fourth section ("Model providers"), + * alongside general / appearance / connection. Renders the v2 catalogue + * fetched from `/api/providers`, lets the operator edit / add / delete / + * test-connection per-provider, and PUTs the result through + * `/api/providers`. PUT triggers a `providers.updated` SSE frame that + * the store's `providersRevision` counter carries back to the composer + * — so saving without a page reload causes the model selector to show + * the new group. + * + * Key handling: + * - GET returns `apiKeyMasked` (e.g. "sk-a***b"). The form's key input + * renders the masked value as its placeholder, NOT its value — so an + * "untouched" field carries an empty string when the form PUTs. + * - The PUT route applies `applyKeepKeyConvention`: an empty apiKey + * on an incoming provider is the sentinel "keep the existing key + * for this id". A non-empty value (including the masked placeholder) + * replaces. The form therefore: + * * never writes the masked placeholder to disk, by virtue of + * leaving the field empty when the user did not touch it; + * * writes the user's typed value verbatim when they did. + * + * Test connection: + * - Per-protocol minimal probe via `/api/providers/test`. The form + * sends the LIVE form values (so the user can test before saving), + * and renders a structured result inline: success latency on + * `OK`, otherwise a translated message keyed by `code` (`INVALID_KEY`, + * `BAD_PROTOCOL`, `PROBE_FAILED`) with the upstream HTTP status / + * network error in the detail line. + * + * Preset one-click enable (degrades gracefully): + * - The contract for `GET /api/providers/presets` + `POST + * /api/providers/preset/:id/enable` is being built in parallel on + * `feat/provider-presets`. This panel fetches the catalogue on + * mount and hides the section on 404 — the rest of the panel + * stays usable without it. + */ + +const PROTOCOLS: api.ProviderProtocol[] = ["openai", "anthropic", "gemini"]; +const AUTH_TYPES: api.ProviderAuthType[] = ["byok", "coding-plan"]; + +/** Re-exported for tests. */ +export type { DraftProvider, DraftModel, ProviderTestOutcome }; + +export function ProviderManagementPanel({ + t, +}: { + t: (key: MessageKey) => string; +}) { + const { providersRevision } = useSessionContext(); + const [providers, setProviders] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [busy, setBusy] = useState(false); + const [savedAt, setSavedAt] = useState(null); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [testByProvider, setTestByProvider] = useState>({}); + + const load = useCallback(async () => { + try { + const snap = await api.listProviders(); + setProviders(snap.providers.map(draftFromView)); + setLoadError(null); + setSelectedId((current) => { + // After a save, fresh drafts have no server counterpart. + // selection falls back to the first server-side row so the + // editor stays meaningful. + if (current && current.startsWith("__new_")) return snap.providers[0]?.id ?? null; + if (current && snap.providers.some((p) => p.id === current)) return current; + return snap.providers[0]?.id ?? null; + }); + } catch (cause) { + setLoadError( + t("providers.loadError").replace( + "{{error}}", + cause instanceof Error ? cause.message : String(cause), + ), + ); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load, providersRevision]); + + // Validation summary across the whole draft, recomputed whenever + // the user touches a field. The editor surface is only enabled + // when the draft is valid; the Save button follows the same rule. + const validation = useMemo(() => { + if (!providers) return { ok: false, errors: [] as string[] }; + const errors: string[] = []; + const seen = new Set(); + for (const p of providers) { + if (p.markedForDeletion) continue; + const idErr = validateProviderId(p.id); + if (idErr) errors.push(`[${p.id || "(new)"}] ${idErr}`); + if (seen.has(p.id.trim())) errors.push(`duplicate id: ${p.id}`); + seen.add(p.id.trim()); + for (const m of p.models) { + const mErr = validateModelRowLib(m); + if (mErr) errors.push(`[${p.id} / ${m.id || "(model)"}] ${mErr}`); + } + } + return { ok: errors.length === 0, errors }; + }, [providers, t]); + + const updateSelected = useCallback( + (mutator: (draft: DraftProvider) => DraftProvider) => { + setProviders((current) => { + if (!current) return current; + return current.map((p) => + p.draftId === selectedId ? mutator(p) : p, + ); + }); + }, + [selectedId], + ); + + const addProvider = useCallback(() => { + // A new draft has a stable `draftId` (opaque, never written to + // disk) and an empty user-facing `id` until the user types. The + // selection path is keyed off `draftId` so editing the user-facing + // id does not lose the row. + const draft = newDraftProvider(); + setProviders((current) => [...(current ?? []), draft]); + setSelectedId(draft.draftId); + }, []); + + const markDeleted = useCallback((draftId: string) => { + setProviders((current) => { + if (!current) return current; + // Existing providers: soft-delete (preserve id so the row stays + // visually in place). New drafts (id starts with `__new_`): + // hard-remove, since they were never saved. + const draft = current.find((p) => p.draftId === draftId); + if (!draft) return current; + if (draft.isNew) return current.filter((p) => p.draftId !== draftId); + return current.map((p) => + p.draftId === draftId ? { ...p, markedForDeletion: true } : p, + ); + }); + }, []); + + const testSelected = useCallback(async () => { + if (!providers || !selectedId) return; + const draft = providers.find((p) => p.draftId === selectedId); + if (!draft) return; + const key = draft.draftId; + setTestByProvider((current) => ({ ...current, [key]: undefined })); + try { + const result = await api.testProviderConnection({ + protocol: draft.protocol, + auth: { + type: draft.auth.type, + apiKey: draft.auth.apiKey, + baseURL: draft.auth.baseURL || undefined, + }, + // 4s — the user is waiting; the wire default is 8s. + timeoutMs: 4000, + }); + setTestByProvider((current) => ({ + ...current, + [key]: { + ok: result.ok, + latencyMs: result.latencyMs, + code: result.code, + error: result.error, + detail: result.detail, + }, + })); + } catch (cause) { + setTestByProvider((current) => ({ + ...current, + [key]: { + ok: false, + code: "PROBE_FAILED", + error: cause instanceof Error ? cause.message : String(cause), + }, + })); + } + }, [providers, selectedId]); + + const save = useCallback(async () => { + if (!providers || !validation.ok) return; + setBusy(true); + setSaveError(null); + try { + const wire = providers + .filter((p) => !p.markedForDeletion) + .map(draftToWire); + await api.putProviders({ version: 2, providers: wire }); + setSavedAt(Date.now()); + // Refresh the local view from the server so masked placeholders + // line up with the just-saved record. + await load(); + } catch (cause) { + setSaveError( + t("providers.saveError").replace( + "{{error}}", + cause instanceof Error ? cause.message : String(cause), + ), + ); + } finally { + setBusy(false); + } + }, [providers, validation.ok, load, t]); + + if (loadError && !providers) { + return ( +

{loadError}

+ ); + } + + if (!providers) { + return

{t("app.connecting")}

; + } + + const selected = + providers.find((p) => p.draftId === selectedId) ?? + providers.find((p) => !p.markedForDeletion) ?? + null; + + return ( +
+
+ {t("providers.title")} + + {t("providers.subtitle")} + +
+ + {/* Preset one-click enable — degrades gracefully when the + sibling branch's endpoints are not yet mounted (404 → null). */} + void load()} /> + +
+ {/* Left rail — provider list. */} +
+
+ {providers.length === 0 ? ( +

+ {t("providers.empty")} +

+ ) : ( + providers.map((p) => { + const isSelected = + (selectedId && p.draftId === selectedId) || + (!selectedId && p === selected); + const test = testByProvider[p.draftId]; + return ( + + ); + }) + )} +
+ +
+ + {/* Right pane — editor. */} +
+ {selected ? ( + markDeleted(selected.id)} + onTest={() => void testSelected()} + testResult={testByProvider[selected.draftId]} + /> + ) : ( + + )} + + {validation.errors.length > 0 ? ( +
    + {validation.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+ ) : null} + +
+ + {savedAt ? ( + + {t("providers.saved")} + + ) : null} + {saveError ? ( + + {saveError} + + ) : null} +
+
+
+
+ ); +} + +// `auth.hasKey` lives on the server's view shape; the draft's auth +// shape does NOT carry it (the form carries apiKey="" as the +// sentinel). Surface "key set" only when the form knows it. + +interface ProviderEditorProps { + t: (key: MessageKey) => string; + draft: DraftProvider; + onChange: (mutator: (draft: DraftProvider) => DraftProvider) => void; + onDelete: () => void; + onTest: () => void; + testResult?: ProviderTestOutcome; +} + +function ProviderEditor({ + t, + draft, + onChange, + onDelete, + onTest, + testResult, +}: ProviderEditorProps) { + const idError = validateProviderId(draft.id); + const testDescription = testResult ? describeTestOutcome(t, testResult) : null; + + return ( +
+
+ + {draft.label || draft.id || "(new provider)"} + +
+ onChange((p) => ({ ...p, enabled: checked }))} + aria-label={draft.enabled ? t("providers.enabled") : t("providers.disabled")} + /> + + {draft.enabled ? t("providers.enabled") : t("providers.disabled")} + +
+
+ + {/* Connection section */} +
+ + {t("providers.section.connection")} + + + onChange((p) => ({ ...p, id: e.target.value }))} + className="mavis-input" + status={idError ? "error" : undefined} + /> + {idError ? ( + + {t("providers.idInvalid")} + + ) : null} + + + + onChange((p) => ({ ...p, label: e.target.value }))} + className="mavis-input" + /> + + + + + onChange((p) => ({ ...p, protocol: value as api.ProviderProtocol })) + } + className="mavis-input" + options={PROTOCOLS.map((proto) => ({ label: proto, value: proto }))} + /> + + + + + onChange((p) => ({ + ...p, + auth: { ...p.auth, type: value as api.ProviderAuthType }, + })) + } + className="mavis-input" + options={AUTH_TYPES.map((type) => ({ label: type, value: type }))} + /> + + + {draft.auth.type === "byok" ? ( + + + onChange((p) => ({ ...p, auth: { ...p.auth, apiKey: value } })) + } + /> + + ) : ( + + + onChange((p) => ({ ...p, auth: { ...p.auth, apiKey: e.target.value } })) + } + className="mavis-input" + placeholder={t("providers.field.apiKeyPlaceholder")} + /> + + )} + + + + onChange((p) => ({ ...p, auth: { ...p.auth, baseURL: e.target.value } })) + } + className="mavis-input" + placeholder={t("providers.field.baseURLHint")} + /> + + +
+ + {testDescription ? ( + + {testDescription.text} + + ) : null} +
+
+ + {/* Models section */} +
+ + {t("providers.section.models")} + + onChange((p) => ({ ...p, models }))} + /> +
+ + {/* Footer — delete. Preset rows cannot be deleted (the preset + controls live on a different branch); we hide the button in + that case. */} + {draft.preset ? null : ( +
+ + + +
+ )} +
+ ); +} + +/** + * The API-key field is the load-bearing piece of the keep-existing-key + * convention: the masked placeholder goes in `placeholder`, NEVER in + * `value`. The controlled value is `""` whenever the user did not + * touch the field — the server interprets that as "keep the existing + * key". A "reveal" toggle is intentionally absent: showing the + * plaintext defeats the masking contract; the user clears the field + * to overwrite (the masked placeholder will reappear). + */ +function ApiKeyInput({ + draft, + t, + onChange, +}: { + draft: DraftProvider; + t: (key: MessageKey) => string; + onChange: (value: string) => void; +}) { + // The placeholder reflects the on-disk state: an existing provider + // shows the masked value (so the operator can see what's stored + // without trusting it back into the controlled field), a new + // provider shows the generic "type a key" hint. + const placeholder = draft.isNew + ? t("providers.field.apiKeyPlaceholder") + : draft.apiKeyMasked || t("providers.field.apiKeyPlaceholder"); + return ( + onChange(e.target.value)} + className="mavis-input" + placeholder={placeholder} + /> + ); +} + +function DraftModelList({ + t, + models, + onChange, +}: { + t: (key: MessageKey) => string; + models: DraftModel[]; + onChange: (next: DraftModel[]) => void; +}) { + return ( +
+ {models.map((m, idx) => ( + { + const copy = models.slice(); + copy[idx] = next; + onChange(copy); + }} + onRemove={() => onChange(models.filter((_, i) => i !== idx))} + /> + ))} + +
+ ); +} + +function DraftModelRow({ + t, + model, + onChange, + onRemove, +}: { + t: (key: MessageKey) => string; + model: DraftModel; + onChange: (next: DraftModel) => void; + onRemove: () => void; +}) { + return ( +
+
+ + onChange({ ...model, id: e.target.value })} + className="mavis-input" + /> + + + onChange({ ...model, label: e.target.value })} + className="mavis-input" + /> + +
+ + onChange({ ...model, contextLimit: e.target.value })} + className="mavis-input" + inputMode="numeric" + /> + + + onChange({ ...model, thinkingLevels: values })} + className="mavis-input" + options={THINKING_LEVELS.map((lvl) => ({ + label: t(`providers.models.thinkingLevels.${lvl}` as MessageKey), + value: lvl, + }))} + /> + + + onChange({ ...model, modalities: values })} + className="mavis-input" + options={MODALITIES.map((mod) => ({ + label: t(`providers.models.modalities.${mod}` as MessageKey), + value: mod, + }))} + /> + +
+ +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +// --------------------------------------------------------------------- +// Preset one-click enable — degrades gracefully. +// --------------------------------------------------------------------- +// The presets API is being built on a sibling branch +// (`feat/provider-presets`). When that branch lands, the management +// panel will render this section as the entry point; until then, the +// fetch returns 404 and we render nothing rather than a broken +// affordance. The 404 path is exercised by the test suite. + +export interface PresetView { + id: string; + label: string; + protocol: api.ProviderProtocol; + authType: api.ProviderAuthType; + enabled: boolean; + /** True when this preset is already enabled in the user's config. */ + active: boolean; +} + +interface PresetsState { + /** `undefined` while the fetch is in flight; `null` when the + * endpoint 404'd (sibling branch not merged) — that branch hides + * the section entirely. */ + presets: PresetView[] | null | undefined; + /** Last error — shown when `presets === null && error !== null`. */ + error: string | null; +} + +export function ProviderPresetSection({ + t, + onAfterEnable, +}: { + t: (key: MessageKey) => string; + onAfterEnable: () => void; +}) { + const [state, setState] = useState({ presets: undefined, error: null }); + const [busyId, setBusyId] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/providers/presets", { + headers: { Accept: "application/json" }, + }); + if (cancelled) return; + if (res.status === 404) { + setState({ presets: null, error: null }); + return; + } + if (!res.ok) { + setState({ + presets: null, + error: `HTTP ${res.status}`, + }); + return; + } + const body = (await res.json()) as { presets: PresetView[] }; + setState({ presets: body.presets ?? [], error: null }); + } catch (cause) { + if (cancelled) return; + setState({ + presets: null, + error: cause instanceof Error ? cause.message : String(cause), + }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const enable = useCallback( + async (id: string) => { + setBusyId(id); + try { + const res = await fetch( + `/api/providers/preset/${encodeURIComponent(id)}/enable`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }, + ); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + onAfterEnable(); + } catch { + // Surfacing a translated error here is overkill — the parent + // will re-fetch on the next providersRevision bump, so the + // error stays visible in the browser console only. + } finally { + setBusyId(null); + } + }, + [onAfterEnable], + ); + + // 404 — endpoint not built yet. Hide the section entirely; the + // rest of the panel stays usable. This is the documented graceful + // degradation. The `undefined` branch (fetch in flight) also returns + // null so the section does not flash. + if (!state.presets) return null; + if (state.presets.length === 0) return null; + + return ( +
+ + {t("providers.presets.title")} + +
+ {state.presets.map((preset) => ( +
+ + {preset.label} + + + {preset.protocol} + + +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/packages/webui/webapp/lib/api.ts b/packages/webui/webapp/lib/api.ts index 53c1770e..bbc8a125 100644 --- a/packages/webui/webapp/lib/api.ts +++ b/packages/webui/webapp/lib/api.ts @@ -280,6 +280,149 @@ export interface ModelsPayload { export const listModels = () => request("/api/models"); +// --- providers -------------------------------------------------------------- +// +// Provider management UI surface (ticket 03). The server carries a v2 +// schema with three layers (env / cwd / user), masked keys, and a +// per-protocol connectivity probe — see `server/lib/providers-config.js` +// for the load-bearing details. +// +// `apiKey` is NEVER returned in plaintext — the response uses +// `apiKeyMasked` (first-N + *** + last-N framing). The PUT body uses +// `auth.apiKey === ""` as the keep-existing-key sentinel (a UI that +// didn't touch the key field sends an empty string, the route copies +// the on-disk key onto the record before validation). The two +// conventions together mean the masked placeholder is the +// placekeeper, not a value to round-trip. + +export type ProviderProtocol = "openai" | "anthropic" | "gemini"; +export type ProviderAuthType = "byok" | "coding-plan"; + +export interface ProviderAuthView { + type: ProviderAuthType; + hasKey: boolean; + apiKeyMasked: string; + baseURL: string; +} + +export interface ProviderModelView { + id: string; + label: string; + contextLimit?: number; + thinkingLevels?: string[]; + modalities?: string[]; +} + +export interface ProviderView { + id: string; + label: string; + preset?: string; + enabled: boolean; + protocol: ProviderProtocol; + auth: ProviderAuthView; + models: ProviderModelView[]; +} + +export interface ProvidersSnapshot { + ok: true; + version: 2; + providers: ProviderView[]; + sources: { env: string | null; cwd: string | null; user: string }; + userPath: string; +} + +export interface ProvidersPutResult { + ok: true; + providers: ProviderView[]; + path: string; +} + +/** + * GET /api/providers — the masked catalogue. + * + * Used by the management panel and by anything that wants to render a + * provider's "is this configured" status (the composer already gets + * `hasKey` from `/api/models`, so this call is panel-only). + */ +export const listProviders = () => request("/api/providers"); + +/** + * PUT /api/providers — replace the user-level file with `body`. + * + * The body is the full v2 record (same shape `listProviders` returns); + * the route normalises and validates. `auth.apiKey === ""` on any + * incoming provider is the keep-existing-key sentinel — see + * `lib/providers-config.js#applyKeepKeyConvention`. + */ +export const putProviders = (body: { + version: 2; + providers: Array<{ + id: string; + label?: string; + preset?: string; + enabled?: boolean; + protocol: ProviderProtocol; + auth: { + type: ProviderAuthType; + apiKey: string; + baseURL?: string; + }; + models: Array<{ + id: string; + label?: string; + contextLimit?: number; + thinkingLevels?: string[]; + modalities?: string[]; + }>; + }>; +}) => + request("/api/providers", { + method: "PUT", + json: body, + }); + +export interface ProviderTestResult { + ok: boolean; + protocol: string; + /** OK | INVALID_KEY | BAD_PROTOCOL | PROBE_FAILED */ + code: string; + error?: string; + latencyMs?: number; + detail?: string; +} + +/** + * POST /api/providers/test — connectivity probe. + * + * Takes the same `protocol` + `auth` shape as the management form (so + * the user can test BEFORE saving). `timeoutMs` is optional; the + * server defaults to 8s. The server returns 200 on success and a + * structured error otherwise — `request()` throws on non-OK, so this + * call uses raw fetch to capture the structured body either way. + */ +export async function testProviderConnection(payload: { + protocol: ProviderProtocol; + auth: { type: ProviderAuthType; apiKey: string; baseURL?: string }; + timeoutMs?: number; +}): Promise { + const response = await fetch(withClientQuery("/api/providers/test"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await response.text(); + let parsed: ProviderTestResult | null = null; + try { + parsed = text ? (JSON.parse(text) as ProviderTestResult) : null; + } catch { + parsed = null; + } + if (!parsed) { + throw new Error(response.ok ? "unexpected non-JSON response" : `HTTP ${response.status}`); + } + return parsed; +} + /** * The account card's data, from the engine's `mcode/account/status` method. * diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index f80004bb..f721baec 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -297,6 +297,65 @@ const en = { // earlier settings-tab route was a misread of the desktop layout). "panel.plugins.title": "Plugins", "panel.plugins.placeholder": "Plugin marketplace is in progress. The engine's install contract is not exposed by this server yet, so the desktop's category tabs + grid view will land once the contract is wired through.", + + /* Provider management (ticket 03) — the settings section that + lists, edits, tests and persists the v2 providers catalogue. + Bilingual by contract: every key has both an English and a + Chinese entry below. */ + "providers.title": "Model providers", + "providers.subtitle": "Configure API keys, protocols, and model catalogues", + "providers.empty": "No providers configured yet", + "providers.add": "Add provider", + "providers.test": "Test connection", + "providers.testing": "Testing…", + "providers.testOk": "Connected in {{ms}}ms", + "providers.testInvalidKey": "API key is invalid or missing", + "providers.testBadProtocol": "Unsupported protocol", + "providers.testProbeFailed": "Could not reach the endpoint", + "providers.testTimeout": "Timed out", + "providers.testHttp": "Endpoint replied {{status}}", + "providers.delete": "Delete", + "providers.deleteConfirm": "Delete provider {{id}}?", + "providers.deleteHint": "Removes the provider and every model under it. This cannot be undone.", + "providers.enabled": "Enabled", + "providers.disabled": "Disabled", + "providers.presetBadge": "Preset", + "providers.customBadge": "Custom", + "providers.field.id": "Provider id", + "providers.field.label": "Display name", + "providers.field.protocol": "Protocol", + "providers.field.authType": "Auth type", + "providers.field.apiKey": "API key", + "providers.field.apiKeyPlaceholder": "Stored as-is — leave blank (or omit the field) to keep the existing key. Clearing is not supported.", + "providers.field.baseURL": "Endpoint (baseURL)", + "providers.field.baseURLHint": "Leave blank to use the protocol default", + "providers.field.models": "Models", + "providers.field.modelId": "Model id", + "providers.field.modelLabel": "Display name", + "providers.field.contextLimit": "Context limit (tokens)", + "providers.field.thinkingLevels": "Thinking levels", + "providers.field.modalities": "Modalities", + "providers.models.add": "Add model", + "providers.models.remove": "Remove", + "providers.save": "Save providers", + "providers.saving": "Saving…", + "providers.saved": "Saved", + "providers.saveError": "Could not save: {{error}}", + "providers.loadError": "Could not load providers: {{error}}", + "providers.models.thinkingLevels.low": "low", + "providers.models.thinkingLevels.medium": "medium", + "providers.models.thinkingLevels.high": "high", + "providers.models.modalities.text": "text", + "providers.models.modalities.image": "image", + "providers.models.modalities.audio": "audio", + "providers.models.modalities.video": "video", + "providers.idInvalid": "Lowercase letters, digits, '.', '-', '_'; must start with one", + "providers.section.connection": "Connection", + "providers.section.models": "Models", + "providers.presets.title": "One-click enable", + "providers.presets.unavailable": "Preset catalogue not available in this build", + "providers.presets.enable": "Enable", + "providers.presets.enabling": "Enabling…", } as const; export type MessageKey = keyof typeof en; @@ -556,6 +615,62 @@ const zh: Record = { // 插件面板 stub —— 等后端装好 plugin install 合约再接上。 "panel.plugins.title": "插件", "panel.plugins.placeholder": "插件市场正在做。后端尚未暴露 plugin install 合约,桌面端的类别 tabs + 卡片网格会在合约打通后实装。", + /* 供应商管理(ticket 03)—— 设置里的供应商列表 / 编辑 / 连测 / 持久化面板。 + 双语齐全;新增键请同步补全英文与中文。 */ + "providers.title": "模型供应商", + "providers.subtitle": "配置 API Key、协议和模型清单", + "providers.empty": "暂无供应商", + "providers.add": "新增供应商", + "providers.test": "测试连接", + "providers.testing": "正在测试…", + "providers.testOk": "{{ms}}ms 连通", + "providers.testInvalidKey": "API Key 无效或缺失", + "providers.testBadProtocol": "不支持的协议", + "providers.testProbeFailed": "无法连接到该端点", + "providers.testTimeout": "连接超时", + "providers.testHttp": "端点返回 {{status}}", + "providers.delete": "删除", + "providers.deleteConfirm": "删除供应商 {{id}}?", + "providers.deleteHint": "将同时删除该供应商下的所有模型,操作不可撤销。", + "providers.enabled": "已启用", + "providers.disabled": "已停用", + "providers.presetBadge": "预置", + "providers.customBadge": "自定义", + "providers.field.id": "供应商 ID", + "providers.field.label": "显示名", + "providers.field.protocol": "协议", + "providers.field.authType": "认证类型", + "providers.field.apiKey": "API Key", + "providers.field.apiKeyPlaceholder": "明文保存;留空或省略该字段即保持现有 Key,不支持主动清空", + "providers.field.baseURL": "端点(baseURL)", + "providers.field.baseURLHint": "留空则使用该协议默认值", + "providers.field.models": "模型清单", + "providers.field.modelId": "模型 ID", + "providers.field.modelLabel": "显示名", + "providers.field.contextLimit": "上下文窗口(tokens)", + "providers.field.thinkingLevels": "思考等级", + "providers.field.modalities": "模态", + "providers.models.add": "新增模型", + "providers.models.remove": "移除", + "providers.save": "保存", + "providers.saving": "正在保存…", + "providers.saved": "已保存", + "providers.saveError": "保存失败:{{error}}", + "providers.loadError": "加载失败:{{error}}", + "providers.models.thinkingLevels.low": "低", + "providers.models.thinkingLevels.medium": "中", + "providers.models.thinkingLevels.high": "高", + "providers.models.modalities.text": "文本", + "providers.models.modalities.image": "图像", + "providers.models.modalities.audio": "音频", + "providers.models.modalities.video": "视频", + "providers.idInvalid": "小写字母、数字、'.', '-', '_';必须以其中之一开头", + "providers.section.connection": "连接", + "providers.section.models": "模型", + "providers.presets.title": "一键启用", + "providers.presets.unavailable": "当前版本未提供预置目录", + "providers.presets.enable": "启用", + "providers.presets.enabling": "正在启用…", }; const DICTIONARIES: Record> = { zh, en }; diff --git a/packages/webui/webapp/lib/provider-management.ts b/packages/webui/webapp/lib/provider-management.ts new file mode 100644 index 00000000..581503af --- /dev/null +++ b/packages/webui/webapp/lib/provider-management.ts @@ -0,0 +1,271 @@ +/** + * Pure helpers for the provider management UI. + * + * Lifted out of `components/provider-management.tsx` so they can be + * unit-tested without a DOM or a render harness. The component file + * re-imports these — there is no React in this module. + * + * Functions: + * - `validateProviderId` — id format (server enforces the same regex). + * - `validateModelRow` — per-model-row check (id + contextLimit + enums). + * - `describeTestOutcome`— map a /api/providers/test wire shape onto a + * UI string + tone (ok / warn / error). + * - `draftToWire` — convert a UI draft into the PUT body shape, + * dropping empty fields the wire contract + * expects to be absent. + * - `THINKING_LEVELS` / `MODALITIES` — the enum values the form sends. + */ + +import type { MessageKey } from "./i18n"; +import type { + ProviderAuthType, + ProviderProtocol, + ProviderView, +} from "./api"; + +export const THINKING_LEVELS = ["low", "medium", "high"] as const; +export const MODALITIES = ["text", "image", "audio", "video"] as const; + +export interface DraftAuth { + type: ProviderAuthType; + /** "" is the keep-existing-key sentinel. */ + apiKey: string; + baseURL: string; +} + +export interface DraftModel { + id: string; + label: string; + contextLimit: string; + thinkingLevels: string[]; + modalities: string[]; +} + +export interface DraftProvider { + /** Internal identity — never shown to the user, never sent over + * the wire. Set once when the draft is created and stable for the + * lifetime of the panel, even when the user later edits the + * user-facing `id`. The selection / update / delete paths are + * keyed off this field so editing the user-facing id does not + * lose the row. */ + draftId: string; + /** User-facing provider id — the wire value, mutable. Empty for a + * brand-new draft until the user types something. */ + id: string; + label: string; + enabled: boolean; + protocol: ProviderProtocol; + auth: DraftAuth; + models: DraftModel[]; + preset: string | null; + hasKey: boolean; + apiKeyMasked: string; + isNew: boolean; + markedForDeletion: boolean; +} + +export interface ProviderTestOutcome { + ok: boolean; + latencyMs?: number; + code: string; + error?: string; + detail?: string; +} + +export function blankAuth(): DraftAuth { + return { type: "byok", apiKey: "", baseURL: "" }; +} + +export function blankModel(): DraftModel { + return { id: "", label: "", contextLimit: "", thinkingLevels: [], modalities: [] }; +} + +export function draftFromView(view: ProviderView): DraftProvider { + return { + draftId: view.id, + id: view.id, + label: view.label, + enabled: view.enabled !== false, + protocol: view.protocol, + auth: { + type: view.auth.type, + apiKey: "", + baseURL: view.auth.baseURL ?? "", + }, + models: view.models.map((m) => ({ + id: m.id, + label: m.label ?? m.id, + contextLimit: m.contextLimit ? String(m.contextLimit) : "", + thinkingLevels: Array.isArray(m.thinkingLevels) ? [...m.thinkingLevels] : [], + modalities: Array.isArray(m.modalities) ? [...m.modalities] : [], + })), + preset: typeof view.preset === "string" ? view.preset : null, + hasKey: !!view.auth.hasKey, + apiKeyMasked: view.auth.apiKeyMasked || "", + isNew: false, + markedForDeletion: false, + }; +} + +/** A unique draft id — opaque to the user, stable for the lifetime of + * the panel. Used as the React `key`, the selection key, and the + * delete key, but NEVER written to disk. */ +export function newDraftId(): string { + return `__new_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +export function newDraftProvider(): DraftProvider { + return { + draftId: newDraftId(), + id: "", + label: "", + enabled: true, + protocol: "openai", + auth: blankAuth(), + models: [], + preset: null, + hasKey: false, + apiKeyMasked: "", + isNew: true, + markedForDeletion: false, + }; +} + +/** + * Provider id validation — server enforces the same regex + * (see `server/lib/providers-config.js#normaliseProvider`). Surface the + * error inline so the user doesn't hit Save and see a 400. + */ +export function validateProviderId(id: string): string | null { + const trimmed = id.trim(); + if (!trimmed) return "id required"; + if (!/^[A-Za-z0-9][A-Za-z0-9_.\-]*$/.test(trimmed)) return "invalid id"; + return null; +} + +/** + * Per-model-row validation. Returns the first error found or `null` + * when the row is well-formed. Empty model id is the "row was added + * but the user hasn't typed yet" state — it is treated as an error so + * the Save button stays disabled until the row is filled in or + * removed. + */ +export function validateModelRow(model: DraftModel): string | null { + const id = model.id.trim(); + if (!id) return "model id required"; + const ctx = model.contextLimit.trim(); + if (ctx && !/^\d+$/.test(ctx)) return "context limit must be a non-negative integer"; + for (const lvl of model.thinkingLevels) { + if (!THINKING_LEVELS.includes(lvl as typeof THINKING_LEVELS[number])) { + return `unknown thinking level: ${lvl}`; + } + } + for (const mod of model.modalities) { + if (!MODALITIES.includes(mod as typeof MODALITIES[number])) { + return `unknown modality: ${mod}`; + } + } + return null; +} + +export interface WireProvider { + id: string; + label?: string; + preset?: string; + enabled?: boolean; + protocol: ProviderProtocol; + auth: { type: ProviderAuthType; apiKey: string; baseURL?: string }; + models: Array<{ + id: string; + label?: string; + contextLimit?: number; + thinkingLevels?: string[]; + modalities?: string[]; + }>; +} + +/** + * Convert a draft into the wire shape the PUT route expects. + * + * - rows with an empty id are dropped (they are UI placeholders). + * - empty fields are dropped (the server's normaliser emits `null` + * or `undefined` for those, and dropping keeps the on-disk shape + * minimal). + * - apiKey is forwarded verbatim — including the empty sentinel + * that the server's `applyKeepKeyConvention` interprets. + */ +export function draftToWire(draft: DraftProvider): WireProvider { + const models = draft.models + .map((m): WireProvider["models"][number] | null => { + const trimmed = m.id.trim(); + if (!trimmed) return null; + const ctxRaw = m.contextLimit.trim(); + const ctx = ctxRaw ? Number(ctxRaw) : undefined; + return { + id: trimmed, + label: m.label.trim() || undefined, + ...(ctx && Number.isFinite(ctx) && ctx > 0 ? { contextLimit: ctx } : {}), + ...(m.thinkingLevels.length > 0 ? { thinkingLevels: [...m.thinkingLevels] } : {}), + ...(m.modalities.length > 0 ? { modalities: [...m.modalities] } : {}), + }; + }) + .filter((m): m is NonNullable => m !== null); + return { + id: draft.id.trim(), + label: draft.label.trim() || undefined, + ...(draft.preset ? { preset: draft.preset } : {}), + enabled: draft.enabled, + protocol: draft.protocol, + auth: { + type: draft.auth.type, + apiKey: draft.auth.apiKey, + ...(draft.auth.baseURL.trim() ? { baseURL: draft.auth.baseURL.trim() } : {}), + }, + models, + }; +} + +/** + * Map a `/api/providers/test` response onto a UI string + tone. + * + * The mapping is kept here (not in i18n) because the codes are wire + * constants, not user-visible strings — a translator picking one of + * them up by accident would render English to a Chinese user. The + * `t` argument is supplied by the caller so a rebrand is a one-line + * change in the component. + */ +export function describeTestOutcome( + t: (key: MessageKey) => string, + result: ProviderTestOutcome, +): { tone: "ok" | "warn" | "error"; text: string } { + if (result.ok) { + return { + tone: "ok", + text: t("providers.testOk").replace("{{ms}}", String(result.latencyMs ?? 0)), + }; + } + switch (result.code) { + case "INVALID_KEY": + return { tone: "error", text: t("providers.testInvalidKey") }; + case "BAD_PROTOCOL": + return { tone: "error", text: t("providers.testBadProtocol") }; + case "PROBE_FAILED": + if (result.error === "timeout") { + return { tone: "error", text: t("providers.testTimeout") }; + } + if (result.error && /^HTTP \d+/.test(result.error)) { + return { + tone: "error", + text: t("providers.testHttp").replace("{{status}}", result.error.replace(/^HTTP /, "")), + }; + } + return { + tone: "error", + text: result.error + ? `${t("providers.testProbeFailed")} — ${result.error}` + : t("providers.testProbeFailed"), + }; + default: + return { tone: "error", text: result.error || t("providers.testProbeFailed") }; + } +} \ No newline at end of file diff --git a/packages/webui/webapp/lib/sse.ts b/packages/webui/webapp/lib/sse.ts index 8b74d49c..9e51765c 100644 --- a/packages/webui/webapp/lib/sse.ts +++ b/packages/webui/webapp/lib/sse.ts @@ -24,6 +24,10 @@ export type SseAction = | { kind: "authorize-cleared" } /** First-ever start: the generated token, until acknowledged. */ | { kind: "first-run"; payload: TokenFirstRun } + /** Providers were saved; the masked catalogue arrived. Consumers + * refresh the management panel and re-fetch /api/models so the + * composer selector shows new groups without a page reload. */ + | { kind: "providers-updated"; providers: unknown[] } /** Keepalive; nothing to render. */ | { kind: "heartbeat" } /** A frame we recognise but intentionally do not act on. */ @@ -37,6 +41,11 @@ export const NAMED_EVENTS = [ "authorization_decided", "token.first_run", "auth.token_rotated", + // Provider management (ticket 03): the server broadcasts this after + // a successful PUT on /api/providers so the management panel and the + // model selector refresh without polling. The data payload carries + // the masked providers list — apiKey NEVER plaintext on this path. + "providers.updated", "heartbeat", ] as const; @@ -83,6 +92,16 @@ export function parseSseFrame(event: string, data: string): SseAction { // The token value is deliberately not read here: it is only needed by the // settings surface, which fetches it through the API when it is open. return { kind: "ignored", reason: "token rotation is handled by the settings surface" }; + case "providers.updated": { + // The data payload is `{ version, providers: [publicView(...)] }`. + // We only forward the `providers` array — the version is just a + // contract marker for the route handler. Malformed payloads are + // surfaced rather than thrown so the connection stays live. + const parsed = parseJson<{ providers?: unknown[] }>(data); + if (!parsed.ok) return { kind: "malformed", event, detail: parsed.detail }; + const providers = Array.isArray(parsed.value.providers) ? parsed.value.providers : []; + return { kind: "providers-updated", providers }; + } default: return { kind: "ignored", reason: `unknown event: ${event || "(none)"}` }; } diff --git a/packages/webui/webapp/lib/store.tsx b/packages/webui/webapp/lib/store.tsx index f1555eed..198046bd 100644 --- a/packages/webui/webapp/lib/store.tsx +++ b/packages/webui/webapp/lib/store.tsx @@ -45,6 +45,15 @@ export interface StoreSnapshot { quota: api.QuotaSnapshot | null; quotaBusy: boolean; quotaError: string | null; + /** + * Monotonic counter that bumps every time a `providers.updated` SSE frame + * arrives. Consumers (the management panel, the model selector) listen to + * the counter rather than to the payload itself: re-fetching through the + * typed API client keeps masking + auth + headers consistent across the + * app, and a counter is enough to trigger an effect. The counter resets + * to 0 on mount; the absolute value is meaningless across reloads. + */ + providersRevision: number; } const INITIAL: StoreSnapshot = { @@ -56,6 +65,7 @@ const INITIAL: StoreSnapshot = { quota: null, quotaBusy: false, quotaError: null, + providersRevision: 0, }; let snapshot: StoreSnapshot = INITIAL; @@ -109,6 +119,14 @@ export function connect(): () => void { case "first-run": setSnapshot({ firstRun: action.payload }); break; + case "providers-updated": + // Bump the revision so the management panel and the model + // selector re-fetch. The masked payload carried by the SSE + // frame is NOT stored — the consumers read the typed API + // again, which keeps the masking and auth headers consistent + // across the app (and lets us drop a frame-shaped buffer). + setSnapshot({ providersRevision: snapshot.providersRevision + 1 }); + break; case "malformed": setSnapshot({ error: `malformed ${action.event || "message"} frame` }); break; diff --git a/packages/webui/webapp/test/provider-management.test.ts b/packages/webui/webapp/test/provider-management.test.ts new file mode 100644 index 00000000..f373ef67 --- /dev/null +++ b/packages/webui/webapp/test/provider-management.test.ts @@ -0,0 +1,415 @@ +// webapp/test/provider-management.test.ts +// +// Unit tests for lib/provider-management.ts — the pure helpers that +// back the settings-modal provider management panel (ticket 03). +// +// Why this test exists: the management panel is render-heavy and the +// suite has no render harness for it, so the load-bearing logic has +// to be pinned without a DOM. These helpers drive: +// - id / model-row validation (the Save button's enable rule); +// - draft → wire conversion (the shape PUT /api/providers sees); +// - the test-connection outcome → UI string mapping (structured +// error rendering). +// A regression in any of those surfaces as a UI that "doesn't work" +// rather than a test failure, so the pinning here matters. +// +// Style note: the helpers are pure, the tests are pure — no DOM, +// no fetch, no React. They run under `pnpm test:webapp`. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +import { + THINKING_LEVELS, + MODALITIES, + blankAuth, + blankModel, + describeTestOutcome, + draftFromView, + draftToWire, + newDraftProvider, + validateModelRow, + validateProviderId, + type DraftProvider, +} from "../lib/provider-management"; +import type { ProviderView } from "../lib/api"; + +const T = (key: string) => + ({ + "providers.testOk": "Connected in {{ms}}ms", + "providers.testInvalidKey": "API key is invalid or missing", + "providers.testBadProtocol": "Unsupported protocol", + "providers.testProbeFailed": "Could not reach the endpoint", + "providers.testTimeout": "Timed out", + "providers.testHttp": "Endpoint replied {{status}}", + } as Record)[key] ?? key; + +function view(overrides: Partial = {}): ProviderView { + return { + id: "p1", + label: "P1", + enabled: true, + protocol: "openai", + auth: { + type: "byok", + hasKey: true, + apiKeyMasked: "sk-a***b", + baseURL: "https://api.example.com", + }, + models: [{ id: "m1", label: "M1" }], + ...overrides, + }; +} + +// --------------------------------------------------------------------- +// THINKING_LEVELS / MODALITIES — the enum values the form sends. +// --------------------------------------------------------------------- + +describe("provider-management — enum values", () => { + test("THINKING_LEVELS covers low / medium / high", () => { + assert.deepEqual([...THINKING_LEVELS], ["low", "medium", "high"]); + }); + + test("MODALITIES covers text / image / audio / video", () => { + assert.deepEqual([...MODALITIES], ["text", "image", "audio", "video"]); + }); +}); + +// --------------------------------------------------------------------- +// validateProviderId — server enforces the same regex. +// --------------------------------------------------------------------- + +describe("validateProviderId — id format", () => { + test("empty id is rejected with a stable message", () => { + const err = validateProviderId(""); + assert.match(err ?? "", /id required/); + assert.match(err ?? "", /id required/); + }); + + test("whitespace-only id is rejected", () => { + assert.match(validateProviderId(" ") ?? "", /id required/); + }); + + test("ids starting with a separator are rejected", () => { + assert.match(validateProviderId("-foo") ?? "", /invalid id/); + assert.match(validateProviderId(".foo") ?? "", /invalid id/); + assert.match(validateProviderId("_foo") ?? "", /invalid id/); + }); + + test("ids containing whitespace are rejected", () => { + assert.match(validateProviderId("foo bar") ?? "", /invalid id/); + }); + + test("valid ids return null", () => { + assert.equal(validateProviderId("p1"), null); + assert.equal(validateProviderId("openai_compat"), null); + assert.equal(validateProviderId("a-b-c.d"), null); + }); +}); + +// --------------------------------------------------------------------- +// validateModelRow — per-row shape. +// --------------------------------------------------------------------- + +describe("validateModelRow — model-row shape", () => { + test("empty id is rejected", () => { + const err = validateModelRow(blankModel()); + assert.match(err ?? "", /model id required/); + }); + + test("non-numeric contextLimit is rejected", () => { + const err = validateModelRow({ + ...blankModel(), + id: "m1", + contextLimit: "100k", + }); + assert.match(err ?? "", /context limit/); + }); + + test("unknown thinking level is rejected", () => { + const err = validateModelRow({ + ...blankModel(), + id: "m1", + thinkingLevels: ["ultra"], + }); + assert.match(err ?? "", /thinking level/); + }); + + test("unknown modality is rejected", () => { + const err = validateModelRow({ + ...blankModel(), + id: "m1", + modalities: ["hologram"], + }); + assert.match(err ?? "", /modality/); + }); + + test("a well-formed row returns null", () => { + const err = validateModelRow({ + ...blankModel(), + id: "m1", + label: "M1", + contextLimit: "128000", + thinkingLevels: ["low", "high"], + modalities: ["text", "image"], + }); + assert.equal(err, null); + }); +}); + +// --------------------------------------------------------------------- +// draftFromView — server view → form draft. +// --------------------------------------------------------------------- + +describe("draftFromView — view → draft", () => { + test("apiKey is always the empty sentinel (placeholder carries the masked value)", () => { + // Pinning this — the load-bearing piece of the keep-existing-key + // convention. If apiKey ever leaks from the masked placeholder + // into the controlled field, every edit wipes the plaintext. + const draft = draftFromView(view()); + assert.equal(draft.auth.apiKey, ""); + assert.equal(draft.apiKeyMasked, "sk-a***b"); + assert.equal(draft.hasKey, true); + }); + + test("preset field is preserved", () => { + const draft = draftFromView(view({ preset: "openai" })); + assert.equal(draft.preset, "openai"); + assert.equal(draft.isNew, false); + }); + + test("missing preset defaults to null", () => { + const draft = draftFromView(view()); + assert.equal(draft.preset, null); + }); + + test("models are converted: contextLimit becomes a string", () => { + const draft = draftFromView(view({ + models: [{ id: "m1", label: "M1", contextLimit: 128000, thinkingLevels: ["low"], modalities: ["text"] }], + })); + assert.equal(draft.models.length, 1); + const row = draft.models[0]; + assert.ok(row, "model row present"); + assert.equal(row.contextLimit, "128000"); + assert.deepEqual(row.thinkingLevels, ["low"]); + assert.deepEqual(row.modalities, ["text"]); + }); +}); + +// --------------------------------------------------------------------- +// draftToWire — form draft → wire shape. +// --------------------------------------------------------------------- + +describe("draftToWire — draft → wire", () => { + test("empty model rows are dropped", () => { + const draft: DraftProvider = { + ...newDraftProvider(), + id: "p1", + models: [ + { id: "", label: "", contextLimit: "", thinkingLevels: [], modalities: [] }, + { id: "m1", label: "M1", contextLimit: "", thinkingLevels: [], modalities: [] }, + ], + }; + const wire = draftToWire(draft); + assert.equal(wire.models.length, 1); + const row = wire.models[0]; + assert.ok(row, "kept row present"); + assert.equal(row.id, "m1"); + }); + + test("empty label is omitted on the wire (server falls back to id)", () => { + const draft: DraftProvider = { ...newDraftProvider(), id: "p1", label: "" }; + const wire = draftToWire(draft); + assert.equal(wire.label, undefined); + }); + + test("empty baseURL is omitted", () => { + const draft: DraftProvider = { + ...newDraftProvider(), + id: "p1", + auth: { type: "byok", apiKey: "sk-x", baseURL: " " }, + }; + const wire = draftToWire(draft); + assert.equal(wire.auth.baseURL, undefined); + }); + + test("preset is preserved only when truthy", () => { + const draft: DraftProvider = { ...newDraftProvider(), id: "p1", preset: null }; + assert.equal(draftToWire(draft).preset, undefined); + const presetDraft: DraftProvider = { ...newDraftProvider(), id: "p1", preset: "openai" }; + assert.equal(draftToWire(presetDraft).preset, "openai"); + }); + + test("apiKey is forwarded verbatim (the sentinel stays empty)", () => { + // The convention: an empty apiKey on the wire means "keep existing". + // draftToWire MUST forward "" unchanged — a UI that wrapped it + // back to a placeholder would silently rewrite keys. + const draft: DraftProvider = { + ...newDraftProvider(), + id: "p1", + auth: { type: "byok", apiKey: "", baseURL: "" }, + }; + const wire = draftToWire(draft); + assert.equal(wire.auth.apiKey, ""); + }); + + test("a typed apiKey is forwarded verbatim", () => { + const draft: DraftProvider = { + ...newDraftProvider(), + id: "p1", + auth: { type: "byok", apiKey: "sk-realtype-12345", baseURL: "" }, + }; + const wire = draftToWire(draft); + assert.equal(wire.auth.apiKey, "sk-realtype-12345"); + }); + + test("contextLimit is parsed to a number when present, omitted when blank", () => { + const draft: DraftProvider = { + ...newDraftProvider(), + id: "p1", + models: [ + { id: "a", label: "", contextLimit: "128000", thinkingLevels: [], modalities: [] }, + { id: "b", label: "", contextLimit: "", thinkingLevels: [], modalities: [] }, + ], + }; + const wire = draftToWire(draft); + const first = wire.models[0]; + const second = wire.models[1]; + assert.ok(first && second, "both rows present"); + assert.equal(first.contextLimit, 128000); + assert.equal(second.contextLimit, undefined); + }); + + test("thinkingLevels and modalities are dropped when empty", () => { + const draft: DraftProvider = { + ...newDraftProvider(), + id: "p1", + models: [ + { id: "a", label: "", contextLimit: "", thinkingLevels: [], modalities: [] }, + { id: "b", label: "", contextLimit: "", thinkingLevels: ["low"], modalities: ["text"] }, + ], + }; + const wire = draftToWire(draft); + const first = wire.models[0]; + const second = wire.models[1]; + assert.ok(first && second, "both rows present"); + assert.equal(first.thinkingLevels, undefined); + assert.equal(first.modalities, undefined); + assert.deepEqual(second.thinkingLevels, ["low"]); + assert.deepEqual(second.modalities, ["text"]); + }); + + test("draftId is never written to the wire", () => { + // `draftId` is a UI-only identity — the PUT body uses `id`. A + // regression that accidentally serialises it would leak the + // `__new_` prefix to the server, where it would fail the id + // regex validation. + const draft: DraftProvider = { + ...newDraftProvider(), + id: "p1", + draftId: "__new_should_not_leak", + }; + const wire = draftToWire(draft) as unknown as Record; + assert.equal(wire.draftId, undefined); + }); +}); + +// --------------------------------------------------------------------- +// describeTestOutcome — wire shape → UI string. +// --------------------------------------------------------------------- + +describe("describeTestOutcome — wire → UI", () => { + test("ok=true: tone=ok with the latency interpolated", () => { + const out = describeTestOutcome(T, { ok: true, code: "OK", latencyMs: 312 }); + assert.equal(out.tone, "ok"); + assert.match(out.text, /312/); + }); + + test("INVALID_KEY: tone=error with the dedicated message", () => { + const out = describeTestOutcome(T, { ok: false, code: "INVALID_KEY", error: "too short" }); + assert.equal(out.tone, "error"); + assert.match(out.text, /invalid or missing/); + }); + + test("BAD_PROTOCOL: tone=error with the dedicated message", () => { + const out = describeTestOutcome(T, { ok: false, code: "BAD_PROTOCOL" }); + assert.equal(out.tone, "error"); + assert.match(out.text, /Unsupported/); + }); + + test("PROBE_FAILED with timeout: dedicated timeout message", () => { + const out = describeTestOutcome(T, { ok: false, code: "PROBE_FAILED", error: "timeout" }); + assert.equal(out.tone, "error"); + assert.match(out.text, /Timed out/); + }); + + test("PROBE_FAILED with HTTP status: status is interpolated", () => { + const out = describeTestOutcome(T, { ok: false, code: "PROBE_FAILED", error: "HTTP 401" }); + assert.equal(out.tone, "error"); + assert.match(out.text, /401/); + }); + + test("PROBE_FAILED with network error: appended to the generic message", () => { + const out = describeTestOutcome(T, { ok: false, code: "PROBE_FAILED", error: "ECONNREFUSED" }); + assert.equal(out.tone, "error"); + assert.match(out.text, /ECONNREFUSED/); + }); + + test("unknown code with no error: falls through to the generic message", () => { + const out = describeTestOutcome(T, { ok: false, code: "MYSTERY" }); + assert.equal(out.tone, "error"); + assert.match(out.text, /Could not reach the endpoint/); + }); +}); + +// --------------------------------------------------------------------- +// blankAuth / blankModel / newDraftProvider — defaults that affect UX. +// --------------------------------------------------------------------- + +describe("defaults — blank fields are well-formed", () => { + test("blankAuth starts as byok + empty key + empty baseURL", () => { + const auth = blankAuth(); + assert.equal(auth.type, "byok"); + assert.equal(auth.apiKey, ""); + assert.equal(auth.baseURL, ""); + }); + + test("blankModel is empty across every field", () => { + const m = blankModel(); + assert.equal(m.id, ""); + assert.equal(m.label, ""); + assert.equal(m.contextLimit, ""); + assert.deepEqual(m.thinkingLevels, []); + assert.deepEqual(m.modalities, []); + }); + + test("newDraftProvider is enabled, openai, byok, isNew=true, no models", () => { + const d = newDraftProvider(); + assert.equal(d.enabled, true); + assert.equal(d.protocol, "openai"); + assert.equal(d.auth.type, "byok"); + assert.equal(d.isNew, true); + assert.equal(d.models.length, 0); + assert.equal(d.markedForDeletion, false); + }); + + test("newDraftProvider has a unique, opaque draftId starting with __new_", () => { + // The `__new_` prefix is the UI's signal for "this draft was + // never saved" — markDeleted uses it to hard-remove rather than + // soft-delete (which would leave a phantom row after save). + const a = newDraftProvider(); + const b = newDraftProvider(); + assert.ok(a.draftId.startsWith("__new_"), "draftId starts with __new_"); + assert.notEqual(a.draftId, b.draftId, "each draft has its own id"); + }); + + test("draftFromView mirrors the wire id into both `id` and `draftId`", () => { + // The `draftId` is the selection key; it MUST be stable for an + // existing provider so the editor stays on the row after a + // re-render. Mirroring the wire id is the simplest invariant + // that gives a unique key without extra bookkeeping. + const d = draftFromView(view()); + assert.equal(d.draftId, "p1"); + assert.equal(d.id, "p1"); + }); +}); \ No newline at end of file diff --git a/packages/webui/webapp/test/sse.test.ts b/packages/webui/webapp/test/sse.test.ts index d67dda21..ae995be3 100644 --- a/packages/webui/webapp/test/sse.test.ts +++ b/packages/webui/webapp/test/sse.test.ts @@ -79,6 +79,40 @@ describe("parseSseFrame — control frames", () => { const action = parseSseFrame("auth.token_rotated", "deadbeef"); assert.equal(action.kind, "ignored"); }); + + test("providers.updated yields the masked provider array", () => { + // Ticket 03: the server broadcasts this after a successful + // PUT on /api/providers. The store forwards the array; the + // management panel and model selector bump a revision counter + // and re-fetch through the typed API rather than trust the + // SSE payload shape for masking + auth headers. + const payload = { + version: 2, + providers: [{ id: "p1", label: "P1", auth: { hasKey: true, apiKeyMasked: "sk-a***b" } }], + }; + const action = parseSseFrame("providers.updated", JSON.stringify(payload)); + assert.equal(action.kind, "providers-updated"); + if (action.kind === "providers-updated") { + assert.equal(action.providers.length, 1); + assert.equal((action.providers[0] as { id: string }).id, "p1"); + } + }); + + test("providers.updated with no providers key falls back to an empty array", () => { + // Server contract is { version, providers }; the parser is + // defensive so a malformed shape cannot crash the stream. + const action = parseSseFrame("providers.updated", JSON.stringify({ version: 2 })); + assert.equal(action.kind, "providers-updated"); + if (action.kind === "providers-updated") { + assert.deepEqual(action.providers, []); + } + }); + + test("providers.updated with malformed JSON is reported", () => { + const action = parseSseFrame("providers.updated", "{not json"); + assert.equal(action.kind, "malformed"); + if (action.kind === "malformed") assert.equal(action.event, "providers.updated"); + }); }); describe("parseSseFrame — forward compatibility", () => { diff --git a/release/public-source.json b/release/public-source.json index 2a8213f9..71b52636 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3592,6 +3592,7 @@ "packages/webui/webapp/components/inbox.tsx", "packages/webui/webapp/components/modals.tsx", "packages/webui/webapp/components/panels.tsx", + "packages/webui/webapp/components/provider-management.tsx", "packages/webui/webapp/components/session-tree.tsx", "packages/webui/webapp/components/shell.tsx", "packages/webui/webapp/components/toolbar.tsx", @@ -3604,6 +3605,7 @@ "packages/webui/webapp/lib/composer-draft.ts", "packages/webui/webapp/lib/i18n.ts", "packages/webui/webapp/lib/markdown.ts", + "packages/webui/webapp/lib/provider-management.ts", "packages/webui/webapp/lib/sse.ts", "packages/webui/webapp/lib/store.tsx", "packages/webui/webapp/lib/theme.ts", @@ -3633,6 +3635,7 @@ "packages/webui/webapp/test/greeting.test.ts", "packages/webui/webapp/test/icons.test.ts", "packages/webui/webapp/test/markdown.test.ts", + "packages/webui/webapp/test/provider-management.test.ts", "packages/webui/webapp/test/slash-commands.test.ts", "packages/webui/webapp/test/sse.test.ts", "packages/webui/webapp/test/transcript-roundtrip.test.ts",