fix(custom-model): address PR #430 pre-merge review (Ark0N)

Four blockers from the 2026-09-18 review:

- PUT /api/model-endpoints/:id now merges modelContextLengths/
  modelSizesGB back in from the stored record instead of trusting the
  editor's body, so renaming an endpoint or changing its default model
  no longer silently drops the context-window floor check and
  CLAUDE_CODE_MAX_CONTEXT_TOKENS injection.
- custom-model:swapped-out is now session-scoped (added to
  SESSION_PREFIXES) instead of broadcasting to every connected client.
- The quick-start custom-model path now hands setCustomModel() only
  the endpoint's own injected env vars, not the full merged set,
  matching the restart-in-place path — the full set put
  CLAUDE_CODE_EFFORT_LEVEL back after the Session constructor had
  already stripped it.
- The quick-start launchModel override for pi/grok/omp is now applied
  generically via the registry's legacyConfigField, mirroring
  Session._withCustomModelLaunchModel, instead of three hardcoded
  mode === '<id>' branches a future CLI's injection recipe would miss.

Also scopes the sticky-toast default (item 5): reverted the blanket
"all error toasts are sticky" default, which had no container cap or
eviction, back to a flat 3s; the one message that needs a moment to
read (a failed custom-model apply) now passes an explicit
duration: 0 at its own call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ea59JhUmHBm1gRCsiYF33R
This commit is contained in:
Devvyn
2026-09-18 20:16:07 +08:00
co-authored by Claude Sonnet 5
parent e2034177c5
commit 1f32128ca9
6 changed files with 59 additions and 34 deletions
+11 -11
View File
@@ -5485,19 +5485,19 @@ Object.assign(CodemanApp.prototype, {
}, },
/** /**
* `duration` defaults to 0 (sticky, no auto-dismiss) for `error` toasts and * `duration` defaults to 3000ms for every toast type. A message worth
* 3000ms for everything else — an error worth a distinct visual style is * reading rather than glancing at (e.g. "Session started on the native
* also worth reading before it vanishes, which a fixed 3s auto-dismiss * backend — could not apply the custom endpoint: <the actual reason>")
* does not guarantee: "Session started on the native backend — could not * passes an explicit `opts.duration: 0` at its own call site instead of
* apply the custom endpoint: <the actual reason>" is exactly the kind of * widening the default: this used to default every `error` toast to
* message that needs a moment to read, not a glance. Every toast gets an * sticky, and with no cap on `.toast-container` and no eviction, a
* explicit close button regardless of duration, since a sticky one with no * repeatedly failing path (a flapping SSE reconnect, a poll loop) stacked
* way to dismiss it would just pile up. A caller can still override either * sticky toasts off the bottom of the viewport where they could not be
* default via `opts.duration` (e.g. a deliberately brief success toast, or * read or dismissed. Every toast still gets an explicit close button
* a non-error one that should also stay put). * regardless of duration.
*/ */
showToast(message, type = 'info', opts = {}) { showToast(message, type = 'info', opts = {}) {
const { duration = type === 'error' ? 0 : 3000, action } = opts; const { duration = 3000, action } = opts;
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast toast-${type}`; toast.className = `toast toast-${type}`;
+3 -1
View File
@@ -962,7 +962,9 @@ Object.assign(CodemanApp.prototype, {
if (!ok || !data || data.success === false) { if (!ok || !data || data.success === false) {
switchingToast?.dismiss(); switchingToast?.dismiss();
const detail = data?.error ? `: ${data.error}` : res ? ` (HTTP ${res.status})` : ' (request failed)'; 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'); this.showToast(`Session started on the native backend — could not apply the custom endpoint${detail}`, 'error', {
duration: 0,
});
return; return;
} }
+13 -1
View File
@@ -108,6 +108,18 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost)
return incoming.apiKey ? incoming : { ...incoming, apiKey: existing.apiKey }; return incoming.apiKey ? incoming : { ...incoming, apiKey: existing.apiKey };
} }
/**
* `modelContextLengths`/`modelSizesGB` are server-populated by discovery, never
* user-entered, and PUT replaces the whole record — so merge them back in from the
* stored host rather than trust whatever the editor's body carried (or omitted).
* The editor only ever sends `models`/`lastDiscoveredAt` verbatim from its cached
* copy; requiring it to also round-trip these two is exactly the kind of thing a
* future caller forgets, same class of bug `applyStoredApiKey` exists to prevent.
*/
function applyDiscoveredFields(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost {
return { ...incoming, modelContextLengths: existing.modelContextLengths, modelSizesGB: existing.modelSizesGB };
}
function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Record<string, string> { function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Record<string, string> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
const apiKey = host.apiKey?.trim(); const apiKey = host.apiKey?.trim();
@@ -705,7 +717,7 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
const index = hosts.findIndex((item) => item.id === id); const index = hosts.findIndex((item) => item.id === id);
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
const host = applyStoredApiKey(incoming, hosts[index]); const host = applyDiscoveredFields(applyStoredApiKey(incoming, hosts[index]), hosts[index]);
const next = [...hosts]; const next = [...hosts];
next[index] = host; next[index] = host;
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next); await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
+31 -19
View File
@@ -3576,6 +3576,12 @@ export function registerSessionRoutes(
// a lighter version of them, since this is the same server-side authority reached a // a lighter version of them, since this is the same server-side authority reached a
// different way, not a separate, less-checked path. // different way, not a separate, less-checked path.
let qsCustomModelEnvOverrides = qsGatedEnvOverrides; let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
// setCustomModel() bookkeeping must be given below. The Session constructor already
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
let qsCustomModelLaunchModel: string | undefined; let qsCustomModelLaunchModel: string | undefined;
let qsCustomModelSessionId: string | undefined; let qsCustomModelSessionId: string | undefined;
let qsCustomModelSwapInProgress = false; let qsCustomModelSwapInProgress = false;
@@ -3670,6 +3676,7 @@ export function registerSessionRoutes(
} }
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides }; qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
qsCustomModelLaunchModel = cmApplied.launchModel; qsCustomModelLaunchModel = cmApplied.launchModel;
qsCustomModelBookkeeping = { qsCustomModelBookkeeping = {
endpointId: cmEndpoint.id, endpointId: cmEndpoint.id,
@@ -3689,7 +3696,7 @@ export function registerSessionRoutes(
} }
} }
const session = new Session({ const qsSessionOptions: ConstructorParameters<typeof Session>[0] = {
id: qsCustomModelSessionId, id: qsCustomModelSessionId,
workingDir: resolvedCasePath, workingDir: resolvedCasePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '', name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
@@ -3705,23 +3712,10 @@ export function registerSessionRoutes(
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined, codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined, geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined, antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
mode === 'pi' grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
? qsCustomModelLaunchModel !== undefined
? { ...(qsGatedPiConfig ?? {}), model: qsCustomModelLaunchModel }
: qsGatedPiConfig
: undefined,
grokConfig:
mode === 'grok'
? qsCustomModelLaunchModel !== undefined
? { ...(qsGatedGrokConfig ?? {}), model: qsCustomModelLaunchModel }
: qsGatedGrokConfig
: undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
ompConfig: ompConfig: qsResolvedOmpConfig,
mode === 'omp' && qsCustomModelLaunchModel !== undefined
? { ...(qsResolvedOmpConfig ?? {}), model: qsCustomModelLaunchModel }
: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides, envOverrides: qsCustomModelEnvOverrides,
effort, effort,
remote, remote,
@@ -3729,7 +3723,25 @@ export function registerSessionRoutes(
resumeSessionId: dockerResumeId, resumeSessionId: dockerResumeId,
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit, tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
parentSessionId: qsParentSessionId, parentSessionId: qsParentSessionId,
}); };
// Force the custom-model selection's launchModel (pi/omp `custom/<id>`, grok's
// `[model.<name>]` block name) onto whichever config field the registry says the
// CLI's `model` launch param lives in — mirrors Session._withCustomModelLaunchModel,
// which the restart-in-place path already uses, rather than a hardcoded per-CLI
// branch here that a CLI landing its injection recipe later would silently miss.
if (qsCustomModelLaunchModel !== undefined) {
const qsCustomModelField = getCli(mode)?.launch.legacyConfigField;
if (qsCustomModelField) {
const qsSessionOptionsBag = qsSessionOptions as unknown as Record<string, unknown>;
qsSessionOptionsBag[qsCustomModelField] = {
...((qsSessionOptionsBag[qsCustomModelField] as Record<string, unknown>) ?? {}),
model: qsCustomModelLaunchModel,
};
} else {
qsSessionOptions.model = qsCustomModelLaunchModel;
}
}
const session = new Session(qsSessionOptions);
// Records the selection for session.customModel/getCustomModelForPersist() and future // Records the selection for session.customModel/getCustomModelForPersist() and future
// clear/switch calls — the actual env vars and launch-model config are already part of // clear/switch calls — the actual env vars and launch-model config are already part of
@@ -3737,7 +3749,7 @@ export function registerSessionRoutes(
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no // this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
// tmux IO of its own (see its own doc comment in session.ts). // tmux IO of its own (see its own doc comment in session.ts).
if (qsCustomModelBookkeeping) { if (qsCustomModelBookkeeping) {
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelEnvOverrides); session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
} }
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
+1
View File
@@ -2373,6 +2373,7 @@ export class WebServer extends EventEmitter {
'scheduled:', 'scheduled:',
'team:', 'team:',
'case:', 'case:',
'custom-model:',
]; ];
if (SESSION_PREFIXES.some((p) => event.startsWith(p))) { if (SESSION_PREFIXES.some((p) => event.startsWith(p))) {
const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string } }; const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string } };
@@ -80,8 +80,6 @@ const ALLOWED_BRANCHES: Record<string, string> = {
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'omp'":
'legacy <Mode>Config plumbing (custom-model launchModel merge onto ompConfig, same selection resolveOmpConfigForCreate already makes internally)',
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',