From 8d9ec62ad21050b36b3db5c0dd468a44ddca99ff Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 30 Jul 2026 20:24:22 +0700 Subject: [PATCH] fix(configure): stop pinning a default model for OpenCode and CoDev Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install-time model choice exists because Claude Code and Codex can only replace their proprietary model with a single one (docs/hub/installation); OpenCode and CoDev Code switch freely in-CLI. Writing the choice as a top-level `model` in their configs outranked that in-CLI selection on every launch — the pin beats the recent models in their state dir — so each /models switch reverted on restart and only hand-editing the config undid it. Every install, `codevhub model` run, and gateway-key refresh rewrote the pin back. configureOpenCodeKind no longer emits `model`. The chosen model now leads the provider's models map instead, so a first launch with nothing selected still lands on it while any later in-CLI switch wins for good. Claude Code, Codex, and Continue are untouched — `codevhub model` still steers those authoritatively, and its completion line now says where the in-CLI choice rules instead of implying it changed those two. Co-Authored-By: Claude Opus 5 (1M context) --- src/ModelApp.tsx | 30 +++++++++++++++++++++++------- src/lib/configure.ts | 25 +++++++++++++++++++------ tests/ModelApp.test.tsx | 5 +++++ tests/lib/configure.test.ts | 22 +++++++++++++--------- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/ModelApp.tsx b/src/ModelApp.tsx index ff6f8ea..6938538 100644 --- a/src/ModelApp.tsx +++ b/src/ModelApp.tsx @@ -347,13 +347,29 @@ export function ModelApp() { )} {phase === "done" && chosenModel && ( - - {"Default model updated to "} - {chosenModel} - {" for "} - {formatToolList(tools.map((t) => TOOL_LABEL[t]))} - {"."} - + + + {"Default model updated to "} + {chosenModel} + {" for "} + {formatToolList(tools.map((t) => TOOL_LABEL[t]))} + {"."} + + {/* Their configs carry no model pin (see configureOpenCodeKind), + so a model already selected in-CLI keeps winning there — + say so rather than let the line above imply otherwise. */} + {tools.some((t) => t === "opencode" || t === "codev-code") && ( + + {"In "} + {formatToolList( + tools + .filter((t) => t === "opencode" || t === "codev-code") + .map((t) => TOOL_LABEL[t]), + )} + {", switch models anytime with /models."} + + )} + )} )} diff --git a/src/lib/configure.ts b/src/lib/configure.ts index 33513b5..2e28b8a 100644 --- a/src/lib/configure.ts +++ b/src/lib/configure.ts @@ -924,9 +924,16 @@ function configureOpenCodeKind( const provider = resolveProvider(creds); // Fall back to [defaultModel] when `models` is unset so callers that don't // know about the list (e.g. older fixtures, the fallback path with no - // fetched list) still produce a valid one-entry map. - const allModels = - creds.models && creds.models.length > 0 ? creds.models : [defaultModel]; + // fetched list) still produce a valid one-entry map. The chosen model leads + // the map: with no top-level pin (see below) a first launch with no saved + // selection falls through to the provider's first model, so ordering is + // what carries the choice. + const allModels = [ + ...new Set([ + defaultModel, + ...(creds.models && creds.models.length > 0 ? creds.models : []), + ]), + ]; // A custom-provider model with no `limit` defaults to context 0, which both // mis-sizes the window and disables OpenCode's auto-compaction entirely. @@ -959,9 +966,15 @@ function configureOpenCodeKind( writeJson(sourcePath, { [OPENCODE_K.schema]: OPENCODE_SCHEMA_URL, ...(mcp !== undefined ? { [OPENCODE_K.mcp]: mcp } : {}), - // Top-level `model` pins the initial active model OpenCode uses on - // launch. Format is `/` per OpenCode's schema. - [OPENCODE_K.model]: `${provider.id}/${defaultModel}`, + // No top-level `model`. The install-time model choice exists for Claude + // Code and Codex, which can only run one model at a time; OpenCode and + // CoDev Code switch freely in-CLI (docs/hub/installation). A pin there + // outranks their saved selection on every launch — it beats the recent + // models in their state dir — so each in-CLI switch reverted on restart + // and only hand-editing the config undid it. The chosen model leads the + // models map instead, which decides the first launch and yields to any + // later selection. This is why `codevhub model` steers only Claude Code + // and Codex. // OpenCode has no percentage trigger; it compacts at `context − reserved`. // Reserve the headroom that lands the trigger at ~85% of the window, to // match Claude Code and Codex. diff --git a/tests/ModelApp.test.tsx b/tests/ModelApp.test.tsx index cf03c8c..79c97ca 100644 --- a/tests/ModelApp.test.tsx +++ b/tests/ModelApp.test.tsx @@ -126,6 +126,11 @@ describe("ModelApp", () => { expect(history).toContain( "Default model updated to new-alpha for Claude Code and OpenCode.", ); + // OpenCode's config carries no model pin (it switches in-CLI), so the + // line above must not read as authoritative for it. + expect(history).toContain( + "In OpenCode, switch models anytime with /models.", + ); expect(configureClaude).toHaveBeenCalledWith({ apiKey: "sk-existing", baseUrl: undefined, diff --git a/tests/lib/configure.test.ts b/tests/lib/configure.test.ts index d2a0174..57c1175 100644 --- a/tests/lib/configure.test.ts +++ b/tests/lib/configure.test.ts @@ -398,8 +398,9 @@ describe("configureOpenCode", () => { expect(config.provider.netgate.models["chosen-model"].name).toBe( "chosen-model", ); - // Top-level `model` pins the active default in / form. - expect(config.model).toBe("netgate/chosen-model"); + // No top-level `model`: OpenCode and CoDev Code switch models in-CLI, and + // a pin would outrank that selection on every launch. + expect(config.model).toBeUndefined(); // Declares the gateway window so OpenCode sizes context correctly and its // auto-compaction fires (a model with no `limit` defaults to context 0, // which disables compaction). `output` is required alongside `context`. @@ -443,8 +444,10 @@ describe("configureOpenCode", () => { output: ["text"], }); } - // Top-level default still points at the chosen one. - expect(config.model).toBe("netgate/model-a"); + // With no pin, the map order carries the choice: a launch with nothing + // selected yet takes the provider's first model. + expect(config.model).toBeUndefined(); + expect(Object.keys(map)[0]).toBe("model-a"); }); test("falls back to [model] when `models` is absent (older call sites)", async () => { @@ -596,8 +599,9 @@ describe("configureCodevCode", () => { expect(config.provider.netgate.models["chosen-model"].name).toBe( "chosen-model", ); - // Top-level `model` pins the active default in / form. - expect(config.model).toBe("netgate/chosen-model"); + // No top-level `model`: OpenCode and CoDev Code switch models in-CLI, and + // a pin would outrank that selection on every launch. + expect(config.model).toBeUndefined(); // The fork shares OpenCode's window/compaction handling, so the same // limit + compaction blocks must land in its config. expect(config.provider.netgate.models["chosen-model"].limit).toEqual({ @@ -1652,7 +1656,7 @@ describe("custom provider identity", () => { expect(config.model_providers.netgate).toBeUndefined(); }); - test("opencode writes the custom id in both the provider map and `model`", async () => { + test("opencode writes the custom id in the provider map", async () => { const { configureOpenCode } = await import("@/lib/configure.js"); configureOpenCode(custom); @@ -1662,7 +1666,7 @@ describe("custom provider identity", () => { "utf-8", ), ); - expect(config.model).toBe("acme-ai/m"); + expect(config.provider["acme-ai"].models.m).toBeDefined(); expect(config.provider["acme-ai"].name).toBe("Acme AI"); expect(config.provider["acme-ai"].options.apiKey).toBe("sk-user"); expect(config.provider.netgate).toBeUndefined(); @@ -1675,7 +1679,7 @@ describe("custom provider identity", () => { const config = JSON.parse( readFileSync(join(tempDir, ".config", "codev", "codev.json"), "utf-8"), ); - expect(config.model).toBe("acme-ai/m"); + expect(config.provider["acme-ai"].models.m).toBeDefined(); expect(config.provider["acme-ai"].name).toBe("Acme AI"); });