mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
co-authored by
Claude Sonnet 5
parent
e2034177c5
commit
1f32128ca9
+11
-11
@@ -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}`;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
Reference in New Issue
Block a user