From 0e26e0d4c50fcba142f31043eda2837f399647f3 Mon Sep 17 00:00:00 2001 From: Star-Star66 <289872070+Star-Star66@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:17:52 +0800 Subject: [PATCH] Persist custom model selection --- src/cli/commands/acp.ts | 6 +++-- src/cli/commands/code.tsx | 4 +-- src/cli/commands/desktop.ts | 3 ++- src/cli/resolve.ts | 2 +- src/cli/ui/App.tsx | 18 ++++++++++++- src/cli/ui/slash/handlers/model.ts | 8 +++++- src/config.ts | 18 ++++++++++++- src/server/api/settings.ts | 4 +-- tests/code-command-quiet-startup.test.ts | 1 + tests/config.test.ts | 16 +++++++++++ tests/resolve.test.ts | 21 ++++++++++++++- tests/settings-api.test.ts | 34 ++++++++++++++++++++++++ 12 files changed, 123 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/acp.ts b/src/cli/commands/acp.ts index 14bdfc1..dca92de 100644 --- a/src/cli/commands/acp.ts +++ b/src/cli/commands/acp.ts @@ -26,6 +26,7 @@ import { buildCodeToolset } from "../../code/setup.js"; import { loadApiKey, loadBaseUrl, + loadModel, loadPreset, loadReasoningEffort, normalizeMcpConfig, @@ -155,7 +156,7 @@ async function buildSession(opts: { }): Promise { const preset = canonicalPresetName(loadPreset()); const resolved = resolvePreset(preset); - const model = opts.modelOverride || resolved.model; + const model = opts.modelOverride || loadModel() || resolved.model; const toolset = await buildCodeToolset({ rootDir: opts.rootDir }); // Bridge MCP tools BEFORE building the prefix so their specs make it into the cache key. const mcpClients = await loadMcpServers(toolset.tools, opts.mcpSpecs ?? [], opts.mcpPrefix); @@ -203,7 +204,8 @@ export async function acpCommand(opts: AcpOptions): Promise { let transcriptStream: WriteStream | null = null; if (opts.transcript) { - const defaultModel = opts.model || resolvePreset(canonicalPresetName(loadPreset())).model; + const defaultModel = + opts.model || loadModel() || resolvePreset(canonicalPresetName(loadPreset())).model; transcriptStream = openTranscriptFile(opts.transcript, { version: 1, source: "carboncode acp", diff --git a/src/cli/commands/code.tsx b/src/cli/commands/code.tsx index 50d0885..6c19b8e 100644 --- a/src/cli/commands/code.tsx +++ b/src/cli/commands/code.tsx @@ -22,7 +22,7 @@ import { readFileSync } from "node:fs"; import { basename, resolve } from "node:path"; import { buildCodeToolset } from "../../code/setup.js"; import { initCollab, renderCollabConnectPrompt, resolveInboxRoot } from "../../collab/inbox.js"; -import { loadApiKey, loadOutputStyle, loadPreset, readConfig } from "../../config.js"; +import { loadApiKey, loadModel, loadOutputStyle, loadPreset, readConfig } from "../../config.js"; import { loadDotenv } from "../../env.js"; import { t } from "../../i18n/index.js"; import { detectForeignAgentPlatform } from "../../memory/project.js"; @@ -75,7 +75,7 @@ export interface CodeOptions { export async function codeCommand(opts: CodeOptions = {}): Promise { markPhase("code_command_enter"); - const resolvedModel = opts.model ?? resolvePreset(loadPreset()).model; + const resolvedModel = opts.model ?? loadModel() ?? resolvePreset(loadPreset()).model; // Bridge .env + ~/.carboncode/config.json into process.env so buildCodeToolset's // eager DeepSeekClient constructions (subagent client; semantic embedder) can // pick up a key the user already configured via `carboncode setup`. chatCommand diff --git a/src/cli/commands/desktop.ts b/src/cli/commands/desktop.ts index f7fe0e6..1696977 100644 --- a/src/cli/commands/desktop.ts +++ b/src/cli/commands/desktop.ts @@ -22,6 +22,7 @@ import { loadDesktopOpenTabs, loadEditMode, loadEditor, + loadModel, loadPreset, loadReasoningEffort, loadRecentWorkspaces, @@ -779,7 +780,7 @@ export async function desktopCommand(opts: DesktopOptions): Promise { pushRecentWorkspace(dir); const preset = canonicalPresetName(loadPreset()); const resolved = resolvePreset(preset); - const model = opts.model || resolved.model; + const model = opts.model || loadModel() || resolved.model; const tab: Tab = { id: nextTabId(), rootDir: dir, diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts index 4c1556a..6fd282f 100644 --- a/src/cli/resolve.ts +++ b/src/cli/resolve.ts @@ -27,7 +27,7 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults { const preset = pickPreset(flags.preset, cfg.preset); const presetSettings = resolvePreset(preset); - const model = flags.model ?? presetSettings.model; + const model = flags.model ?? cfg.model ?? presetSettings.model; const reasoningEffort = presetSettings.reasoningEffort; // `--mcp` accumulator is [] when absent. Treat empty from flags as diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 363d515..de89463 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -46,6 +46,7 @@ import { readConfig, resolveThemePreference, saveEditMode, + saveModel, savePreset, saveTheme, } from "../../config.js"; @@ -2166,8 +2167,13 @@ function AppInner({ loop.configure({ reasoningEffort: effort }); }, applyModelLive: (model) => { - loop.configure({ model }); + loop.configure({ model, autoEscalate: false }); agentStore.dispatch({ type: "session.model.change", model }); + try { + saveModel(model); + } catch { + /* disk full / perms - runtime change still took effect */ + } }, getModels: () => modelsRef.current, setProNextLive: (armed) => { @@ -2570,6 +2576,10 @@ function AppInner({ try { savePreset(inferred); } catch {} + } else { + try { + saveModel(target); + } catch {} } return `model: ${target}`; }, @@ -4503,6 +4513,12 @@ function AppInner({ } catch { /* disk full / perms —runtime change still took effect */ } + } else { + try { + saveModel(outcome.id); + } catch { + /* disk full / perms - runtime change still took effect */ + } } log.pushInfo(`model: ${outcome.id}`); return; diff --git a/src/cli/ui/slash/handlers/model.ts b/src/cli/ui/slash/handlers/model.ts index 82130de..10d29a6 100644 --- a/src/cli/ui/slash/handlers/model.ts +++ b/src/cli/ui/slash/handlers/model.ts @@ -1,4 +1,4 @@ -import { savePreset } from "@/config.js"; +import { saveModel, savePreset } from "@/config.js"; import { t } from "@/i18n/index.js"; import { PRESETS } from "../../presets.js"; import type { SlashHandler } from "../dispatch.js"; @@ -27,6 +27,12 @@ const model: SlashHandler = (args, loop, ctx) => { } catch { /* disk full / perms — runtime change still took effect */ } + } else { + try { + saveModel(id); + } catch { + /* disk full / perms — runtime change still took effect */ + } } if (known && known.length > 0 && !known.includes(id)) { return { diff --git a/src/config.ts b/src/config.ts index 8ce267c..8a2bc4e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -111,6 +111,8 @@ export interface RateLimitConfig { export interface ReasonixConfig { apiKey?: string; baseUrl?: string; + /** Explicit chat model pin. When absent, the selected preset supplies the model. */ + model?: string; lang?: LanguageCode; preset?: PresetName; editMode?: EditMode; @@ -988,10 +990,24 @@ export function loadPreset(path: string = defaultConfigPath()): PresetName | und return readConfig(path).preset; } -/** Persist preset so `/preset pro` (or `/model deepseek-v4-pro`) sticks across relaunches. */ +export function loadModel(path: string = defaultConfigPath()): string | undefined { + const model = readConfig(path).model; + return typeof model === "string" && model.trim() ? model.trim() : undefined; +} + +/** Persist an explicit model pin while keeping the preset as the effort fallback. */ +export function saveModel(model: string, path: string = defaultConfigPath()): void { + const cfg = readConfig(path); + const trimmed = model.trim(); + cfg.model = trimmed || undefined; + writeConfig(cfg, path); +} + +/** Persist a preset and clear any explicit model pin so the preset owns model selection. */ export function savePreset(preset: PresetName, path: string = defaultConfigPath()): void { const cfg = readConfig(path); cfg.preset = preset; + cfg.model = undefined; writeConfig(cfg, path); } diff --git a/src/server/api/settings.ts b/src/server/api/settings.ts index 18603cd..bb74df5 100644 --- a/src/server/api/settings.ts +++ b/src/server/api/settings.ts @@ -138,6 +138,7 @@ export async function handleSettings( return { status: 400, body: { error: "preset must be auto | flash | pro" } }; } cfg.preset = fields.preset as "auto" | "flash" | "pro" | "fast" | "smart" | "max"; + cfg.model = undefined; presetPendingLive = fields.preset; changed.push("preset"); } @@ -166,9 +167,8 @@ export async function handleSettings( if (typeof fields.model !== "string" || !fields.model.trim()) { return { status: 400, body: { error: "model must be a non-empty string" } }; } - // Model is live-only (not in ReasonixConfig). Same as /model slash — disk - // pickup goes through preset / startup flag, not direct cfg.model. modelPendingLive = fields.model.trim(); + cfg.model = modelPendingLive; changed.push("model"); } if (fields.proNext !== undefined) { diff --git a/tests/code-command-quiet-startup.test.ts b/tests/code-command-quiet-startup.test.ts index b65bbb4..35c7f80 100644 --- a/tests/code-command-quiet-startup.test.ts +++ b/tests/code-command-quiet-startup.test.ts @@ -23,6 +23,7 @@ vi.mock("../src/code/prompt.js", () => ({ vi.mock("../src/config.js", () => ({ loadApiKey: vi.fn(() => undefined), + loadModel: vi.fn(() => undefined), loadPreset: vi.fn(() => "auto"), loadOutputStyle: vi.fn(() => "default"), readConfig: vi.fn(() => ({})), diff --git a/tests/config.test.ts b/tests/config.test.ts index 0220de4..973ea60 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -16,6 +16,7 @@ import { loadEditMode, loadIndexConfig, loadIndexUserConfig, + loadModel, loadPricingOverride, loadProjectPathAllowed, loadProjectShellAllowed, @@ -37,6 +38,8 @@ import { saveDesktopOpenTabs, saveEditMode, saveIndexConfig, + saveModel, + savePreset, saveReasoningEffort, saveSemanticEmbeddingConfig, saveTheme, @@ -71,6 +74,19 @@ describe("config", () => { delete process.env.REASONIX_EMBED_MODEL; }); + it("persists an explicit chat model", () => { + saveModel(" custom-chat-model ", path); + expect(loadModel(path)).toBe("custom-chat-model"); + expect(readConfig(path).model).toBe("custom-chat-model"); + }); + + it("clears an explicit model when a preset is selected", () => { + writeConfig({ model: "custom-chat-model", preset: "auto" }, path); + savePreset("pro", path); + expect(loadModel(path)).toBeUndefined(); + expect(readConfig(path).preset).toBe("pro"); + }); + afterEach(() => { if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); if (originalEnv === undefined) { diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index f1f4651..379096b 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -65,6 +65,22 @@ describe("resolveDefaults", () => { expect(r.reasoningEffort).toBe("max"); }); + it("config.model overrides the preset model while preserving preset effort", () => { + writeConfig( + { preset: "max", model: "provider-custom-model" }, + join(home, ".carboncode", "config.json"), + ); + const r = resolveDefaults({}); + expect(r.model).toBe("provider-custom-model"); + expect(r.reasoningEffort).toBe("max"); + }); + + it("--model overrides config.model", () => { + writeConfig({ model: "provider-custom-model" }, join(home, ".carboncode", "config.json")); + const r = resolveDefaults({ model: "one-shot-model" }); + expect(r.model).toBe("one-shot-model"); + }); + it("--mcp overrides config.mcp wholesale (no merging)", () => { writeConfig( { mcp: ["fs=npx -y @modelcontextprotocol/server-filesystem /tmp/old"] }, @@ -85,7 +101,10 @@ describe("resolveDefaults", () => { }); it("--no-config ignores the config entirely", () => { - writeConfig({ preset: "max", mcp: ["x=cmd"] }, join(home, ".carboncode", "config.json")); + writeConfig( + { preset: "max", model: "custom-model", mcp: ["x=cmd"] }, + join(home, ".carboncode", "config.json"), + ); const r = resolveDefaults({ noConfig: true }); expect(r.model).toBe("deepseek-v4-flash"); // smart defaults (new default) expect(r.reasoningEffort).toBe("max"); diff --git a/tests/settings-api.test.ts b/tests/settings-api.test.ts index 75e3b3a..ff5904e 100644 --- a/tests/settings-api.test.ts +++ b/tests/settings-api.test.ts @@ -72,6 +72,40 @@ describe("settings API — combined POST persistence (#274)", () => { expect(cfg.search).toBe(false); }); + it("persists a model selection and applies it live", async () => { + const applied: string[] = []; + const ctx: DashboardContext = { + ...makeCtx(configPath), + applyModelLive: (model) => applied.push(model), + }; + const res = await handleSettings( + "POST", + [], + JSON.stringify({ model: "provider-custom-model" }), + ctx, + ); + expect(res.status).toBe(200); + expect(readCfg(configPath).model).toBe("provider-custom-model"); + expect(applied).toEqual(["provider-custom-model"]); + }); + + it("clears a persisted model when a preset is selected", async () => { + writeFileSync( + configPath, + JSON.stringify({ preset: "auto", model: "provider-custom-model" }), + "utf8", + ); + const res = await handleSettings( + "POST", + [], + JSON.stringify({ preset: "pro" }), + makeCtx(configPath), + ); + expect(res.status).toBe(200); + expect(readCfg(configPath).model).toBeUndefined(); + expect(readCfg(configPath).preset).toBe("pro"); + }); + it("does not write to disk when no fields are provided", async () => { const before = readFileSync(configPath, "utf8"); const res = await handleSettings("POST", [], JSON.stringify({}), makeCtx(configPath));