Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/cli/commands/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { buildCodeToolset } from "../../code/setup.js";
import {
loadApiKey,
loadBaseUrl,
loadModel,
loadPreset,
loadReasoningEffort,
normalizeMcpConfig,
Expand Down Expand Up @@ -155,7 +156,7 @@ async function buildSession(opts: {
}): Promise<Session> {
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);
Expand Down Expand Up @@ -203,7 +204,8 @@ export async function acpCommand(opts: AcpOptions): Promise<void> {

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",
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -75,7 +75,7 @@ export interface CodeOptions {

export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
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
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
loadDesktopOpenTabs,
loadEditMode,
loadEditor,
loadModel,
loadPreset,
loadReasoningEffort,
loadRecentWorkspaces,
Expand Down Expand Up @@ -779,7 +780,7 @@ export async function desktopCommand(opts: DesktopOptions): Promise<void> {
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,
Expand Down
2 changes: 1 addition & 1 deletion src/cli/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
readConfig,
resolveThemePreference,
saveEditMode,
saveModel,
savePreset,
saveTheme,
} from "../../config.js";
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -2570,6 +2576,10 @@ function AppInner({
try {
savePreset(inferred);
} catch {}
} else {
try {
saveModel(target);
} catch {}
}
return `model: ${target}`;
},
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion src/cli/ui/slash/handlers/model.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
4 changes: 2 additions & 2 deletions src/server/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -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 <id> 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) {
Expand Down
1 change: 1 addition & 0 deletions tests/code-command-quiet-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({})),
Expand Down
16 changes: 16 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
loadEditMode,
loadIndexConfig,
loadIndexUserConfig,
loadModel,
loadPricingOverride,
loadProjectPathAllowed,
loadProjectShellAllowed,
Expand All @@ -37,6 +38,8 @@ import {
saveDesktopOpenTabs,
saveEditMode,
saveIndexConfig,
saveModel,
savePreset,
saveReasoningEffort,
saveSemanticEmbeddingConfig,
saveTheme,
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 20 additions & 1 deletion tests/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand All @@ -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");
Expand Down
34 changes: 34 additions & 0 deletions tests/settings-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading