Skip to content

feat(webui): real cross-provider switching — sync providers into the engine registry - #39

Merged
fengzhi09 merged 2 commits into
mainfrom
feat/cross-provider-switching
Sep 26, 2026
Merged

fengzhi09 merged 2 commits into
mainfrom
feat/cross-provider-switching

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

What

Closes the user's "不太完善" feedback: provider model selection was UI-only — applyRecordedModel only matched the engine's own model catalogue and silently skipped provider models (engine kept its default). Cross-provider switching is now REAL.

  • Engine sync (engine-provider-sync.js): eligible providers (byok, key + baseURL) project into the engine's custom_provider YAML 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).
  • Ownership merge (hardening round): _webui_owned: true fingerprint; webui updates/deletes only its own entries; operator-managed custom_provider entries are PRESERVED verbatim (previously the sync replaced the whole tree — a data-destruction class issue, live-verified and closed); response carries keys+preserved.
  • Real switching: applyRecordedModel resolves custom_provider:<key>/<model> → engine wire format m: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.
  • UI: settings provider card shows configured models as chips directly (criterion 4); dialog shows only configured providers (enabled-without-key greyed).

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 POSTed body.model=glm-5.3 with Authorization: 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 verify deferred to CI.

…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.
@fengzhi09
fengzhi09 merged commit c750be3 into main Sep 26, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the feat/cross-provider-switching branch September 26, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant