Merge pull request #514 from irisitymichaelgrundberg/feat/claude-session-model

feat(sessions): accept a per-session Claude model on POST /api/sessions
This commit is contained in:
Codeman maintainer
2026-10-04 23:24:43 +02:00
12 changed files with 242 additions and 11 deletions
+1
View File
@@ -1827,6 +1827,7 @@ export class Session extends EventEmitter {
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
model: this._model,
customModel: this.customModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
// intent before restarting a crash-looped session. Deliberately NOT restored
+7
View File
@@ -807,6 +807,13 @@ export interface SessionState {
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/**
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
* the app-wide default when there was none. Persisted so a recovered session relaunches on
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
* the CLI's banner reports.
*/
model?: string;
/**
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
+1
View File
@@ -195,6 +195,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
model: saved.model,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
+14 -4
View File
@@ -889,6 +889,15 @@ export function registerSessionRoutes(
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
const body = parseBody(CreateSessionSchema, req.body);
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
// dropped without a word; refuse it before anything is written for the session.
if (body.model && getCli(body.mode ?? 'claude')?.capabilities.model.source !== 'claude-settings-file') {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
);
}
let workingDir = body.workingDir || process.cwd();
let remote = undefined;
@@ -1074,9 +1083,10 @@ export function registerSessionRoutes(
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
// in that CLI's own config object.
// 'claude-settings-file' — claude alone, whose model is written to
// <case>/.claude/settings.local.json rather than passed as
// a flag, so the app-wide default applies here.
// 'claude-settings-file' — claude alone, whose persistent model is written to
// <case>/.claude/settings.local.json (`modelOverride`). A
// per-session `model` from the caller goes out as --model and
// wins; without one, the app-wide default applies.
// 'none' — shell has no model; deepseek's is a composition entry in
// the profile's config tree, not a session field
// (docs/deepseek-integration.md). Both get nothing.
@@ -1087,7 +1097,7 @@ export function registerSessionRoutes(
| string
| undefined)
: modelSource?.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
? body.model || modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
+13
View File
@@ -528,6 +528,19 @@ export const CreateSessionSchema = z.object({
effort: effortLevelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/**
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
* disk. Wins over the app-wide default model. Same character set as the registry's
* `model-claude` pattern, so a value accepted here is never rejected at launch. An empty
* string means no per-session model, as it does for `modelOverride`. Claude only: the route
* refuses it for any other CLI.
*/
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-[\]]+$/)
.or(z.literal(''))
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
+1
View File
@@ -3520,6 +3520,7 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
model: savedState?.model,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though