Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
491 changes: 491 additions & 0 deletions packages/webui/server/lib/engine-provider-sync.js

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion packages/webui/server/lib/mcode-acp.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ function matchesModelId(recorded, engineCurrent, modelOption) {
* after the last separator in the recorded id) matching exactly one
* option → return that option's `value`;
* - multiple matches or none → null (caller skips).
*
* Ticket 05: the bare-name match is case-insensitive. The engine
* populates `option.name` from the user-supplied model label (e.g.
* `GLM-5.3` for a custom provider whose label happens to differ in
* case from the model id), while the webui records the model id in
* `cs.model.name` (e.g. `glm-5.3`). A strict comparison would skip
* the apply and leave the engine on its default. The recorded id is
* authoritative — when only one option matches case-insensitively,
* that option is the right target. (Multiple case-insensitive
* matches still returns null; ambiguity is ambiguity.)
*/
function resolveModelId(recorded, modelOption) {
if (!modelOption || !Array.isArray(modelOption.options)) return null;
Expand All @@ -199,7 +209,9 @@ function resolveModelId(recorded, modelOption) {
if (o.value === recorded) return o.value;
}
const bareName = lastSegment(recorded);
const matches = options.filter((o) => o.name === bareName);
const matches = options.filter(
(o) => typeof o.name === "string" && o.name.toLowerCase() === bareName.toLowerCase(),
);
if (matches.length === 1) return matches[0].value;
return null;
}
Expand Down
71 changes: 70 additions & 1 deletion packages/webui/server/routes/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ import {
loadUserLevelProviders,
normaliseProvider,
} from "../lib/providers-config.js";
import {
syncProvidersToEngine,
syncProvidersFromPutBody,
} from "../lib/engine-provider-sync.js";
import {
PROVIDER_PRESETS,
publicPresetView,
Expand All @@ -67,6 +71,7 @@ import {
} from "../lib/provider-presets.js";
import { pushStateFor, sseByCid } from "../lib/state-bus.js";
import { readJson } from "../lib/read-json.js";
import { shutdownMcodeAcpSingleton } from "../lib/acp-client.js";

/**
* GET /api/providers — masked catalogue + resolved-layer summary.
Expand Down Expand Up @@ -146,12 +151,13 @@ export async function handlePutProviders(req, res, _ctx) {
// or non-array providers list is an error the original validation
// surfaces as BAD_BODY, and we must not change that behaviour.
const incomingProviders = Array.isArray(parsed.providers) ? parsed.providers : null;
const existingUserLevel = loadUserLevelProviders();
const toWrite =
incomingProviders === null
? parsed
: {
...parsed,
providers: applyKeepKeyConvention(loadUserLevelProviders(), incomingProviders),
providers: applyKeepKeyConvention(existingUserLevel, incomingProviders),
};
const result = writeProvidersConfig(toWrite);
if (!result.ok) {
Expand All @@ -161,6 +167,26 @@ const result = writeProvidersConfig(toWrite);
JSON.stringify({ ok: false, code: result.code, error: result.error }),
);
}
// ticket 05: project the same providers into the engine's
// `custom_provider` tree so the engine's `model` config option
// (packages/tui/src/acp/control-state.ts) advertises them and
// `applyRecordedModel` can resolve them. We run the sync AFTER
// the user-level file is durable so a sync failure cannot leave the
// engine advertising something the user-level file does not have.
// Surface the error in the response (acceptance criterion 1) but
// keep the response status 200 — the user-level write succeeded,
// the dialog refresh reflects the new catalogue, and the operator
// can retry the sync on the next PUT. The `engineSync` field lets
// the UI surface a non-blocking warning.
const engineSync = await syncProvidersToEngine(result.providers);
if (engineSync.ok) {
// Tear down the singleton subprocess so the next operation
// spawns a fresh one that reads the new config.yaml. Brand-new
// prompt subprocesses spawned by `runMcodeAcp` already pick up
// the latest config; this is only about the singleton used for
// session/list, commands probe, and account status.
shutdownMcodeAcpSingleton();
}
// 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
Expand All @@ -177,6 +203,22 @@ const result = writeProvidersConfig(toWrite);
ok: true,
providers: result.providers.map(publicView),
path: result.path,
...(engineSync.ok
? {
engineSync: {
ok: true,
written: engineSync.written,
keys: engineSync.keys,
},
}
: {
engineSync: {
ok: false,
code: engineSync.code,
error: engineSync.error,
},
warning: `engine config sync failed: ${engineSync.error}`,
}),
}),
);
}
Expand Down Expand Up @@ -431,6 +473,17 @@ export async function handleEnablePreset(req, res, _ctx, params = {}) {
JSON.stringify({ ok: false, code: result.code, error: result.error }),
);
}
// ticket 05: project to the engine's custom_provider tree as
// well. The preset itself lands without an apiKey (the user must
// supply one), so the sync sees an "enabled without key" record
// and correctly skips it — but the same shape runs through the
// PUT path's logic when the user later supplies a key and saves
// again. We still call the sync so a non-preset byok provider the
// user already has flows through with no behaviour change.
const engineSync = await syncProvidersToEngine(result.providers);
if (engineSync.ok) {
shutdownMcodeAcpSingleton();
}
// Broadcast — same SSE event PUT uses. The UI's model picker
// re-fetches /api/models after this, picking up the new
// template-driven entries.
Expand All @@ -446,6 +499,22 @@ export async function handleEnablePreset(req, res, _ctx, params = {}) {
alreadyEnabled: false,
provider: publicView(persisted),
path: result.path,
...(engineSync.ok
? {
engineSync: {
ok: true,
written: engineSync.written,
keys: engineSync.keys,
},
}
: {
engineSync: {
ok: false,
code: engineSync.code,
error: engineSync.error,
},
warning: `engine config sync failed: ${engineSync.error}`,
}),
}),
);
}
Expand Down
Loading
Loading