Skip to content

Commit c750be3

Browse files
authored
feat(webui): real cross-provider switching — sync providers into the engine registry (#39)
* feat(webui): cross-provider switching — sync providers to engine and surface configured models Tickets 01-04 cataloged providers in the webui but never pushed them to the engine's custom_provider registry. Selecting a provider model in the dialog was UI-only — applyRecordedModel had nothing to match against and the engine kept its default. This commit closes ticket 05 by adding the engine-side projection: * packages/webui/server/lib/engine-provider-sync.js: pure helpers (providerKeyFromId, modelKeyFromId, toEngineCustomProvider) plus the sync entry that writes the engine's custom_provider tree via an atomic YAML rewrite. Only byok entries with both apiKey and baseURL flow through; coding-plan goes through the engine's OAuth flows. Reserved engine ids ('minimax', 'minimax_api', 'provider', 'custom_provider') get a deterministic '-byok' suffix to avoid shadowing. * packages/webui/server/routes/providers.js: PUT and preset enable handlers call syncProvidersToEngine after the user-level write, then shutdownMcodeAcpSingleton so the next operation spawns a fresh subprocess that reads the new config. The sync result is included in the response (engineSync.ok / engineSync.keys) so the UI can surface a non-blocking warning if the engine write fails. * packages/webui/server/lib/mapplyRecordedModel: bare-name option match is now case-insensitive. The engine populates option.name from the user-supplied label (e.g. 'GLM-5.3' for a custom provider whose label differs in case from the model id 'glm-5.3'); a strict comparison would skip the apply and leave the engine on its default. * packages/webui/webapp/components/provider-management.tsx: provider card now shows the configured models as chips directly, so the operator sees what's wired up without expanding the editor. * Test updates: - packages/webui/test/lib/engine-provider-sync.test.js pins the v2 → engine custom_provider mapping, the keep-key convention applied to the PUT body, atomic write semantics, and operator-section preservation. - packages/webui/test/lib/mcode-acp-note.test.js updates the model option fixtures to the actual engine wire format ('m:<provider>:<model>:u|v:<variant>') and pins the cross-provider resolution path. * release/public-source.json regenerated (new files + 3 modified). Self-check evidence (18112 / 18113, isolated data dir under /tmp/dev-cp, engine subprocess pointed at a fake HTTP server on :19999): 1. PUT /api/providers with byok-zhipu → engine config.yaml gains custom_provider.byok-zhipu with the GLM-5.3 / GLM-4.6 entries; response carries engineSync: { ok: true, keys: ['byok-zhipu'] }. 2. POST /api/send with model=byok-zhipu/glm-5.3 → engine subprocess POSTs http://127.0.0.1:19999/v1/chat/completions with body.model 'glm-5.3' and Authorization 'Bearer sk-zhipu-fake-for-test' (the BYOK provider's apiKey, NOT the builtin MiniMax apiKey). Assistant returns 'OK from fake server (model=glm-5.3)'. 3. Hot-add: PUT with a third model (GLM-4-Flash) → /api/models and the provider card reflect the new chip without restart. 4. Settings provider card: 'Zhipu BYOK 自定义 openai · byok · key set GLM-5.3 GLM-4.6 GLM-4-Flash (HOT-ADDED)' visible without opening the editor. Gates: typecheck clean, test:webui 1602/1604 pass (2 unrelated skips), test:webapp 264/264 pass, build:webui OK, check:source OK. * fix(webui): preserve foreign engine custom_provider entries; write config.yaml 0600 Ticket 05 acceptance round: merge-over-replace, not replace-everything. The previous sync algorithm replaced the engine's whole custom_provider tree. A manually-added operator entry (e.g. via 'mcode provider add' on the engine CLI) was silently DROPPED on the next webui PUT — same destruction class as the absent-key wipe we hardened in ticket 03. Ownership rule (on-disk fingerprint): Every entry webui writes carries _webui_owned: true. The engine ignores unknown fields (js-yaml parses the whole record and the downstream consumers read named fields only), so the marker is engine-safe. Merge algorithm: existing engine keys ∩ eligible webui keys → UPDATE in place existing engine keys ∖ eligible webui keys: _webui_owned === true → DELETE (webui-owned, operator removed the webui provider) _webui_owned !== true (or missing) → PRESERVE (foreign; operator-owned) eligible webui keys ∖ existing engine keys → ADD A foreign 'manual_only' provider now survives every webui PUT, including the empty-eligible case (PUTting an ineligible-only catalogue no longer wipes foreign entries). The sync response carries both 'keys' (the webui keys touched) and 'preserved' (the foreign keys left untouched) so the route can surface the operator's manual providers in the UI. File mode 0600: config.yaml carries plaintext apiKeys. The engine's own updateLocalByokConfig already pins 0600; the helper now matches. Three-step atomic write (0600 tmp, rename, 0600 chmod) — same dance as the engine's writer. Tests added (engine-provider-sync.test.js): - foreign entry survives a sync that has webui providers (whitelist preserves it). - empty eligible list does NOT wipe foreign (the destruction class closed here). - webui-managed entry whose provider is removed is dropped; foreign sibling survives. - webui-managed entry update replaces data, keeps the marker. - no-op skip when nothing changed (no mtime churn, no needless chmod). - config.yaml mode is 0600 (POSIX-only assertion, skipped on Windows). Gates: typecheck clean, test:webui 1606/1608 pass (2 pre-existing skips), engine-provider-sync tests 40/40 pass three consecutive runs.
1 parent 67ad609 commit c750be3

7 files changed

Lines changed: 1420 additions & 59 deletions

File tree

‎packages/webui/server/lib/engine-provider-sync.js‎

Lines changed: 491 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/webui/server/lib/mcode-acp.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,16 @@ function matchesModelId(recorded, engineCurrent, modelOption) {
188188
* after the last separator in the recorded id) matching exactly one
189189
* option → return that option's `value`;
190190
* - multiple matches or none → null (caller skips).
191+
*
192+
* Ticket 05: the bare-name match is case-insensitive. The engine
193+
* populates `option.name` from the user-supplied model label (e.g.
194+
* `GLM-5.3` for a custom provider whose label happens to differ in
195+
* case from the model id), while the webui records the model id in
196+
* `cs.model.name` (e.g. `glm-5.3`). A strict comparison would skip
197+
* the apply and leave the engine on its default. The recorded id is
198+
* authoritative — when only one option matches case-insensitively,
199+
* that option is the right target. (Multiple case-insensitive
200+
* matches still returns null; ambiguity is ambiguity.)
191201
*/
192202
function resolveModelId(recorded, modelOption) {
193203
if (!modelOption || !Array.isArray(modelOption.options)) return null;
@@ -199,7 +209,9 @@ function resolveModelId(recorded, modelOption) {
199209
if (o.value === recorded) return o.value;
200210
}
201211
const bareName = lastSegment(recorded);
202-
const matches = options.filter((o) => o.name === bareName);
212+
const matches = options.filter(
213+
(o) => typeof o.name === "string" && o.name.toLowerCase() === bareName.toLowerCase(),
214+
);
203215
if (matches.length === 1) return matches[0].value;
204216
return null;
205217
}

‎packages/webui/server/routes/providers.js‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ import {
5959
loadUserLevelProviders,
6060
normaliseProvider,
6161
} from "../lib/providers-config.js";
62+
import {
63+
syncProvidersToEngine,
64+
syncProvidersFromPutBody,
65+
} from "../lib/engine-provider-sync.js";
6266
import {
6367
PROVIDER_PRESETS,
6468
publicPresetView,
@@ -67,6 +71,7 @@ import {
6771
} from "../lib/provider-presets.js";
6872
import { pushStateFor, sseByCid } from "../lib/state-bus.js";
6973
import { readJson } from "../lib/read-json.js";
74+
import { shutdownMcodeAcpSingleton } from "../lib/acp-client.js";
7075

7176
/**
7277
* GET /api/providers — masked catalogue + resolved-layer summary.
@@ -146,12 +151,13 @@ export async function handlePutProviders(req, res, _ctx) {
146151
// or non-array providers list is an error the original validation
147152
// surfaces as BAD_BODY, and we must not change that behaviour.
148153
const incomingProviders = Array.isArray(parsed.providers) ? parsed.providers : null;
154+
const existingUserLevel = loadUserLevelProviders();
149155
const toWrite =
150156
incomingProviders === null
151157
? parsed
152158
: {
153159
...parsed,
154-
providers: applyKeepKeyConvention(loadUserLevelProviders(), incomingProviders),
160+
providers: applyKeepKeyConvention(existingUserLevel, incomingProviders),
155161
};
156162
const result = writeProvidersConfig(toWrite);
157163
if (!result.ok) {
@@ -161,6 +167,26 @@ const result = writeProvidersConfig(toWrite);
161167
JSON.stringify({ ok: false, code: result.code, error: result.error }),
162168
);
163169
}
170+
// ticket 05: project the same providers into the engine's
171+
// `custom_provider` tree so the engine's `model` config option
172+
// (packages/tui/src/acp/control-state.ts) advertises them and
173+
// `applyRecordedModel` can resolve them. We run the sync AFTER
174+
// the user-level file is durable so a sync failure cannot leave the
175+
// engine advertising something the user-level file does not have.
176+
// Surface the error in the response (acceptance criterion 1) but
177+
// keep the response status 200 — the user-level write succeeded,
178+
// the dialog refresh reflects the new catalogue, and the operator
179+
// can retry the sync on the next PUT. The `engineSync` field lets
180+
// the UI surface a non-blocking warning.
181+
const engineSync = await syncProvidersToEngine(result.providers);
182+
if (engineSync.ok) {
183+
// Tear down the singleton subprocess so the next operation
184+
// spawns a fresh one that reads the new config.yaml. Brand-new
185+
// prompt subprocesses spawned by `runMcodeAcp` already pick up
186+
// the latest config; this is only about the singleton used for
187+
// session/list, commands probe, and account status.
188+
shutdownMcodeAcpSingleton();
189+
}
164190
// Reload + broadcast. `loadProvidersConfig()` re-reads the file on
165191
// every call (no in-process cache), so a follow-up GET already
166192
// sees the change. The SSE push is the mechanism the UI uses to
@@ -177,6 +203,22 @@ const result = writeProvidersConfig(toWrite);
177203
ok: true,
178204
providers: result.providers.map(publicView),
179205
path: result.path,
206+
...(engineSync.ok
207+
? {
208+
engineSync: {
209+
ok: true,
210+
written: engineSync.written,
211+
keys: engineSync.keys,
212+
},
213+
}
214+
: {
215+
engineSync: {
216+
ok: false,
217+
code: engineSync.code,
218+
error: engineSync.error,
219+
},
220+
warning: `engine config sync failed: ${engineSync.error}`,
221+
}),
180222
}),
181223
);
182224
}
@@ -431,6 +473,17 @@ export async function handleEnablePreset(req, res, _ctx, params = {}) {
431473
JSON.stringify({ ok: false, code: result.code, error: result.error }),
432474
);
433475
}
476+
// ticket 05: project to the engine's custom_provider tree as
477+
// well. The preset itself lands without an apiKey (the user must
478+
// supply one), so the sync sees an "enabled without key" record
479+
// and correctly skips it — but the same shape runs through the
480+
// PUT path's logic when the user later supplies a key and saves
481+
// again. We still call the sync so a non-preset byok provider the
482+
// user already has flows through with no behaviour change.
483+
const engineSync = await syncProvidersToEngine(result.providers);
484+
if (engineSync.ok) {
485+
shutdownMcodeAcpSingleton();
486+
}
434487
// Broadcast — same SSE event PUT uses. The UI's model picker
435488
// re-fetches /api/models after this, picking up the new
436489
// template-driven entries.
@@ -446,6 +499,22 @@ export async function handleEnablePreset(req, res, _ctx, params = {}) {
446499
alreadyEnabled: false,
447500
provider: publicView(persisted),
448501
path: result.path,
502+
...(engineSync.ok
503+
? {
504+
engineSync: {
505+
ok: true,
506+
written: engineSync.written,
507+
keys: engineSync.keys,
508+
},
509+
}
510+
: {
511+
engineSync: {
512+
ok: false,
513+
code: engineSync.code,
514+
error: engineSync.error,
515+
},
516+
warning: `engine config sync failed: ${engineSync.error}`,
517+
}),
449518
}),
450519
);
451520
}

0 commit comments

Comments
 (0)