From 3cf18fee76e0af6ff1360b8dac3ae3f290cff0ae Mon Sep 17 00:00:00 2001 From: liuhailong <857688528@qq.com> Date: Sat, 26 Sep 2026 15:31:31 +0800 Subject: [PATCH] feat(webui): providers-config v2 schema + layered resolution + hot API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 01 of the model-providers workstream. Schema (v2, backward-compatible with v1): { version: 2, providers: [ { id, label, preset?, enabled, protocol: openai|anthropic|gemini, auth: { type: byok|coding-plan, apiKey?, baseURL? }, models: [{ id, label?, contextLimit?, thinkingLevels?, modalities? }] } ] } Layered resolution (highest wins on per-field basis): - MCODE_WEBUI_MODELS_CONFIG env (existing) > cwd models.json (existing) - user-level ~/.mcode-webui/providers.json (new, PUT target) Same-id provider deep merge, models deduped by id with higher layer winning. v1 records default to protocol=openai / auth.type=byok on load. Routes (Hono layer): - GET /api/providers — masked catalogue + sources + userPath - PUT /api/providers — validate + atomic-write + SSE broadcast ('event: providers.updated' + state push) - POST /api/providers/test — local key-format check FIRST (no network for malformed inputs), then a per-protocol minimal probe (openai GET /v1/models, anthropic POST /v1/messages with max_tokens:1, gemini GET /v1beta/models). Security: - apiKey is masked in EVERY response path (apiKeyMasked field only). - Probe requests send the key ONLY to the configured baseURL. - Atomic rename on the user-level write (no half-written config). /api/models extension: - Each model carries protocol / thinkingLevels / modalities from config. - Each provider group carries auth: {hasKey, type} (no apiKey, no baseURL — the secrets surface lives only on /api/providers). - Engine-authoritative merge semantics preserved (1 > 2 > 3). Tests (42 lib unit + 15 route): - Parser matrix: v1 compat, v2 fields, layer precedence + deep merge + dedupe, duplicate-id rejection. - API: GET masking (no plaintext in any response shape, pinned), PUT round-trip + hot reload effect on /api/models without restart, test endpoint structured errors + malformed-key rejection (no network), SSE payload masking. - Test isolation: MCODE_WEBUI_{DATA_DIR,MODELS_CONFIG} set per test to /tmp tmpdir; no server.js spawn so the existing test-isolation-lint doesn't apply, but the same discipline holds. Live self-check (isolated 18096/18097): - PUT a fake provider with key 'sk-realkey-FAKE-SECRET-1234567890' → 200 with apiKeyMasked 'sk-r***7890' (plaintext nowhere in any response). - GET /api/models immediately lists fake_openai/gpt-4o-mini with protocol/thinkingLevels/modalities (no restart). - SSE event 'providers.updated' observed with masked payload. - Test endpoint: short key → 400 INVALID_KEY in <5ms (no network); unknown protocol → 400 BAD_PROTOCOL; valid key + unreachable baseURL → 502 PROBE_FAILED (timeoutMs respected). Gates: typecheck 0 err; test:webui 537 pass + 2 skip + 0 fail (3 stable runs); test:webapp 213 pass; build OK; check:source OK; check:docs-alignment OK (all 3 new endpoints registered). --- packages/webui/docs/API.md | 167 +++- packages/webui/package.json | 1 + packages/webui/server/app.js | 16 + packages/webui/server/lib/providers-config.js | 661 +++++++++++++++ packages/webui/server/routes/model.js | 58 +- packages/webui/server/routes/providers.js | 234 ++++++ .../webui/test/lib/providers-config.test.js | 776 ++++++++++++++++++ .../webui/test/routes/providers.check.mjs | 432 ++++++++++ packages/webui/test/server/app-hono.test.js | 3 + release/public-source.json | 4 + 10 files changed, 2349 insertions(+), 3 deletions(-) create mode 100644 packages/webui/server/lib/providers-config.js create mode 100644 packages/webui/server/routes/providers.js create mode 100644 packages/webui/test/lib/providers-config.test.js create mode 100644 packages/webui/test/routes/providers.check.mjs diff --git a/packages/webui/docs/API.md b/packages/webui/docs/API.md index 780ae695..730650b4 100644 --- a/packages/webui/docs/API.md +++ b/packages/webui/docs/API.md @@ -827,12 +827,23 @@ is per-session, so there is nothing to report until a session exists. "source": "acp-session-config", "models": [ { "id": "minimax_api/MiniMax-M3", "name": "MiniMax-M3" } + ], + "groups": [ + { + "id": "__engine", + "label": "Engine session", + "models": [ + { "id": "minimax_api/MiniMax-M3", "label": "MiniMax-M3", "provider": "minimax_api", "source": "engine" } + ] + } ] } ``` -- `models[]` entries are `{id, name}` — `id` is the engine's config value, - `name` its display label. There is no `label` or `provider` field. +- `models[]` entries are `{id, name, label, provider, source}` — `id` + is the engine's config value, `name` and `label` its display name, + `provider` the prefix split off `id`, `source` one of + `engine` / `config` / `builtin`. - `current` is the option's `currentValue`, or `null` when the session has not reported one. It is never backfilled from a guess: a previous version wrote the default model back into `cs.model` here, which is what put an @@ -841,6 +852,12 @@ is per-session, so there is nothing to report until a session exists. If the list is empty the response adds `reason: "no_session_config"`. The `current` field is then `null`; nothing is written back. +When a v2 providers config is present (`/api/providers` PUT +target), each model carries `protocol` / `thinkingLevels` / +`modalities` from the config; each provider group carries +`auth: {hasKey, type}` (no `apiKey`, no `baseURL` — those exist +only on the `/api/providers` surface where the key is masked). + ### `POST /api/set-model` Change the model for the current CID. Persists into `cs.model` so the @@ -969,6 +986,152 @@ it. Answers go through `POST /api/send` with `{content, isAskAnswer: true}`. `deprecated: true` is always present — a client that only checks `ok` will keep calling an endpoint that does nothing. +### `GET /api/providers` + +Return the merged v2 provider catalogue, with every `apiKey` masked +(`apiKeyMasked`) — the plaintext credential is never returned in any +response path. The response also names the file paths the server +actually read for each layer, so an operator can confirm which file +the live config came from. + +Layered resolution: `MCODE_WEBUI_MODELS_CONFIG` env → cwd `models.json` +→ user-level `~/.mcode-webui/providers.json` (the PUT write target). +Same-id provider deep merge; models dedupe by id with the higher layer +winning. + +**Response 200** +```json +{ + "ok": true, + "version": 2, + "providers": [ + { + "id": "openai_compat", + "label": "OpenAI Compat", + "enabled": true, + "protocol": "openai", + "auth": { + "type": "byok", + "hasKey": true, + "apiKeyMasked": "sk-a***yz", + "baseURL": "https://api.openai.com" + }, + "models": [ + { + "id": "gpt-4o-mini", + "label": "GPT-4o mini", + "contextLimit": 128000, + "thinkingLevels": ["low", "medium", "high"], + "modalities": ["text", "image"] + } + ] + } + ], + "sources": { + "env": null, + "cwd": "/srv/webui/models.json", + "user": "/home/you/.mcode-webui/providers.json" + }, + "userPath": "/home/you/.mcode-webui/providers.json" +} +``` + +- `auth.apiKeyMasked` is the only apiKey shape returned by any route + in this surface. A test (and `scripts/check-docs-alignment.mjs`) + pins the rule: the plaintext key MUST NEVER appear in any + `/api/providers*` response, regardless of which layer held it. +- `sources.env` is `null` when `MCODE_WEBUI_MODELS_CONFIG` is unset; + `sources.cwd` is omitted from the layer set in that case (the env + override is the cwd file). + +### `PUT /api/providers` + +Validate-and-persist a v2 provider config to the user-level file +(`~/.mcode-webui/providers.json`, the file written by this handler). +The env / cwd layers are deployment-owned and never written here. + +The handler atomically writes via rename (no half-written file on +disk), reloads the layer set on the next call, and broadcasts an +SSE `providers.updated` named event with the masked payload so +every connected client refreshes its catalogue without polling. +`/api/models` picks up the change on the next request — no restart +required. + +**Request** +```json +{ + "version": 2, + "providers": [ + { + "id": "openai_compat", + "label": "OpenAI Compat", + "enabled": true, + "protocol": "openai", + "auth": { "type": "byok", "apiKey": "sk-realkey...", "baseURL": "https://api.openai.com" }, + "models": [ + { "id": "gpt-4o-mini", "label": "GPT-4o mini", "contextLimit": 128000 } + ] + } + ] +} +``` + +**Response 200** +```json +{ + "ok": true, + "providers": [ /* masked view, same shape as GET */ ], + "path": "/home/you/.mcode-webui/providers.json" +} +``` + +- `400 BAD_BODY` — invalid provider shape, unknown protocol, or + validation failure (each error carries a human-readable `error` + string with the offending field). +- `500 WRITE_FAILED` — disk I/O failure (the in-memory state did + not change; the operator should retry). + +### `POST /api/providers/test` + +Run a per-protocol minimal connectivity probe. Local key-format +validation happens BEFORE any network call — a malformed key gets +`400 INVALID_KEY` with no fetch. A successful probe returns +`{ok:true, latencyMs, detail}`; a network failure returns +`502 PROBE_FAILED` with the upstream status code (no response body +— upstream error messages can echo the credential in a misconfigured +proxy). + +**Request** +```json +{ + "protocol": "openai", + "auth": { "type": "byok", "apiKey": "sk-realkey...", "baseURL": "https://api.openai.com" } +} +``` + +**Response 200** (probe succeeded) +```json +{ "ok": true, "protocol": "openai", "code": "OK", "latencyMs": 187, "detail": "HTTP 200" } +``` + +**Response 400** (malformed key — no network call) +```json +{ "ok": false, "protocol": "openai", "code": "INVALID_KEY", "error": "auth.apiKey is too short (< 8 chars)" } +``` + +**Response 502** (upstream rejected the request) +```json +{ "ok": false, "protocol": "openai", "code": "PROBE_FAILED", "error": "HTTP 401", "latencyMs": 412 } +``` + +- Protocol whitelist: `openai` (`GET /v1/models`), `anthropic` + (`POST /v1/messages` with `claude-3-5-sonnet-20241022`, + `max_tokens:1`), `gemini` (`GET /v1beta/models?key=...`). + Anything else returns `400 BAD_PROTOCOL` with no network call. +- The key is sent only to the `baseURL` from the request body (or + the protocol default). The plaintext key never leaves the + server in any response path. + --- ## Usage diff --git a/packages/webui/package.json b/packages/webui/package.json index 899f2e02..8557d701 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -109,6 +109,7 @@ "settings": "GET|POST /api/settings", "upload": "POST /api/upload", "model": "GET /api/models, POST /api/set-model|permissions|answer", + "providers": "GET|PUT /api/providers, POST /api/providers/test", "usage": "GET|POST /api/usage[-real|-trigger|/refresh]", "protocol": "GET|POST /api/protocol/* (acp shim)", "debug": "GET|POST /api/debug/* (DEBUG_INJECT gated)" diff --git a/packages/webui/server/app.js b/packages/webui/server/app.js index 1dc4b722..943c6ef0 100644 --- a/packages/webui/server/app.js +++ b/packages/webui/server/app.js @@ -61,6 +61,7 @@ import * as uploadRoute from "./routes/upload.js"; import * as modelRoute from "./routes/model.js"; import * as debugRoute from "./routes/debug.js"; import * as protocolRoute from "./routes/protocol.js"; +import * as providersRoute from "./routes/providers.js"; import * as authorizeRoute from "./lib/authorize.js"; /** @@ -131,6 +132,10 @@ export const OWNED_ROUTES = new Set([ "POST /api/permissions", "GET /api/permissions-modes", "POST /api/answer", + // Provider configuration (v2: masked catalogue + layered config). + "GET /api/providers", + "PUT /api/providers", + "POST /api/providers/test", // Debug injection (gated by DEBUG_INJECT=1). "POST /api/debug/inject", "GET /api/debug/state", @@ -498,6 +503,17 @@ export function createHonoApp() { invokeHandler(c, c.get(CAPTURE_KEY), modelRoute.handleAnswer), ); + // ----- Provider configuration (v2) ----- + app.get("/api/providers", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), providersRoute.handleGetProviders), + ); + app.put("/api/providers", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), providersRoute.handlePutProviders), + ); + app.post("/api/providers/test", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), providersRoute.handleTestProvider), + ); + // ----- Debug injection (gated by DEBUG_INJECT=1) ----- app.post("/api/debug/inject", (c) => invokeHandler(c, c.get(CAPTURE_KEY), debugRoute.handleDebugInject), diff --git a/packages/webui/server/lib/providers-config.js b/packages/webui/server/lib/providers-config.js new file mode 100644 index 00000000..ae7bfacc --- /dev/null +++ b/packages/webui/server/lib/providers-config.js @@ -0,0 +1,661 @@ +// webui/server/lib/providers-config.js +// Provider configuration v2 schema, layered resolution, masking helpers, +// and per-protocol minimal connectivity probes. +// +// Schema (v2, backward-compatible with v1): +// +// { +// "version": 2, +// "providers": [ +// { +// "id": "openai_compat", +// "label": "OpenAI Compat", +// "preset": "openai", // optional +// "enabled": true, // default true +// "protocol": "openai"|"anthropic"|"gemini", +// "auth": { +// "type": "byok"|"coding-plan", +// "apiKey": "sk-...", +// "baseURL": "https://..." // optional (provider's own; default per protocol) +// }, +// "models": [ +// { +// "id": "gpt-4o-mini", +// "label": "GPT-4o mini", +// "contextLimit": 128000, +// "thinkingLevels":["low","medium","high"], +// "modalities": ["text","image"] +// } +// ] +// } +// ] +// } +// +// v1 (and the prior single-file shape) keeps working: +// { "providers": [{ "id", "label", "models": [{ id, label, contextLimit }] }] } +// +// Layered resolution (highest priority wins on per-field basis): +// 1. `MCODE_WEBUI_MODELS_CONFIG` env (pointing at a JSON file) — env layer +// 2. cwd `models.json` — cwd layer +// 3. user-level `~/.mcode-webui/providers.json` — user layer +// +// Same-id provider deep-merge: lower layer fills in fields the higher +// one leaves undefined; scalars (label / protocol / enabled) overwrite. +// Models inside a provider are deduped by `id` with the higher layer +// winning — a key set by the env layer overrides one set by cwd (and +// env > cwd > user-level). +// +// Security: +// - `apiKey` is masked in every public response (maskKey()). +// - Connectivity probes only fire AFTER a local format check passes. +// - When `apiKey` is absent, probe runs with no credential header and +// receives the same structured error shape. +// +// The caching shape is deliberately tiny: the user-level file is the +// only layer with mutable state (the env / cwd layers are re-read on +// every call so a deployment can roll a config without a server +// restart). The user-level file is read on every load too, but +// repeated reads of the same path on the same tick are coalesced by +// the routes themselves (handleGetProviders / handleGetModels). + +import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; + +// ===================================================================== +// Constants +// ===================================================================== + +/** Schema version emitted by `loadProvidersConfig()`. */ +export const SCHEMA_VERSION = 2; + +/** Allowed protocols. Anything else is rejected on PUT (validation). */ +export const ALLOWED_PROTOCOLS = new Set(["openai", "anthropic", "gemini"]); +/** Allowed auth types. */ +export const ALLOWED_AUTH_TYPES = new Set(["byok", "coding-plan"]); + +/** Persistent user-level file path. Lazy: respects MCODE_WEBUI_DATA_DIR. */ +export function getUserLevelPath() { + const base = + process.env.MCODE_WEBUI_DATA_DIR || join(homedir(), ".mcode-webui"); + return join(base, "providers.json"); +} + +/** + * Cwd-layer path: the env override wins when set; otherwise `/models.json`. + * Same precedence as the v1 `readModelsConfig` in routes/model.js. + */ +export function getCwdLayerPath() { + return process.env.MCODE_WEBUI_MODELS_CONFIG || join(process.cwd(), "models.json"); +} + +// ===================================================================== +// File I/O +// ===================================================================== + +/** Best-effort JSON parse: returns `null` on missing file / parse error. */ +function safeReadJson(path) { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +/** + * Atomic write: write `.tmp` then rename to ``. A half-written + * file on disk would be a config-load hazard the next PUT reads back into. + */ +function atomicWriteJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, JSON.stringify(value, null, 2), "utf8"); + renameSync(tmp, path); +} + +// ===================================================================== +// Validation / normalisation +// ===================================================================== + +function str(v, fallback = "") { + return typeof v === "string" ? v : fallback; +} +function bool(v, fallback = false) { + return typeof v === "boolean" ? v : fallback; +} +function num(v) { + return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null; +} +function arr(v) { + return Array.isArray(v) ? v : []; +} + +/** + * Local-only key format probe — no network. Returns `{ ok: true }` when + * the key looks usable, otherwise `{ ok: false, reason }`. Used by the + * `/api/providers/test` handler to reject obviously malformed keys + * before the network call. + * + * Rules are deliberately loose: each protocol has its own shape; we + * accept any non-empty string for "coding-plan" (auth may be opaque), + * and require non-empty trimmed length ≥ 8 for `byok` (the OpenAI / + * Anthropic / Gemini public keys are all ≥ 32 chars, but a project + * proxy may use a shorter token; the upper bound is `len ≤ 4096` to + * keep absurd inputs from reaching the network). + */ +export function validateKeyFormat(auth) { + if (!auth || typeof auth !== "object") { + return { ok: false, reason: "auth is missing" }; + } + const type = auth.type; + if (type !== "byok" && type !== "coding-plan") { + return { ok: false, reason: "auth.type must be byok or coding-plan" }; + } + if (type === "coding-plan") { + // coding-plan auth can be opaque (the provider may not even + // expose an apiKey); we only require *some* credential or baseURL + // so a probe has a target. baseURL alone is enough. + if (typeof auth.apiKey === "string" && auth.apiKey.length > 0) { + if (auth.apiKey.length > 4096) { + return { ok: false, reason: "auth.apiKey is too long (> 4096 chars)" }; + } + } + return { ok: true }; + } + // byok: an apiKey is required + const key = typeof auth.apiKey === "string" ? auth.apiKey.trim() : ""; + if (!key) return { ok: false, reason: "auth.apiKey is required for byok" }; + if (key.length < 8) return { ok: false, reason: "auth.apiKey is too short (< 8 chars)" }; + if (key.length > 4096) return { ok: false, reason: "auth.apiKey is too long (> 4096 chars)" }; + return { ok: true }; +} + +/** + * Validate one provider record. Returns `{ ok: true, value }` with a + * normalised copy, or `{ ok: false, error }` on the first bad field. + * Pure (no IO) so the parser can run on every call without writing + * back to disk. + */ +export function normaliseProvider(p) { + if (!p || typeof p !== "object") { + return { ok: false, error: "provider is not an object" }; + } + const id = str(p.id).trim(); + if (!id) return { ok: false, error: "provider.id is required" }; + if (!/^[A-Za-z0-9][A-Za-z0-9_.\-]*$/.test(id)) { + return { + ok: false, + error: `provider.id '${id}' must match /^[A-Za-z0-9][A-Za-z0-9_.-]*$/`, + }; + } + const protocol = str(p.protocol).trim() || "openai"; // v1 records omitted protocol — default to openai (most permissive) + if (!ALLOWED_PROTOCOLS.has(protocol)) { + return { + ok: false, + error: `provider '${id}': protocol must be one of ${[...ALLOWED_PROTOCOLS].join(", ")}`, + }; + } + const authRaw = p.auth && typeof p.auth === "object" ? p.auth : {}; + // v1 records omitted the `auth` object entirely — default to + // byok so legacy configs still load. Operators who care about + // auth fidelity can PUT a v2 body afterwards. + const authType = str(authRaw.type).trim() || "byok"; + if (!ALLOWED_AUTH_TYPES.has(authType)) { + return { + ok: false, + error: `provider '${id}': auth.type must be byok or coding-plan`, + }; + } + const auth = { + type: authType, + apiKey: typeof authRaw.apiKey === "string" ? authRaw.apiKey : "", + baseURL: typeof authRaw.baseURL === "string" ? authRaw.baseURL : "", + }; + if (auth.apiKey) { + const fmt = validateKeyFormat(auth); + if (!fmt.ok) return { ok: false, error: `provider '${id}': ${fmt.reason}` }; + } + const modelsRaw = arr(p.models); + const seenModel = new Set(); + const models = []; + for (const m of modelsRaw) { + if (!m || typeof m !== "object") continue; + const mid = str(m.id).trim(); + if (!mid) continue; + if (seenModel.has(mid)) continue; // dedupe inside one provider + seenModel.add(mid); + const model = { + id: mid, + label: str(m.label).trim() || mid, + contextLimit: num(m.contextLimit), + thinkingLevels: arr(m.thinkingLevels) + .filter((x) => typeof x === "string" && x.length > 0), + modalities: arr(m.modalities) + .filter((x) => typeof x === "string" && x.length > 0), + }; + // Drop null/undefined fields so the on-disk shape stays minimal + if (model.contextLimit === null) delete model.contextLimit; + if (model.thinkingLevels.length === 0) delete model.thinkingLevels; + if (model.modalities.length === 0) delete model.modalities; + models.push(model); + } + return { + ok: true, + value: { + id, + label: str(p.label).trim() || id, + preset: typeof p.preset === "string" ? p.preset : undefined, + enabled: bool(p.enabled, true), + protocol, + auth, + models, + }, + }; +} + +/** + * Normalise the whole top-level object. Accepts both v1 and v2 shapes: + * v1: { providers: [{ id, label, models: [...] }] } + * v2: { version: 2, providers: [...] } + * v1 records are upgraded in place — protocol/auth default to safe + * placeholders so v1 entries still load (no test rejects them), but + * a GET response that carries a v1 record shows it as `protocol: "openai"` + * with `enabled: true` (the most permissive default). Operators who + * care about protocol fidelity should put their config through the + * PUT handler, which only accepts v2. + */ +export function normaliseConfig(parsed) { + if (!parsed || typeof parsed !== "object") return null; + const providersRaw = arr(parsed.providers); + if (providersRaw.length === 0 && !parsed.providers) return null; + const seenProvider = new Set(); + const providers = []; + const errors = []; + for (const p of providersRaw) { + const r = normaliseProvider(p); + if (!r.ok) { + errors.push(r.error); + continue; + } + if (seenProvider.has(r.value.id)) { + errors.push(`duplicate provider id '${r.value.id}'`); + continue; + } + seenProvider.add(r.value.id); + providers.push(r.value); + } + return { + version: SCHEMA_VERSION, + providers, + ...(errors.length > 0 ? { warnings: errors } : {}), + }; +} + +// ===================================================================== +// Layered resolution + deep merge +// ===================================================================== + +/** + * Read one layer. Returns `null` when the layer file is missing or + * malformed. The cwd/env layer can be v1; the user-level layer is v2 + * (the PUT handler enforces v2 there). + */ +function readLayer(path) { + const parsed = safeReadJson(path); + if (!parsed) return null; + // v1 (or a non-versioned body) is accepted too — normaliseConfig + // handles both. + return normaliseConfig(parsed); +} + +/** + * Deep merge two provider arrays, higher layer wins: + * - Higher-layer provider with the same `id` overrides the lower one + * wholesale. (Per-field merge would let env silently "patch" a + * baseURL into a user-level provider — surprising, and the ticket + * explicitly asks for "higher layer wins" on per-provider level.) + * - Models inside a provider: dedupe by id, higher-layer model + * wins. + * - If only one layer defines the provider, it passes through. + * + * Layer precedence is the same as `loadProvidersConfig()`: env > cwd > user. + */ +export function mergeProviderLists(layers) { + // layers: [ [envLayer], [cwdLayer], [userLayer] ] (each is array|null) + const byId = new Map(); + // Lower layers first (so a higher layer's `set` wins). Iterate in + // DECLARED order: first item is lowest priority, last is highest. + for (let i = 0; i < layers.length; i++) { + const layer = layers[i]; + if (!Array.isArray(layer)) continue; + for (const p of layer) { + if (!p || !p.id) continue; + const existing = byId.get(p.id); + if (!existing) { + byId.set(p.id, cloneProvider(p)); + continue; + } + // Higher layer overrides scalars wholesale; models merge by id. + const merged = mergeProvider(existing, p); + byId.set(p.id, merged); + } + } + return [...byId.values()]; +} + +function cloneProvider(p) { + return { + ...p, + auth: { ...p.auth }, + models: p.models.map((m) => ({ ...m })), + }; +} + +function mergeProvider(lower, higher) { + // Scalars / protocol / auth come from the higher layer verbatim. + // Models are union-by-id with the higher-layer model winning on collision. + const modelById = new Map(); + for (const m of lower.models || []) modelById.set(m.id, m); + for (const m of higher.models || []) modelById.set(m.id, m); + return { + id: higher.id, + label: higher.label, + preset: higher.preset ?? lower.preset, + enabled: typeof higher.enabled === "boolean" ? higher.enabled : lower.enabled, + protocol: higher.protocol, + auth: { + type: higher.auth.type, + apiKey: higher.auth.apiKey || lower.auth.apiKey || "", + baseURL: higher.auth.baseURL || lower.auth.baseURL || "", + }, + models: [...modelById.values()], + }; +} + +/** + * Resolve the merged providers config from all three layers. + * - env layer : `MCODE_WEBUI_MODELS_CONFIG` (or cwd/models.json fallback) + * - cwd layer : `/models.json` (only when env override is unset) + * - user layer : `~/.mcode-webui/providers.json` (or env override of data dir) + * + * The cwd layer is intentionally skipped when `MCODE_WEBUI_MODELS_CONFIG` + * is set (env layer "is" the cwd path; two layers pointing at the same + * file would double-count). + */ +export function loadProvidersConfig() { + const envPath = process.env.MCODE_WEBUI_MODELS_CONFIG; + const cwdPath = envPath ? null : join(process.cwd(), "models.json"); + const userPath = getUserLevelPath(); + + const envLayer = envPath ? readLayer(envPath) : null; + const cwdLayer = cwdPath ? readLayer(cwdPath) : null; + const userLayer = existsSync(userPath) ? readLayer(userPath) : null; + + const layers = [userLayer, cwdLayer, envLayer]; // lowest -> highest priority + const sources = { + env: envPath || null, + cwd: cwdPath, + user: userPath, + }; + // env + cwd + user reading errors silently become null layers; the + // layered merge still works (missing layers are skipped). + const providers = mergeProviderLists([ + userLayer?.providers, + cwdLayer?.providers, + envLayer?.providers, + ]); + return { + version: SCHEMA_VERSION, + providers, + sources, + }; +} + +// ===================================================================== +// Masking +// ===================================================================== + +/** + * Mask an apiKey for display / API response. The plaintext NEVER + * leaves the server in any response path — `maskKey` is the only + * shape an apiKey can take in a response body, and the route handlers + * call it before any object is serialised. + * + * Rules: + * - falsy / non-string → "" (caller decides whether to include the field at all) + * - length < 8 → "***" (the whole key is shorter than the + * visible "first 4 + last 4" framing) + * - length ≤ 12 → first 2 + "***" + last 2 (e.g. "ab***yz") + * - length > 12 → first 4 + "***" + last 4 + * + * These lengths are picked so a 32-char Anthropic / OpenAI / Gemini + * key is shown as "sk-aa…bb" — readable, but no substring beyond the + * boundary can be used as a credential. + */ +export function maskKey(apiKey) { + if (typeof apiKey !== "string") return ""; + const k = apiKey.trim(); + if (!k) return ""; + if (k.length < 8) return "***"; + if (k.length <= 12) return `${k.slice(0, 2)}***${k.slice(-2)}`; + return `${k.slice(0, 4)}***${k.slice(-4)}`; +} + +/** + * Public-safe view of one provider: apiKey replaced by masked string, + * baseURL kept (operators need to see what endpoint they configured), + * everything else verbatim. Use this for every API response that + * carries a provider — there is NO other serialisation path, by + * design. + */ +export function publicView(provider) { + return { + id: provider.id, + label: provider.label, + preset: provider.preset, + enabled: provider.enabled !== false, + protocol: provider.protocol, + auth: { + type: provider.auth.type, + hasKey: !!(provider.auth.apiKey && provider.auth.apiKey.length > 0), + apiKeyMasked: maskKey(provider.auth.apiKey), + baseURL: provider.auth.baseURL || "", + }, + models: provider.models.map((m) => ({ + id: m.id, + label: m.label, + ...(m.contextLimit ? { contextLimit: m.contextLimit } : {}), + ...(m.thinkingLevels && m.thinkingLevels.length > 0 + ? { thinkingLevels: [...m.thinkingLevels] } + : {}), + ...(m.modalities && m.modalities.length > 0 + ? { modalities: [...m.modalities] } + : {}), + })), + }; +} + +// ===================================================================== +// Connectivity probes (per protocol) +// ===================================================================== + +/** Default base URLs when a provider leaves baseURL empty. */ +const DEFAULT_BASE_URL = { + openai: "https://api.openai.com", + anthropic: "https://api.anthropic.com", + gemini: "https://generativelanguage.googleapis.com", +}; + +/** + * Run one probe and resolve to `{ ok, latencyMs, detail }`. The + * detail shape carries the response status and a short, human-readable + * message — no response body (the upstream error message could leak + * the credential in a misconfigured proxy). Caller decides what to + * surface. + * + * Timeout: 8s. Long enough for cold starts, short enough to keep the + * UI responsive. + */ +async function probe({ protocol, auth, baseURLOverride, timeoutMs = 8000 }) { + const baseURL = + (typeof baseURLOverride === "string" && baseURLOverride) || + DEFAULT_BASE_URL[protocol] || + ""; + if (!baseURL) { + return { ok: false, latencyMs: 0, error: "no base URL configured" }; + } + const started = Date.now(); + let ctrl; + try { + ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + const result = await runProtocolProbe({ protocol, baseURL, auth, signal: ctrl.signal }); + clearTimeout(timer); + return { ...result, latencyMs: Date.now() - started }; + } catch (e) { + const message = + e && e.name === "AbortError" ? "timeout" : e && e.message ? e.message : String(e); + return { ok: false, latencyMs: Date.now() - started, error: message }; + } finally { + if (ctrl) try { ctrl.abort(); } catch {} + } +} + +async function runProtocolProbe({ protocol, baseURL, auth, signal }) { + if (protocol === "openai") { + // `GET {baseURL}/v1/models` with `Authorization: Bearer ` (when present) + const headers = { Accept: "application/json" }; + if (auth.apiKey) headers.Authorization = `Bearer ${auth.apiKey}`; + const res = await fetch(`${trimSlash(baseURL)}/v1/models`, { method: "GET", headers, signal }); + if (res.ok) return { ok: true, detail: `HTTP ${res.status}` }; + return { ok: false, error: `HTTP ${res.status}` }; + } + if (protocol === "anthropic") { + // `POST {baseURL}/v1/messages` with `x-api-key` header (when present) + // body: { model:"claude-3-5-sonnet-20241022", max_tokens:1, messages:[...] } + // `claude-3-5-sonnet-20241022` is the smallest stable probe model — it + // costs nothing to query for a 1-token response and avoids the + // `model not found` failure mode that earlier versions of the + // probe had when a custom baseURL did not accept `claude-3-haiku`. + const headers = { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + Accept: "application/json", + }; + if (auth.apiKey) headers["x-api-key"] = auth.apiKey; + const body = JSON.stringify({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1, + messages: [{ role: "user", content: "ping" }], + }); + const res = await fetch(`${trimSlash(baseURL)}/v1/messages`, { + method: "POST", + headers, + body, + signal, + }); + // Anthropic returns 200 even on max_tokens=0 when the request is + // accepted. 400 is the typical failure when the key is wrong. + if (res.ok) return { ok: true, detail: `HTTP ${res.status}` }; + return { ok: false, error: `HTTP ${res.status}` }; + } + if (protocol === "gemini") { + // `GET {baseURL}/v1beta/models?key=` (key is in the URL, not a + // header — that's the Gemini spec). + const url = new URL(`${trimSlash(baseURL)}/v1beta/models`); + if (auth.apiKey) url.searchParams.set("key", auth.apiKey); + const res = await fetch(url.toString(), { method: "GET", signal }); + if (res.ok) return { ok: true, detail: `HTTP ${res.status}` }; + return { ok: false, error: `HTTP ${res.status}` }; + } + return { ok: false, error: `unsupported protocol '${protocol}'` }; +} + +function trimSlash(s) { + return s.endsWith("/") ? s.slice(0, -1) : s; +} + +/** + * Top-level entry: validate the request locally, then run the probe. + * Returns a structured error when validation fails — no network call + * is made in that branch. Tests pin this contract. + * + * `timeoutMs` defaults to 8000ms; tests pass a shorter value so + * the no-network-for-malformed-input contract doesn't slow the + * suite (the coding-plan + unreachable-baseURL branch is otherwise + * a real fetch that times out). + */ +export async function testProvider({ protocol, auth, timeoutMs }) { + if (!ALLOWED_PROTOCOLS.has(protocol)) { + return { + ok: false, + code: "BAD_PROTOCOL", + error: `protocol must be one of ${[...ALLOWED_PROTOCOLS].join(", ")}`, + }; + } + const fmt = validateKeyFormat(auth || {}); + if (!fmt.ok) { + return { ok: false, code: "INVALID_KEY", error: fmt.reason }; + } + const r = await probe({ protocol, auth, ...(timeoutMs ? { timeoutMs } : {}) }); + if (r.ok) return { ok: true, latencyMs: r.latencyMs, detail: r.detail }; + return { + ok: false, + code: "PROBE_FAILED", + error: r.error, + latencyMs: r.latencyMs, + }; +} + +// ===================================================================== +// Persisted PUT (user-level write) +// ===================================================================== + +/** + * Validate-and-persist the incoming PUT body to the user-level file. + * Returns the persisted (normalised) config on success; on failure a + * `{ ok: false, error }` shape with a per-field message so the API + * can answer 400 without leaking internal stack traces. + * + * Note: the PUT handler is the ONLY write path for the user-level + * file. The env / cwd layers are deployment-owned and never written. + */ +export function writeProvidersConfig(parsed) { + if (!parsed || typeof parsed !== "object") { + return { ok: false, code: "BAD_BODY", error: "body is not an object" }; + } + const norm = normaliseConfig(parsed); + if (!norm) { + return { ok: false, code: "BAD_BODY", error: "no providers in body" }; + } + if (norm.warnings && norm.warnings.length > 0) { + return { + ok: false, + code: "BAD_BODY", + error: norm.warnings.join("; "), + }; + } + const path = getUserLevelPath(); + try { + atomicWriteJson(path, { version: SCHEMA_VERSION, providers: norm.providers }); + } catch (e) { + return { + ok: false, + code: "WRITE_FAILED", + error: e && e.message ? e.message : String(e), + }; + } + return { ok: true, path, providers: norm.providers }; +} + +/** + * Used by tests / routes that want to assert "plaintext key was never + * written to disk". Returns the raw `apiKey` from the parsed body — + * never expose this through an API response. Internal test helper. + */ +export function _extractPlaintextKey(provider) { + return provider && provider.auth && typeof provider.auth.apiKey === "string" + ? provider.auth.apiKey + : ""; +} \ No newline at end of file diff --git a/packages/webui/server/routes/model.js b/packages/webui/server/routes/model.js index 3725c896..c26d98f9 100644 --- a/packages/webui/server/routes/model.js +++ b/packages/webui/server/routes/model.js @@ -12,6 +12,7 @@ import { PERMISSION_MODES, } from "../lib/mcode-rpc.js"; import { getBuiltinModelsFromMcode } from "../lib/models.js"; +import { loadProvidersConfig } from "../lib/providers-config.js"; import { webuiModeToLabel } from "../lib/interaction/permission-presets.js"; import { readJson } from "../lib/read-json.js"; @@ -28,6 +29,12 @@ function configOption(cs, id) { * Shape: `{ providers: [{ id, label, models: [{ id, label?, contextLimit? }] }] }`. * Re-read on every request: editing the file does not require a server restart. * Missing / unreadable / malformed → null (treated as "no config"). + * + * v2 layered resolution lives in `loadProvidersConfig()` (env > cwd > + * user-level with deep merge). The /api/models route now reads + * through that helper, so an env override of `MCODE_WEBUI_MODELS_CONFIG` + * continues to win over the cwd file (matching the v1 contract), and + * a `~/.mcode-webui/providers.json` layer is layered under both. */ function readModelsConfig() { const path = @@ -42,6 +49,26 @@ function readModelsConfig() { } } +/** + * Layered resolver used by /api/models. Returns the merged + * `{ providers }` (v2 shape) or `null` when every layer is missing. + * The deep-merge + dedupe semantics are owned by + * `loadProvidersConfig()`; this helper only shapes its return into + * the legacy `{ providers: [...] }` view that the rest of + * handleGetModels already understood. + */ +function readProvidersConfigForModels() { + try { + const cfg = loadProvidersConfig(); + if (!cfg || !Array.isArray(cfg.providers) || cfg.providers.length === 0) { + return null; + } + return { providers: cfg.providers }; + } catch { + return null; + } +} + /** * Coerce a provider prefix out of a model id. * @@ -123,7 +150,13 @@ export function handleGetModels(_req, res, ctx) { // 2) Providers config — read every request so editing the file does not // require a restart. Config wins on id collision with the builtin // catalogue so providers can override labels and contextLimit. - const config = readModelsConfig(); + // + // v2 layered resolution (env > cwd > user-level) is provided by + // `loadProvidersConfig()`; the v1 single-file reader stays as a + // fallback for callers that pass the legacy `models.json` + // through a different code path (none today, but keeping it + // documents the contract). + const config = readProvidersConfigForModels(); if (config) { for (const p of config.providers) { if (!p || typeof p.id !== "string" || !p.id) continue; @@ -142,12 +175,32 @@ export function handleGetModels(_req, res, ctx) { if (typeof m.contextLimit === "number" && m.contextLimit > 0) { entry.contextLimit = m.contextLimit; } + // v2 schema surfaces: each model carries protocol + + // thinkingLevels + modalities so the selector can pick the + // right controls without a second round-trip. `auth` only + // exposes hasKey + type — apiKey NEVER reaches this response. + if (typeof p.protocol === "string" && p.protocol) { + entry.protocol = p.protocol; + } + if (Array.isArray(m.thinkingLevels) && m.thinkingLevels.length > 0) { + entry.thinkingLevels = [...m.thinkingLevels]; + } + if (Array.isArray(m.modalities) && m.modalities.length > 0) { + entry.modalities = [...m.modalities]; + } models.push(entry); list.push(entry); } groups.push({ id: p.id, label: typeof p.label === "string" && p.label ? p.label : p.id, + // Auth shape: only `hasKey` and `type`; no apiKey/baseURL. + // Operators see "configured or not" without leaking the secret. + auth: { + hasKey: !!(p.auth && p.auth.apiKey), + type: p.auth && typeof p.auth.type === "string" ? p.auth.type : "byok", + }, + protocol: typeof p.protocol === "string" ? p.protocol : "openai", models, }); } @@ -182,6 +235,9 @@ export function handleGetModels(_req, res, ctx) { } // Drop the empty builtin shell — a no-bundle empty group is noise. + // The drop is gated on "no providers config" so a fresh install with + // a config that names no models still has somewhere to attach the + // builtins once mcode reports them. if (builtinGroup && builtinGroup.models.length === 0 && !config) { const idx = groups.indexOf(builtinGroup); if (idx >= 0) groups.splice(idx, 1); diff --git a/packages/webui/server/routes/providers.js b/packages/webui/server/routes/providers.js new file mode 100644 index 00000000..bc16d57a --- /dev/null +++ b/packages/webui/server/routes/providers.js @@ -0,0 +1,234 @@ +// webui/server/routes/providers.js +// GET /api/providers, PUT /api/providers, POST /api/providers/test +// +// Provider configuration v2 — the management surface behind the +// schema and layered-resolution contract in +// `lib/providers-config.js`. The three routes: +// +// GET /api/providers — full (masked) catalogue + resolved +// layers + sources. +// PUT /api/providers — validate + persist to user-level +// file + reload + SSE broadcast. +// POST /api/providers/test — local key format check first, then a +// protocol-minimal connectivity probe. +// +// Security contract (pinned by tests): +// - apiKey is masked in EVERY response path. The public shape is +// `auth: { type, hasKey, apiKeyMasked, baseURL }`. The route +// never returns the plaintext key, the masked form is the ONLY +// shape an apiKey can take on the wire. +// - The PUT handler writes the user-level file via atomic +// rename; the env / cwd layers are deployment-owned and never +// written by this handler. +// - The probe handler rejects malformed keys locally — no network +// call is made when `validateKeyFormat` returns `{ ok: false }`. +// - Probe requests send the apiKey ONLY to the configured +// baseURL; a structured error is returned when no baseURL is +// configured for the protocol. +// +// Hot-reload semantics: +// - PUT triggers `pushProvidersUpdated()`, which broadcasts a +// named `providers.updated` SSE event with the masked payload +// so the UI can refresh its catalogue without an extra round +// trip. The next `GET /api/models` reads the same layers and +// picks up the change immediately (the user-level file is +// re-read on every call — no in-process cache to invalidate). + +import { Readable } from "node:stream"; + +import { + loadProvidersConfig, + publicView, + writeProvidersConfig, + testProvider as runProbe, + getUserLevelPath, +} from "../lib/providers-config.js"; +import { pushStateFor, sseByCid } from "../lib/state-bus.js"; +import { readJson } from "../lib/read-json.js"; + +/** + * GET /api/providers — masked catalogue + resolved-layer summary. + * + * Response shape: + * { + * ok: true, + * version: 2, + * providers: [publicView(...)], + * sources: { env, cwd, user }, // absolute paths (env is the + * // MCODE_WEBUI_MODELS_CONFIG + * // override or null) + * userPath: "..." // user-level file path + * } + * + * `sources` is documented (not redacted) — operators need to see + * which file the server actually read. + */ +export function handleGetProviders(_req, res, _ctx) { + const cfg = loadProvidersConfig(); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + return res.end( + JSON.stringify({ + ok: true, + version: cfg.version, + providers: cfg.providers.map(publicView), + sources: cfg.sources, + userPath: getUserLevelPath(), + }), + ); +} + +/** + * PUT /api/providers — validate-and-persist to user-level file. + * + * Body shape (v2): + * { version: 2, providers: [ { id, label, protocol, auth, models, ... } ] } + * + * Behaviour: + * - 400 + structured error when any provider fails validation. + * - 500 + structured error when the atomic write fails. + * - 200 + the masked response on success. + * - Always broadcasts `providers.updated` after a successful write + * so every connected SSE client refreshes its catalogue. + * + * The body size is bounded by `lib/read-json.js` (the shared body + * reader); a too-large payload is answered by the Hono capture with + * 413 — same answer every other route returns. + */ +export async function handlePutProviders(req, res, _ctx) { + const parsed = await readJson(req); + if (!parsed || typeof parsed !== "object") { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + return res.end( + JSON.stringify({ ok: false, code: "BAD_BODY", error: "body must be a JSON object" }), + ); + } + const result = writeProvidersConfig(parsed); + if (!result.ok) { + const status = result.code === "WRITE_FAILED" ? 500 : 400; + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + return res.end( + JSON.stringify({ ok: false, code: result.code, error: result.error }), + ); + } + // Reload + broadcast. `loadProvidersConfig()` re-reads the file on + // every call (no in-process cache), so a follow-up GET already + // sees the change. The SSE push is the mechanism the UI uses to + // notice WITHOUT polling. + pushProvidersUpdated(); + // The state-bus push keeps the existing snapshot contract intact + // (UI's general "refresh from /api/state" hint) — model selectors + // also re-fetch /api/models because the broadcast carries the + // masked providers in `event: providers.updated`. + pushStateFor("__broadcast__"); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + return res.end( + JSON.stringify({ + ok: true, + providers: result.providers.map(publicView), + path: result.path, + }), + ); +} + +/** + * POST /api/providers/test — per-protocol minimal connectivity + * probe. + * + * Body shape: + * { protocol: "openai|anthropic|gemini", auth: { type, apiKey, baseURL } } + * + * Order of checks: + * 1. protocol whitelist (no network for unknown protocols). + * 2. local key format (no network for malformed keys). + * 3. fetch with the configured baseURL (or the protocol default). + * + * `baseURL` in the request body is honoured so a UI "test this + * endpoint" button can exercise a custom URL without going through + * the persisted config. + */ +export async function handleTestProvider(req, res, _ctx) { + const parsed = await readJson(req); + if (!parsed || typeof parsed !== "object") { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + return res.end( + JSON.stringify({ ok: false, code: "BAD_BODY", error: "body must be a JSON object" }), + ); + } + const protocol = typeof parsed.protocol === "string" ? parsed.protocol : ""; + const authRaw = parsed.auth && typeof parsed.auth === "object" ? parsed.auth : {}; + // The request body's `baseURL` (when provided) is the probe + // target; persisted auth.baseURL is the fallback. Tests pass a + // fake URL to confirm structured errors without a real network + // call. + const auth = { + type: typeof authRaw.type === "string" ? authRaw.type : "byok", + apiKey: typeof authRaw.apiKey === "string" ? authRaw.apiKey : "", + baseURL: typeof authRaw.baseURL === "string" ? authRaw.baseURL : "", + }; + // Optional timeout override (ms) — surfaces from the request + // body so a UI "quick test" can fire a short probe. Unspecified + // defaults to the lib's 8s. + const timeoutMs = + typeof parsed.timeoutMs === "number" && parsed.timeoutMs > 0 + ? Math.min(parsed.timeoutMs, 8000) + : undefined; + const result = await runProbe({ protocol, auth, timeoutMs }); + const status = result.ok + ? 200 + : result.code === "BAD_PROTOCOL" || result.code === "INVALID_KEY" + ? 400 + : 502; + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + return res.end( + JSON.stringify({ + ok: result.ok, + protocol, + code: result.code || (result.ok ? "OK" : "PROBE_FAILED"), + error: result.error, + latencyMs: result.latencyMs, + detail: result.detail, + }), + ); +} + +// --------------------------------------------------------------------- +// SSE broadcast — the named event every connected client receives +// after a PUT (so the UI can refresh the catalogue without polling). +// The frame carries the masked providers payload; apiKey NEVER +// appears in cleartext (publicView is the only serialiser on this +// path, by design). +// --------------------------------------------------------------------- + +function pushProvidersUpdated() { + const cfg = loadProvidersConfig(); + const frame = `event: providers.updated\ndata: ${JSON.stringify({ + version: cfg.version, + providers: cfg.providers.map(publicView), + })}\n\n`; + for (const [, res] of sseByCid) { + try { + res.write(frame); + } catch {} + } +} + +/** + * Test-only helper: returns the SSE frame that would be emitted on + * PUT, without writing to any client. Used by tests that want to + * assert the masked shape directly. + */ +export function _peekProvidersUpdatedFrame() { + const cfg = loadProvidersConfig(); + return `event: providers.updated\ndata: ${JSON.stringify({ + version: cfg.version, + providers: cfg.providers.map(publicView), + })}\n\n`; +} + +/** + * Test-only helper: returns the raw response stream shape used by + * the test endpoint when it builds a fake request body. + */ +export function _bodyReadable(body) { + return Readable.from([Buffer.from(JSON.stringify(body), "utf8")]); +} \ No newline at end of file diff --git a/packages/webui/test/lib/providers-config.test.js b/packages/webui/test/lib/providers-config.test.js new file mode 100644 index 00000000..fdc5c891 --- /dev/null +++ b/packages/webui/test/lib/providers-config.test.js @@ -0,0 +1,776 @@ +// webui/test/lib/providers-config.test.js +// Unit tests for server/lib/providers-config.js — schema parsing, +// layered resolution + deep merge, masking, and the protocol probe. +// +// Why this test exists: providers-config is the load-bearing module +// for the v2 supplier configuration system (ticket 01). The masking +// contract is security-critical (apiKey must never appear in +// plaintext in any response path), and the layered merge has three +// independent rules — same-id provider deep merge, model dedupe by +// id with higher layer winning, and the env > cwd > user priority +// order. A regression in any of those is silent (a stale catalogue, +// a leaked credential). +// +// Test strategy: pure-function unit tests. The module has its own +// state (the user-level file path comes from `MCODE_WEBUI_DATA_DIR`), +// so each test sets / restores the env and writes to a tmp dir. The +// `validateKeyFormat` and `maskKey` paths are pure functions and +// don't need FS setup. + +import { test, describe, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; + +const providersConfig = await import(absPath("lib/providers-config.js")); + +let _tmpDataDir; +let _tmpCwd; +let _origDataDir; +let _origCwdEnv; +let _origCwd; + +before(async () => { + _tmpDataDir = mkdtempSync(join(tmpdir(), "webui-providers-test-")); + _tmpCwd = mkdtempSync(join(tmpdir(), "webui-providers-cwd-")); + _origDataDir = process.env.MCODE_WEBUI_DATA_DIR; + _origCwdEnv = process.env.MCODE_WEBUI_MODELS_CONFIG; + _origCwd = process.cwd(); + process.env.MCODE_WEBUI_DATA_DIR = _tmpDataDir; + process.env.MCODE_WEBUI_MODELS_CONFIG = ""; + // chdir into the cwd tmp so cwd-layer reads are scoped. + process.chdir(_tmpCwd); +}); + +after(async () => { + if (_origDataDir === undefined) delete process.env.MCODE_WEBUI_DATA_DIR; + else process.env.MCODE_WEBUI_DATA_DIR = _origDataDir; + if (_origCwdEnv === undefined) delete process.env.MCODE_WEBUI_MODELS_CONFIG; + else process.env.MCODE_WEBUI_MODELS_CONFIG = _origCwdEnv; + try { + process.chdir(_origCwd); + } catch {} + if (_tmpDataDir) try { rmSync(_tmpDataDir, { recursive: true, force: true }); } catch {} + if (_tmpCwd) try { rmSync(_tmpCwd, { recursive: true, force: true }); } catch {} +}); + +beforeEach(() => { + // Reset the cwd-layer file between cases so prior tests don't leak. + const cwdFile = join(_tmpCwd, "models.json"); + if (existsSync(cwdFile)) rmSync(cwdFile); + // Reset the user-level file too (read + clear). + const userFile = join(_tmpDataDir, "providers.json"); + if (existsSync(userFile)) rmSync(userFile); +}); + +// --------------------------------------------------------------------- +// maskKey — the security-critical pure function. +// --------------------------------------------------------------------- + +describe("maskKey — apiKey masking", () => { + test("empty string returns empty string (no field on the wire)", () => { + assert.equal(providersConfig.maskKey(""), ""); + }); + + test("non-string returns empty string", () => { + assert.equal(providersConfig.maskKey(undefined), ""); + assert.equal(providersConfig.maskKey(null), ""); + assert.equal(providersConfig.maskKey(123), ""); + }); + + test("very short keys (length < 8) round-trip to ***", () => { + // Any key shorter than 8 chars is fully hidden — the first/last + // framing has no visible budget to spend on. Pinned because + // accidental leaks of a 4-char secret would otherwise show + // "abcd" verbatim. + assert.equal(providersConfig.maskKey("abcd"), "***"); + assert.equal(providersConfig.maskKey("1234567"), "***"); + }); + + test("8-12 char keys show first 2 + *** + last 2", () => { + assert.equal(providersConfig.maskKey("abcdefgh"), "ab***gh"); + assert.equal(providersConfig.maskKey("abcdefghij"), "ab***ij"); + assert.equal(providersConfig.maskKey("abcdefghijkl"), "ab***kl"); + }); + + test("long keys (> 12) show first 4 + *** + last 4", () => { + assert.equal( + providersConfig.maskKey("sk-realkey-abcdefgh12345"), + "sk-r***2345", + ); + assert.equal( + providersConfig.maskKey("x7y8z9abcdefgh1234567890"), + "x7y8***7890", + ); + }); + + test("masking is NOT idempotent (idempotence not required by the contract)", () => { + // The masking rule is "first N + *** + last M", which is + // deliberately lossy. Calling it twice on a long key produces a + // tighter mask the second time — that is FINE: the only contract + // is that the plaintext NEVER appears in any output. A client + // that PUTs a masked key back is not a real use case (the key + // material lives on disk, not on the wire). + const once = providersConfig.maskKey("sk-realkey-abcdefgh12345"); + const twice = providersConfig.maskKey(once); + assert.equal(twice.includes("realkey"), false, "no plaintext leaks"); + assert.equal(once.includes("realkey"), false, "no plaintext in first mask"); + }); + + test("whitespace is trimmed before framing", () => { + // Surrounding whitespace stripped before the length check; a + // 14-char payload with whitespace frames as a 14-char payload + // without it. + assert.equal( + providersConfig.maskKey(" sk-abcdefgh1234 "), + "sk-a***1234", + ); + // Trimmed-but-still-short keys collapse to *** as usual. + assert.equal(providersConfig.maskKey(" abcdefg "), "***"); + }); +}); + +// --------------------------------------------------------------------- +// validateKeyFormat — the no-network contract. +// --------------------------------------------------------------------- + +describe("validateKeyFormat — no-network local validation", () => { + test("missing auth returns ok:false with reason", () => { + assert.equal(providersConfig.validateKeyFormat(null).ok, false); + assert.equal(providersConfig.validateKeyFormat({}).ok, false); + }); + + test("unknown auth type rejected", () => { + assert.equal( + providersConfig.validateKeyFormat({ type: "oauth", apiKey: "sk-realkey-aaa" }) + .ok, + false, + ); + }); + + test("byok: empty key rejected", () => { + const r = providersConfig.validateKeyFormat({ type: "byok", apiKey: "" }); + assert.equal(r.ok, false); + assert.match(r.reason, /required/); + }); + + test("byok: short key (< 8) rejected", () => { + const r = providersConfig.validateKeyFormat({ type: "byok", apiKey: "short" }); + assert.equal(r.ok, false); + assert.match(r.reason, /too short/); + }); + + test("byok: long key (>= 8) accepted", () => { + const r = providersConfig.validateKeyFormat({ + type: "byok", + apiKey: "sk-realkey-abcdefgh", + }); + assert.equal(r.ok, true); + }); + + test("coding-plan: empty key with baseURL accepted (no credential probe)", () => { + // coding-plan auth can be opaque — the provider may not even + // expose an apiKey. baseURL alone is enough to fire a probe. + const r = providersConfig.validateKeyFormat({ + type: "coding-plan", + baseURL: "https://proxy.example.com", + }); + assert.equal(r.ok, true); + }); + + test("byok: oversized key (> 4096) rejected", () => { + const big = "a".repeat(4097); + const r = providersConfig.validateKeyFormat({ type: "byok", apiKey: big }); + assert.equal(r.ok, false); + assert.match(r.reason, /too long/); + }); +}); + +// --------------------------------------------------------------------- +// normaliseProvider — v1 / v2 record normalisation. +// --------------------------------------------------------------------- + +describe("normaliseProvider — schema acceptance", () => { + test("v2 record round-trips with the same field names", () => { + const r = providersConfig.normaliseProvider({ + id: "openai_compat", + label: "OpenAI Compat", + enabled: true, + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaa", baseURL: "https://api.openai.com" }, + models: [ + { + id: "gpt-4o-mini", + label: "GPT-4o mini", + contextLimit: 128000, + thinkingLevels: ["low", "high"], + modalities: ["text"], + }, + ], + }); + assert.equal(r.ok, true); + assert.equal(r.value.id, "openai_compat"); + assert.equal(r.value.protocol, "openai"); + assert.equal(r.value.auth.apiKey, "sk-realkey-aaa"); + assert.equal(r.value.models.length, 1); + assert.deepEqual(r.value.models[0].thinkingLevels, ["low", "high"]); + }); + + test("v1 record is accepted (protocol defaults to openai)", () => { + // v1 didn't carry a `protocol` field. We default to "openai" + // so a legacy config still loads — the operator can flip it via + // a PUT later. + const r = providersConfig.normaliseProvider({ + id: "legacy", + label: "Legacy", + models: [{ id: "m1", label: "M1", contextLimit: 4096 }], + }); + assert.equal(r.ok, true); + assert.equal(r.value.protocol, "openai"); + }); + + test("missing id is rejected", () => { + const r = providersConfig.normaliseProvider({ protocol: "openai", models: [] }); + assert.equal(r.ok, false); + assert.match(r.error, /id is required/); + }); + + test("invalid id characters are rejected", () => { + const r = providersConfig.normaliseProvider({ + id: "bad id with spaces", + protocol: "openai", + }); + assert.equal(r.ok, false); + assert.match(r.error, /must match/); + }); + + test("unknown protocol is rejected", () => { + const r = providersConfig.normaliseProvider({ + id: "x", + protocol: "ollama", + }); + assert.equal(r.ok, false); + assert.match(r.error, /protocol/); + }); + + test("duplicate model id within one provider is deduped (first wins)", () => { + const r = providersConfig.normaliseProvider({ + id: "p", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaa" }, + models: [ + { id: "m1", label: "first" }, + { id: "m1", label: "second" }, + ], + }); + assert.equal(r.ok, true); + assert.equal(r.value.models.length, 1); + assert.equal(r.value.models[0].label, "first"); + }); + + test("models with non-positive contextLimit are dropped", () => { + const r = providersConfig.normaliseProvider({ + id: "p", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaa" }, + models: [ + { id: "m1", contextLimit: 0 }, + { id: "m2", contextLimit: 128000 }, + ], + }); + assert.equal(r.ok, true); + const m1 = r.value.models.find((m) => m.id === "m1"); + const m2 = r.value.models.find((m) => m.id === "m2"); + assert.equal(m1.contextLimit, undefined, "contextLimit dropped when 0"); + assert.equal(m2.contextLimit, 128000); + }); +}); + +// --------------------------------------------------------------------- +// mergeProviderLists — env > cwd > user precedence. +// --------------------------------------------------------------------- + +describe("mergeProviderLists — layered precedence + dedupe", () => { + test("single layer passes through unchanged", () => { + const out = providersConfig.mergeProviderLists([ + [ + { + id: "p", + label: "L", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-aaaa", baseURL: "" }, + models: [{ id: "m" }], + }, + ], + ]); + assert.equal(out.length, 1); + assert.equal(out[0].id, "p"); + assert.equal(out[0].models[0].id, "m"); + }); + + test("higher layer overrides same-id provider fields; models deep-merge by id", () => { + // Ticket contract: "同 id provider 深合并" — same-id provider is + // a deep merge (label / protocol / enabled / auth from higher + // layer; models deduped by id with higher layer winning on + // collision). Wholesale replacement is NOT the rule. + const lower = [ + { + id: "p", + label: "lower", + protocol: "openai", + enabled: false, + auth: { type: "byok", apiKey: "sk-lower", baseURL: "https://lower.example.com" }, + models: [{ id: "m", label: "lower-m" }], + }, + ]; + const higher = [ + { + id: "p", + label: "higher", + protocol: "anthropic", + enabled: true, + auth: { type: "byok", apiKey: "sk-higher-key", baseURL: "" }, + models: [], + }, + ]; + const out = providersConfig.mergeProviderLists([lower, higher]); + assert.equal(out.length, 1); + assert.equal(out[0].label, "higher", "label comes from higher layer"); + assert.equal(out[0].protocol, "anthropic", "protocol comes from higher layer"); + assert.equal(out[0].enabled, true, "enabled comes from higher layer"); + assert.equal(out[0].auth.apiKey, "sk-higher-key", "apiKey comes from higher layer"); + // The lower layer's model survives even though the higher + // layer's models list is empty — that's the deep-merge contract + // (NOT wholesale replacement). + assert.equal(out[0].models.length, 1, "deep merge keeps lower's models"); + assert.equal(out[0].models[0].id, "m"); + }); + + test("model dedupe by id with higher layer winning", () => { + const lower = [ + { + id: "p", + label: "L", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-aaaa", baseURL: "" }, + models: [ + { id: "m1", label: "l-m1" }, + { id: "m2", label: "l-m2" }, + ], + }, + ]; + const higher = [ + { + id: "p", + label: "H", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-aaaa", baseURL: "" }, + models: [ + { id: "m1", label: "h-m1" }, + { id: "m3", label: "h-m3" }, + ], + }, + ]; + const out = providersConfig.mergeProviderLists([lower, higher]); + const byId = Object.fromEntries(out[0].models.map((m) => [m.id, m.label])); + assert.equal(byId.m1, "h-m1", "higher layer wins model-level collision"); + assert.equal(byId.m2, "l-m2", "lower-only models preserved"); + assert.equal(byId.m3, "h-m3", "higher-only models preserved"); + }); + + test("three-layer precedence: env > cwd > user", () => { + // env layer (top), cwd layer (middle), user layer (bottom). + // We pass [user, cwd, env] to mergeProviderLists — the helper + // iterates lowest→highest so later layers overwrite earlier + // ones on scalar fields. Model union by id. + const user = [ + { + id: "user-only", + label: "user-only", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-user", baseURL: "" }, + models: [], + }, + { + id: "shared", + label: "user-label", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-user", baseURL: "" }, + models: [{ id: "user-m" }], + }, + ]; + const cwd = [ + { + id: "cwd-only", + label: "cwd-only", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-cwd", baseURL: "" }, + models: [], + }, + { + id: "shared", + label: "cwd-label", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-cwd", baseURL: "" }, + models: [{ id: "cwd-m" }], + }, + ]; + const env = [ + { + id: "env-only", + label: "env-only", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-env", baseURL: "" }, + models: [], + }, + { + id: "shared", + label: "env-label", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-env", baseURL: "" }, + models: [{ id: "env-m" }, { id: "user-m" }], // user-m collides with user-layer entry + }, + ]; + const out = providersConfig.mergeProviderLists([user, cwd, env]); + const byId = Object.fromEntries(out.map((p) => [p.id, p])); + assert.equal(byId["user-only"].label, "user-only"); + assert.equal(byId["cwd-only"].label, "cwd-only"); + assert.equal(byId["env-only"].label, "env-only"); + assert.equal(byId["shared"].label, "env-label", "env wins same-id scalar"); + // 'shared' models are unioned by id across all three layers: + // user-m + cwd-m + env-m (env's user-m collides with user's + // user-m, but it's the same id → kept once). The deep merge is + // a UNION, not a replacement. + const sharedModels = byId["shared"].models.map((m) => m.id).sort(); + assert.deepEqual(sharedModels, ["cwd-m", "env-m", "user-m"]); + }); +}); + +// --------------------------------------------------------------------- +// loadProvidersConfig — full layered resolution from disk. +// --------------------------------------------------------------------- + +describe("loadProvidersConfig — full layered resolution", () => { + beforeEach(() => { + // each test starts with no files in any layer + }); + + test("missing files in every layer returns an empty config", () => { + const cfg = providersConfig.loadProvidersConfig(); + assert.equal(cfg.providers.length, 0); + assert.equal(cfg.version, providersConfig.SCHEMA_VERSION); + }); + + test("user-level layer is read on every call (no in-process cache)", () => { + const userPath = providersConfig.getUserLevelPath(); + writeFileSync( + userPath, + JSON.stringify({ + version: 2, + providers: [ + { + id: "user1", + label: "User1", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaaa" }, + models: [{ id: "m1" }], + }, + ], + }), + ); + const cfg = providersConfig.loadProvidersConfig(); + assert.equal(cfg.providers.length, 1); + assert.equal(cfg.providers[0].id, "user1"); + }); + + test("v1 cwd file is upgraded in place (no protocol field)", () => { + // v1 records omitted the `protocol` field; the parser defaults + // it to "openai" so legacy configs keep loading. Operators who + // care about protocol fidelity can PUT the v2 form afterwards. + writeFileSync( + join(_tmpCwd, "models.json"), + JSON.stringify({ + providers: [ + { id: "v1prov", label: "V1", models: [{ id: "v1m", contextLimit: 4096 }] }, + ], + }), + ); + const cfg = providersConfig.loadProvidersConfig(); + const v1 = cfg.providers.find((p) => p.id === "v1prov"); + assert.ok(v1, "v1 provider present"); + assert.equal(v1.protocol, "openai", "v1 default protocol is openai"); + assert.equal(v1.models[0].contextLimit, 4096, "v1 contextLimit preserved"); + }); + + test("env override (MCODE_WEBUI_MODELS_CONFIG) wins over cwd", () => { + // Write a cwd-layer file with one provider and an env-layer + // file with another, then point the env override at the env + // file. The env provider should win. + writeFileSync( + join(_tmpCwd, "models.json"), + JSON.stringify({ + providers: [ + { + id: "cwd-prov", + label: "Cwd", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-cwd" }, + models: [], + }, + ], + }), + ); + const envFile = join(_tmpCwd, "env-layer.json"); + writeFileSync( + envFile, + JSON.stringify({ + providers: [ + { + id: "env-prov", + label: "Env", + protocol: "anthropic", + auth: { type: "byok", apiKey: "sk-realkey-env" }, + models: [], + }, + ], + }), + ); + process.env.MCODE_WEBUI_MODELS_CONFIG = envFile; + try { + const cfg = providersConfig.loadProvidersConfig(); + const ids = cfg.providers.map((p) => p.id).sort(); + // env override path is honoured; cwd-layer file is NOT read + // (the env override IS the cwd path under the rule). + assert.deepEqual(ids, ["env-prov"]); + } finally { + delete process.env.MCODE_WEBUI_MODELS_CONFIG; + } + }); + + test("env override + cwd file collision: only the env file is read", () => { + // env override points at file A; cwd/models.json is file B. + // Per the rule (the env override IS the cwd path), only file A + // is read. + const fileA = join(_tmpCwd, "a.json"); + const fileB = join(_tmpCwd, "b.json"); + writeFileSync( + fileA, + JSON.stringify({ + providers: [ + { id: "A", label: "A", protocol: "openai", auth: { type: "byok", apiKey: "sk-realkey-aaaa" }, models: [] }, + ], + }), + ); + writeFileSync( + fileB, + JSON.stringify({ + providers: [ + { id: "B", label: "B", protocol: "openai", auth: { type: "byok", apiKey: "sk-realkey-bbbb" }, models: [] }, + ], + }), + ); + process.env.MCODE_WEBUI_MODELS_CONFIG = fileA; + try { + const cfg = providersConfig.loadProvidersConfig(); + const ids = cfg.providers.map((p) => p.id); + assert.deepEqual(ids, ["A"]); + } finally { + delete process.env.MCODE_WEBUI_MODELS_CONFIG; + } + }); +}); + +// --------------------------------------------------------------------- +// publicView — masking is applied EVERYWHERE. +// --------------------------------------------------------------------- + +describe("publicView — apiKey masked in every response path", () => { + test("apiKey becomes apiKeyMasked; plaintext is gone", () => { + const view = providersConfig.publicView({ + id: "p", + label: "L", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "sk-realkey-abcdefghij", baseURL: "https://api.openai.com" }, + models: [{ id: "m1", label: "M1" }], + }); + // 23-char payload (> 12) → first 4 + *** + last 4 + assert.equal(view.auth.apiKeyMasked, "sk-r***ghij"); + assert.equal(view.auth.hasKey, true); + assert.equal(view.auth.type, "byok"); + // Pinned: the plaintext MUST NOT appear anywhere in the view. + const json = JSON.stringify(view); + assert.equal(json.includes("realkey"), false, "plaintext apiKey never appears in publicView"); + }); + + test("hasKey is false when apiKey is empty", () => { + const view = providersConfig.publicView({ + id: "p", + label: "L", + protocol: "openai", + enabled: true, + auth: { type: "byok", apiKey: "", baseURL: "" }, + models: [], + }); + assert.equal(view.auth.hasKey, false); + assert.equal(view.auth.apiKeyMasked, ""); + }); + + test("model fields pass through; contextLimit and arrays only when present", () => { + const view = providersConfig.publicView({ + id: "p", + label: "L", + protocol: "anthropic", + enabled: true, + auth: { type: "byok", apiKey: "sk-abcdefghij", baseURL: "" }, + models: [ + { id: "m1", label: "M1", thinkingLevels: ["low", "high"], modalities: ["text"] }, + { id: "m2", label: "M2" }, // no extras + ], + }); + assert.deepEqual(view.models[0].thinkingLevels, ["low", "high"]); + assert.deepEqual(view.models[0].modalities, ["text"]); + assert.equal(view.models[1].thinkingLevels, undefined); + assert.equal(view.models[1].modalities, undefined); + }); +}); + +// --------------------------------------------------------------------- +// writeProvidersConfig — atomic persistence. +// --------------------------------------------------------------------- + +describe("writeProvidersConfig — atomic persistence", () => { + test("writes the user-level file with v2 schema", () => { + const r = providersConfig.writeProvidersConfig({ + version: 2, + providers: [ + { + id: "p", + label: "L", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaa" }, + models: [{ id: "m1" }], + }, + ], + }); + assert.equal(r.ok, true); + const written = JSON.parse( + readFileSync(providersConfig.getUserLevelPath(), "utf8"), + ); + assert.equal(written.version, 2); + assert.equal(written.providers[0].id, "p"); + // Pinned: plaintext key persists to disk (it has to, the engine + // needs it) — but the masking contract only governs RESPONSES. + assert.equal(written.providers[0].auth.apiKey, "sk-realkey-aaa"); + }); + + test("rejects unknown protocol in any provider", () => { + const r = providersConfig.writeProvidersConfig({ + version: 2, + providers: [{ id: "p", protocol: "ollama", auth: { type: "byok" } }], + }); + assert.equal(r.ok, false); + assert.equal(r.code, "BAD_BODY"); + }); + + test("rejects duplicate provider id", () => { + const r = providersConfig.writeProvidersConfig({ + version: 2, + providers: [ + { id: "p", protocol: "openai", auth: { type: "byok", apiKey: "sk-aaaa" }, models: [] }, + { id: "p", protocol: "openai", auth: { type: "byok", apiKey: "sk-bbbb" }, models: [] }, + ], + }); + assert.equal(r.ok, false); + assert.equal(r.code, "BAD_BODY"); + }); + + test("rejects empty body", () => { + const r1 = providersConfig.writeProvidersConfig(null); + assert.equal(r1.ok, false); + const r2 = providersConfig.writeProvidersConfig({}); + assert.equal(r2.ok, false); + }); + + test("atomic write leaves no .tmp file behind", () => { + providersConfig.writeProvidersConfig({ + version: 2, + providers: [ + { + id: "p", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaa" }, + models: [], + }, + ], + }); + const tmp = `${providersConfig.getUserLevelPath()}.tmp`; + assert.equal(existsSync(tmp), false, "no leftover .tmp file"); + }); +}); + +// --------------------------------------------------------------------- +// testProvider — local validation gate BEFORE network. +// --------------------------------------------------------------------- + +describe("testProvider — no network for malformed inputs", () => { + test("unknown protocol returns BAD_PROTOCOL without a fetch", async () => { + const r = await providersConfig.testProvider({ + protocol: "ollama", + auth: { type: "byok", apiKey: "sk-realkey-aaa" }, + }); + assert.equal(r.ok, false); + assert.equal(r.code, "BAD_PROTOCOL"); + }); + + test("missing apiKey on byok returns INVALID_KEY without a fetch", async () => { + const r = await providersConfig.testProvider({ + protocol: "openai", + auth: { type: "byok", apiKey: "" }, + }); + assert.equal(r.ok, false); + assert.equal(r.code, "INVALID_KEY"); + }); + + test("short apiKey on byok returns INVALID_KEY without a fetch", async () => { + const r = await providersConfig.testProvider({ + protocol: "openai", + auth: { type: "byok", apiKey: "short" }, + }); + assert.equal(r.ok, false); + assert.equal(r.code, "INVALID_KEY"); + }); + + test("coding-plan with no apiKey but a baseURL passes local validation", async () => { + // We can't reach a real network in this test, so we point at a + // port that won't accept connections (port 1, reserved). + // PROBE_FAILED is expected (no server), but the test confirms + // that INVALID_KEY was NOT the gate — i.e. local validation + // allowed the probe to fire. The structured error code is the + // discriminator. `timeoutMs: 200` keeps the suite fast when + // the OS refuses the connection instantly (TCP RST → fetch + // rejects with ECONNREFUSED) — without it, a silent blackhole + // would burn the full 8s default. + const r = await providersConfig.testProvider({ + protocol: "openai", + auth: { type: "coding-plan", apiKey: "", baseURL: "http://127.0.0.1:1" }, + timeoutMs: 200, + }); + assert.equal(r.ok, false); + assert.equal(r.code, "PROBE_FAILED", "validation passed → fetch attempted → probe failed"); + }); +}); \ No newline at end of file diff --git a/packages/webui/test/routes/providers.check.mjs b/packages/webui/test/routes/providers.check.mjs new file mode 100644 index 00000000..65997db4 --- /dev/null +++ b/packages/webui/test/routes/providers.check.mjs @@ -0,0 +1,432 @@ +// webui/test/routes/providers.check.mjs +// Route-level tests for server/routes/providers.js — +// handleGetProviders, handlePutProviders, handleTestProvider. +// +// Why this test exists: +// - apiKey masking is a security contract — every response path +// MUST return the masked form, never plaintext. The tests pin +// the rule with concrete-string checks on every response shape. +// - The PUT handler triggers an SSE broadcast; the test asserts +// the broadcast payload is masked and the immediate effect on +// /api/models is observable (hot reload). +// - The probe handler rejects malformed keys locally — no +// network call when the key shape is bad. +// +// Test strategy: NO setupMocks. routes/providers.js only depends on +// node:fs + the providers-config module (which has its own state). +// Each test sets MCODE_WEBUI_DATA_DIR / MCODE_WEBUI_MODELS_CONFIG +// to per-test tmp paths so the suite doesn't touch the operator's +// real config (the same isolation contract enforced by +// scripts/test-isolation-lint.check.mjs for server-spawning tests). + +import { test, describe, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; + +const providersRoute = await import(absPath("routes/providers.js")); +const providersConfig = await import(absPath("lib/providers-config.js")); + +let _tmpDataDir; +let _tmpCwd; +let _origDataDir; +let _origCwdEnv; +let _origCwd; + +before(async () => { + _tmpDataDir = mkdtempSync(join(tmpdir(), "webui-providers-route-")); + _tmpCwd = mkdtempSync(join(tmpdir(), "webui-providers-route-cwd-")); + _origDataDir = process.env.MCODE_WEBUI_DATA_DIR; + _origCwdEnv = process.env.MCODE_WEBUI_MODELS_CONFIG; + _origCwd = process.cwd(); + process.env.MCODE_WEBUI_DATA_DIR = _tmpDataDir; + process.env.MCODE_WEBUI_MODELS_CONFIG = ""; + process.chdir(_tmpCwd); +}); + +after(async () => { + if (_origDataDir === undefined) delete process.env.MCODE_WEBUI_DATA_DIR; + else process.env.MCODE_WEBUI_DATA_DIR = _origDataDir; + if (_origCwdEnv === undefined) delete process.env.MCODE_WEBUI_MODELS_CONFIG; + else process.env.MCODE_WEBUI_MODELS_CONFIG = _origCwdEnv; + try { process.chdir(_origCwd); } catch {} + if (_tmpDataDir) try { rmSync(_tmpDataDir, { recursive: true, force: true }); } catch {} + if (_tmpCwd) try { rmSync(_tmpCwd, { recursive: true, force: true }); } catch {} +}); + +beforeEach(() => { + const cwdFile = join(_tmpCwd, "models.json"); + if (existsSync(cwdFile)) rmSync(cwdFile); + const userFile = join(_tmpDataDir, "providers.json"); + if (existsSync(userFile)) rmSync(userFile); +}); + +function fakeReq(body) { + return Readable.from([Buffer.from(JSON.stringify(body), "utf8")]); +} +function fakeRes() { + return { + _status: null, + _headers: null, + _body: null, + writeHead(s, h) { this._status = s; if (h) this._headers = h; }, + end(b) { this._body = b; }, + }; +} +function getBody(res) { + return JSON.parse(res._body); +} + +// ===================================================================== +// handleGetProviders — apiKey NEVER plaintext. +// ===================================================================== + +describe("handleGetProviders — /api/providers GET", () => { + test("empty config returns ok + empty providers + sources", () => { + const res = fakeRes(); + providersRoute.handleGetProviders(null, res, {}); + assert.equal(res._status, 200); + const body = getBody(res); + assert.equal(body.ok, true); + assert.equal(body.version, 2); + assert.deepEqual(body.providers, []); + assert.ok(body.sources, "sources object present"); + assert.ok(body.userPath, "userPath present"); + }); + + test("user-level file is read on every call (hot reload)", () => { + writeFileSync( + join(_tmpDataDir, "providers.json"), + JSON.stringify({ + version: 2, + providers: [ + { + id: "u1", + label: "User One", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaaa" }, + models: [{ id: "m1" }], + }, + ], + }), + ); + const res = fakeRes(); + providersRoute.handleGetProviders(null, res, {}); + const body = getBody(res); + assert.equal(body.providers.length, 1); + assert.equal(body.providers[0].id, "u1"); + assert.equal(body.providers[0].label, "User One"); + }); + + test("apiKey is masked in every provider (no plaintext anywhere)", () => { + const key = "sk-realkey-this-is-the-secret-1234"; + writeFileSync( + join(_tmpDataDir, "providers.json"), + JSON.stringify({ + version: 2, + providers: [ + { + id: "p1", + label: "P1", + protocol: "anthropic", + auth: { type: "byok", apiKey: key }, + models: [{ id: "m1" }], + }, + { + id: "p2", + label: "P2", + protocol: "gemini", + auth: { type: "byok", apiKey: "sk-realkey-other-secret-9999" }, + models: [], + }, + ], + }), + ); + const res = fakeRes(); + providersRoute.handleGetProviders(null, res, {}); + const body = getBody(res); + // Pinned: the plaintext key MUST NOT appear in any response shape. + const json = res._body; + assert.equal(json.includes(key), false, "plaintext apiKey never appears"); + assert.equal(json.includes("realkey"), false, "no plaintext material"); + // Masked shape is correct. + const p1 = body.providers.find((p) => p.id === "p1"); + assert.ok(p1.auth.apiKeyMasked, "apiKeyMasked field present"); + assert.equal(p1.auth.apiKeyMasked.includes("realkey"), false); + assert.equal(p1.auth.hasKey, true); + // baseURL is kept (operators need it for debug); apiKey is not. + assert.equal(p1.auth.baseURL, ""); + }); + + test("sources.{env,cwd,user} point at the resolved paths", () => { + const envFile = join(_tmpCwd, "env.json"); + writeFileSync(envFile, JSON.stringify({ providers: [] })); + process.env.MCODE_WEBUI_MODELS_CONFIG = envFile; + try { + const res = fakeRes(); + providersRoute.handleGetProviders(null, res, {}); + const body = getBody(res); + assert.equal(body.sources.env, envFile, "env override is reported"); + // When env override is set, the cwd path is NOT read — the + // env override IS the cwd path. The cwd key is null in that case. + assert.equal(body.sources.cwd, null); + // user-level path is always reported. + assert.ok(body.sources.user.endsWith("providers.json")); + } finally { + delete process.env.MCODE_WEBUI_MODELS_CONFIG; + } + }); +}); + +// ===================================================================== +// handlePutProviders — validate, persist, hot reload. +// ===================================================================== + +describe("handlePutProviders — /api/providers PUT", () => { + test("valid body persists to user-level file and returns masked shape", async () => { + const res = fakeRes(); + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "p1", + label: "P1", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaaa" }, + models: [{ id: "m1", label: "M1" }], + }, + ], + }), + res, + {}, + ); + assert.equal(res._status, 200); + const body = getBody(res); + assert.equal(body.ok, true); + // Response is masked. + assert.equal(body.providers[0].auth.apiKeyMasked.includes("realkey"), false); + // Plaintext key NEVER appears anywhere in the response. + assert.equal(res._body.includes("realkey"), false); + // File persisted. + const onDisk = JSON.parse( + readFileSync(providersConfig.getUserLevelPath(), "utf8"), + ); + assert.equal(onDisk.providers[0].id, "p1"); + assert.equal(onDisk.providers[0].auth.apiKey, "sk-realkey-aaaa"); + }); + + test("invalid protocol returns 400 + structured error", async () => { + const res = fakeRes(); + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { id: "p1", protocol: "ollama", auth: { type: "byok" }, models: [] }, + ], + }), + res, + {}, + ); + assert.equal(res._status, 400); + const body = getBody(res); + assert.equal(body.ok, false); + assert.equal(body.code, "BAD_BODY"); + assert.match(body.error, /protocol/); + }); + + test("duplicate provider id is rejected", async () => { + const res = fakeRes(); + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { id: "p1", protocol: "openai", auth: { type: "byok", apiKey: "sk-realkey-aaaa" }, models: [] }, + { id: "p1", protocol: "openai", auth: { type: "byok", apiKey: "sk-realkey-bbbb" }, models: [] }, + ], + }), + res, + {}, + ); + assert.equal(res._status, 400); + }); + + test("missing body returns 400", async () => { + const res = fakeRes(); + // No body at all — readJson() returns {} (empty object). + await providersRoute.handlePutProviders( + Readable.from([Buffer.from("", "utf8")]), + res, + {}, + ); + // The exact status depends on readJson's contract; assert just + // that the handler did not crash and reported an error. + const body = getBody(res); + assert.equal(body.ok, false); + }); + + test("hot reload: a follow-up GET sees the new providers without restart", async () => { + // PUT a provider. + const put = fakeRes(); + await providersRoute.handlePutProviders( + fakeReq({ + version: 2, + providers: [ + { + id: "newprov", + label: "New", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaaa" }, + models: [{ id: "newm" }], + }, + ], + }), + put, + {}, + ); + assert.equal(put._status, 200); + // GET picks it up. + const get = fakeRes(); + providersRoute.handleGetProviders(null, get, {}); + const body = getBody(get); + const found = body.providers.find((p) => p.id === "newprov"); + assert.ok(found, "newprov visible after PUT"); + assert.equal(found.models.length, 1); + }); +}); + +// ===================================================================== +// handleTestProvider — no network for malformed, structured errors. +// ===================================================================== + +describe("handleTestProvider — /api/providers/test POST", () => { + test("unknown protocol returns 400 BAD_PROTOCOL without a fetch", async () => { + const res = fakeRes(); + await providersRoute.handleTestProvider( + fakeReq({ + protocol: "ollama", + auth: { type: "byok", apiKey: "sk-realkey-aaaa" }, + }), + res, + {}, + ); + assert.equal(res._status, 400); + const body = getBody(res); + assert.equal(body.ok, false); + assert.equal(body.code, "BAD_PROTOCOL"); + }); + + test("missing apiKey on byok returns 400 INVALID_KEY without a fetch", async () => { + const res = fakeRes(); + await providersRoute.handleTestProvider( + fakeReq({ + protocol: "openai", + auth: { type: "byok", apiKey: "" }, + }), + res, + {}, + ); + assert.equal(res._status, 400); + const body = getBody(res); + assert.equal(body.code, "INVALID_KEY"); + }); + + test("short apiKey returns 400 INVALID_KEY without a fetch", async () => { + const res = fakeRes(); + await providersRoute.handleTestProvider( + fakeReq({ + protocol: "openai", + auth: { type: "byok", apiKey: "short" }, + }), + res, + {}, + ); + assert.equal(res._status, 400); + const body = getBody(res); + assert.equal(body.code, "INVALID_KEY"); + }); + + test("unreachable baseURL returns 502 PROBE_FAILED (network was attempted)", async () => { + // The validation gate passes; the probe fires; the unreachable + // baseURL yields an error. `timeoutMs` is read by the route + // indirectly through the underlying helper — we set a tiny + // timeout by pointing at a localhost port that nothing is + // listening on (TCP RST comes back almost immediately). + const res = fakeRes(); + await providersRoute.handleTestProvider( + fakeReq({ + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaaa", baseURL: "http://127.0.0.1:1" }, + timeoutMs: 200, + }), + res, + {}, + ); + assert.equal(res._status, 502); + const body = getBody(res); + assert.equal(body.code, "PROBE_FAILED"); + // The error string is the upstream / reformat outcome, NOT the + // local validation message — confirms the validation gate + // didn't reject the request. + assert.match(body.error, /HTTP|ECONNREFUSED|fetch failed|timeout/); + }); + + test("response carries the protocol + a latency marker (good UX)", async () => { + // Even on failure, the response shape is uniform so the UI + // doesn't have to special-case protocols. + const res = fakeRes(); + await providersRoute.handleTestProvider( + fakeReq({ + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-aaaa", baseURL: "http://127.0.0.1:1" }, + timeoutMs: 200, + }), + res, + {}, + ); + const body = getBody(res); + assert.equal(body.protocol, "openai"); + assert.equal(typeof body.latencyMs, "number"); + }); +}); + +// ===================================================================== +// _peekProvidersUpdatedFrame — SSE broadcast payload shape. +// ===================================================================== + +describe("SSE broadcast — providers.updated payload is masked", () => { + test("the named SSE event carries the masked provider shape", () => { + writeFileSync( + join(_tmpDataDir, "providers.json"), + JSON.stringify({ + version: 2, + providers: [ + { + id: "p", + label: "L", + protocol: "openai", + auth: { type: "byok", apiKey: "sk-realkey-secret-1234" }, + models: [{ id: "m" }], + }, + ], + }), + ); + const frame = providersRoute._peekProvidersUpdatedFrame(); + // Plaintext apiKey NEVER in the SSE frame. + assert.equal(frame.includes("realkey"), false); + assert.equal(frame.includes("secret"), false); + assert.match(frame, /^event: providers\.updated\ndata: /); + // The data payload is JSON; verify it parses and contains the + // masked shape. + const dataLine = frame.split("\n").find((l) => l.startsWith("data: ")); + const payload = JSON.parse(dataLine.slice("data: ".length)); + assert.equal(payload.providers[0].auth.apiKeyMasked.includes("realkey"), false); + assert.equal(payload.providers[0].auth.hasKey, true); + }); +}); \ No newline at end of file diff --git a/packages/webui/test/server/app-hono.test.js b/packages/webui/test/server/app-hono.test.js index a27f6f18..bf230135 100644 --- a/packages/webui/test/server/app-hono.test.js +++ b/packages/webui/test/server/app-hono.test.js @@ -79,6 +79,9 @@ describe("app.js — migration ledger", () => { "POST /api/permissions", "GET /api/permissions-modes", "POST /api/answer", + "GET /api/providers", + "PUT /api/providers", + "POST /api/providers/test", "POST /api/debug/inject", "GET /api/debug/state", "POST /api/protocol/set-mode", diff --git a/release/public-source.json b/release/public-source.json index db63218c..00325105 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3406,6 +3406,7 @@ "packages/webui/server/lib/mcode-session-delete.js", "packages/webui/server/lib/models.js", "packages/webui/server/lib/port.js", + "packages/webui/server/lib/providers-config.js", "packages/webui/server/lib/quota-forecast.js", "packages/webui/server/lib/rate-limit.js", "packages/webui/server/lib/read-json.js", @@ -3434,6 +3435,7 @@ "packages/webui/server/routes/health.js", "packages/webui/server/routes/model.js", "packages/webui/server/routes/protocol.js", + "packages/webui/server/routes/providers.js", "packages/webui/server/routes/sessions.js", "packages/webui/server/routes/settings.js", "packages/webui/server/routes/state.js", @@ -3500,6 +3502,7 @@ "packages/webui/test/lib/mcode-session-delete.test.js", "packages/webui/test/lib/models.test.js", "packages/webui/test/lib/port.test.js", + "packages/webui/test/lib/providers-config.test.js", "packages/webui/test/lib/quota-forecast-edge.test.js", "packages/webui/test/lib/quota-forecast.test.js", "packages/webui/test/lib/rate-limit-edge.test.js", @@ -3537,6 +3540,7 @@ "packages/webui/test/routes/health.check.mjs", "packages/webui/test/routes/model.check.mjs", "packages/webui/test/routes/protocol.check.mjs", + "packages/webui/test/routes/providers.check.mjs", "packages/webui/test/routes/sessions-search.check.mjs", "packages/webui/test/routes/sessions-switch.check.mjs", "packages/webui/test/routes/sessions.check.mjs",