feat(custom-model): detect llama-swap model conflicts before switching

Root-caused the user's earlier confusion ('the terminal says opus even though
something is waiting for llama to load'): llama.cpp runs exactly one model at
a time, and llama-swap unloads/reloads it on demand - a swap can take
anywhere from a few seconds to well over a minute, during which a session
looks indistinguishable from one still on the native backend.

1. Feature-detects llama-swap (vs. plain llama.cpp/any OpenAI-compatible
   server) via its own GET /running, which plain llama.cpp has no concept of
   at all. New GET /api/model-endpoints/:id/running-status route exposes this
   read-only, for the frontend's polling loop below.

2. Before applying a selection, POST /api/sessions/:id/custom-model now checks
   what llama-swap currently has loaded. If it differs from the requested
   model AND another live session's own customModel selection is actively
   using that loaded model, the apply is refused with a
   {requiresConfirmation, currentlyLoadedModel, affectedSessions} payload
   instead of silently switching. A "confirmed: true" field on the retry
   skips the check. Switching with nothing else affected proceeds
   immediately, no confirmation asked, only ever when there is something to
   warn about.

3. The frontend (runCustomModelEntry) shows a native confirm() naming the
   affected session(s) and the model they'd lose, matching this codebase's
   existing convention for this class of decision (delete case, kill
   session, etc.) rather than a new modal. On a successful apply the response
   also carries modelSwapInProgress; when true, a new _watchLlamaSwapLoading
   poll shows a sticky "Loading <model>..." toast via the new running-status
   route until llama-swap reports the target model ready (bounded at 2
   minutes), so a prompt sent mid-swap reads as "loading", never as silence
   or an answer from whatever was loaded a moment before.

Checks are read-only against llama-swap's own /running - never /props, which
takes a ?model= and can itself trigger a load as a side effect of asking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
Devvyn
2026-09-16 14:35:07 +08:00
co-authored by Claude Sonnet 5
parent 25f22b9839
commit bcebc81fcd
7 changed files with 493 additions and 8 deletions
+85 -6
View File
@@ -740,17 +740,96 @@ Object.assign(CodemanApp.prototype, {
// unreachable" apart from "the CLI can't be redirected", "not one of the
// discovered models", or "this is a Docker/remote session". Go through the
// raw response here instead so a failure is diagnosable, not just present.
const res = await this._api(`/api/sessions/${sessionId}/custom-model`, {
method: 'POST',
body: { endpointId, modelId },
});
const data = res ? await res.json().catch(() => null) : null;
if (!data || data.success === false) {
let { ok, data, res } = await this._applyCustomModelToSession(sessionId, endpointId, modelId);
// A success body comes back as {success:true, data:{...}} (server.ts's preSerialization
// envelope), but a route-level error is {success:false, error, errorCode} with no nested
// data — createErrorResponse() never wraps one. `payload` below is only ever meaningful
// once `data.success !== false`.
let payload = data?.success !== false ? data?.data : undefined;
// llama-swap runs one model at a time: switching would unload it out from under
// another session actively using it. The route only asks when that's actually true
// (never just because a swap is needed at all) — confirming re-sends the exact same
// call with `confirmed: true` so the route skips the check the second time.
if (ok && payload?.requiresConfirmation) {
const names = payload.affectedSessions.map((s) => s.name || s.id).join(', ');
const proceed = confirm(
`${names} ${payload.affectedSessions.length === 1 ? 'is' : 'are'} currently using ` +
`${payload.currentlyLoadedModel} on this endpoint. Switching to ${modelId} will unload it ` +
`for ${payload.affectedSessions.length === 1 ? 'that session' : 'those sessions'} too. Continue?`
);
if (!proceed) {
this.showToast('Kept the native backend — model switch cancelled', 'info');
return;
}
({ ok, data, res } = await this._applyCustomModelToSession(sessionId, endpointId, modelId, true));
payload = data?.success !== false ? data?.data : undefined;
}
if (!ok || !data || data.success === false) {
const detail = data?.error ? `: ${data.error}` : res ? ` (HTTP ${res.status})` : ' (request failed)';
this.showToast(`Session started on the native backend — could not apply the custom endpoint${detail}`, 'error');
return;
}
this.showToast(`Pointed at ${endpointId} — restarting the session...`, 'info');
// The apply above already succeeded — the session IS pointed at the endpoint — but
// llama-swap itself may still be unloading the old model and loading this one, which
// can take well over a minute. Without this, a prompt sent during that window either
// hangs silently or (the bug this whole feature exists to fix) gets answered by
// whatever was loaded a moment ago, reading as "it's still using the wrong model."
if (payload?.modelSwapInProgress) {
void this._watchLlamaSwapLoading(endpointId, modelId);
}
},
/** POST /api/sessions/:id/custom-model, returning {ok, data, res} rather than throwing —
* see runCustomModelEntry's own comment for why this goes through `_api()` (raw fetch)
* rather than `_apiJson()`: a failure's `error` detail must survive to the caller. */
async _applyCustomModelToSession(sessionId, endpointId, modelId, confirmed) {
const res = await this._api(`/api/sessions/${sessionId}/custom-model`, {
method: 'POST',
body: confirmed ? { endpointId, modelId, confirmed } : { endpointId, modelId },
});
const data = res ? await res.json().catch(() => null) : null;
return { ok: !!res, data, res };
},
/**
* Polls llama-swap's own `/running` (via the read-only running-status route) until
* `modelId` reports `state: 'ready'`, showing a sticky toast the whole time so a slow
* unload/reload (measured well over a minute for a large model) reads as "loading",
* never as silence or a wrong answer from whatever was loaded before. Bounded at 2
* minutes; still not ready by then gets a toast saying so rather than polling forever.
*
* `pollIntervalMs`/`maxWaitMs` exist to let a test drive this in milliseconds instead of
* minutes — real callers never pass them, which is what keeps the defaults live here
* rather than only in a test fixture.
*/
async _watchLlamaSwapLoading(endpointId, modelId, pollIntervalMs = 3000, maxWaitMs = 120000) {
const toast = this.showToast(`Loading ${modelId} on ${endpointId}… this can take a while`, 'info', {
duration: 0,
});
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const status = await this._apiJson(`/api/model-endpoints/${encodeURIComponent(endpointId)}/running-status`);
if (!status) continue; // transient failure — keep waiting rather than giving up early
if (!status.isLlamaSwap) {
// Endpoint changed under us, or wasn't llama-swap after all — nothing more to
// watch for, and not a failure worth a toast of its own.
toast?.dismiss();
return;
}
if (status.running.some((r) => r.model === modelId && r.state === 'ready')) {
toast?.dismiss();
this.showToast(`${modelId} is ready`, 'success', { duration: 2500 });
return;
}
}
toast?.dismiss();
this.showToast(`Still waiting for ${modelId} to finish loading on ${endpointId} — check the llama-swap server`, 'warning');
},
/**