feat(webui): real cross-provider switching — sync providers into the engine registry - #39
Merged
Merged
Conversation
…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.
…nfig.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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes the user's "不太完善" feedback: provider model selection was UI-only —
applyRecordedModelonly matched the engine's own model catalogue and silently skipped provider models (engine kept its default). Cross-provider switching is now REAL.engine-provider-sync.js): eligible providers (byok, key + baseURL) project into the engine'scustom_providerYAML tree (atomic tmp+rename, 0600 — plaintext keys live only in providers.json + engine config, zero plaintext in responses/logs/SSE). Hot-apply on PUT/enable; singleton restarted; fresh prompt subprocesses re-read. Protocol→api: openai→openai-completions, anthropic→anthropic-messages, gemini→openai-completions (documented)._webui_owned: truefingerprint; webui updates/deletes only its own entries; operator-managedcustom_providerentries are PRESERVED verbatim (previously the sync replaced the whole tree — a data-destruction class issue, live-verified and closed); response carrieskeys+preserved.applyRecordedModelresolvescustom_provider:<key>/<model>→ engine wire formatm:custom_provider%3A<key>:<model>:v:(old test fixtures mocked an unrealistic wire format — now calibrated to the engine's real ids). Selecting a provider model makes the engine POST to that provider's baseURL with that provider's key.Acceptance (independent agent, 2 rounds)
Round 1 PASS-WITH-CONCERNS: engine's own CLI (
mcode provider list) parses and registers the webui-written YAML through the real code path (key attached, masked display); the final HTTP hop was wire-captured in the dev env (engine POSTedbody.model=glm-5.3withAuthorization: Bearer <provider key>to a fake endpoint — proof of real switching); tree-replacement destruction concern + 0600 flagged. Round 2 PASS after hardening: foreign-entry survival pinned (would fail on the old replace-all code), marker inert in the engine parser (source-checked). Gates: sync tests 40/40 ×3 · test:webui 1606/1608 (2 pre-existing skips) · test:webapp 264/264 · typecheck 0 · build ✓.Full
pnpm verifydeferred to CI.