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
30 changes: 23 additions & 7 deletions src/ModelApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,13 +347,29 @@ export function ModelApp() {
</Box>
)}
{phase === "done" && chosenModel && (
<Text>
{"Default model updated to "}
<Text color="cyan">{chosenModel}</Text>
{" for "}
{formatToolList(tools.map((t) => TOOL_LABEL[t]))}
{"."}
</Text>
<Box flexDirection="column">
<Text>
{"Default model updated to "}
<Text color="cyan">{chosenModel}</Text>
{" for "}
{formatToolList(tools.map((t) => TOOL_LABEL[t]))}
{"."}
</Text>
{/* 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") && (
<Text dimColor>
{"In "}
{formatToolList(
tools
.filter((t) => t === "opencode" || t === "codev-code")
.map((t) => TOOL_LABEL[t]),
)}
{", switch models anytime with /models."}
</Text>
)}
</Box>
)}
</Step>
)}
Expand Down
25 changes: 19 additions & 6 deletions src/lib/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<provider>/<modelId>` 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.
Expand Down
5 changes: 5 additions & 0 deletions tests/ModelApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 13 additions & 9 deletions tests/lib/configure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <provider>/<modelId> 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`.
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 <provider>/<modelId> 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({
Expand Down Expand Up @@ -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);

Expand All @@ -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();
Expand All @@ -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");
});

Expand Down
Loading