From beeb2873d0d6f3efc46614eebbb8f56235f263e2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 11:37:16 -0700 Subject: [PATCH] Extract a shared resolveDefaultModel helper for the default-model fallback The defaultModel-or-first-model precedence was hand-rolled at three call sites (providers.ts:86, inference-sources.ts:43, settings.ts:1074), so a rule change at one site wouldn't apply at the others and the active and backup model for the same provider could diverge. --- src/config/inference-sources.ts | 3 ++- src/config/providers.test.ts | 26 +++++++++++++++++++++++++- src/config/providers.ts | 12 +++++++++++- src/config/settings.ts | 3 ++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index 374a5d04f..9a378d29d 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -13,6 +13,7 @@ import type { Settings } from "./settings.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import { SOURCE_MAX_TOKENS } from "./index.js"; import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js"; +import { resolveDefaultModel } from "./providers.js"; export type BuildSourceContext = { sessionId: string; @@ -40,7 +41,7 @@ function backupRefsFromSettings( const tail: ProviderRef[] = []; for (const [provider, p] of Object.entries(settings.providers)) { if (seenProviders.has(provider)) continue; - const model = p.defaultModel ?? p.models[0]; + const model = resolveDefaultModel(p); if (model === undefined || model.length === 0) continue; seenProviders.add(provider); tail.push({ provider, model }); diff --git a/src/config/providers.test.ts b/src/config/providers.test.ts index 877e0d295..b6a14d8e6 100644 --- a/src/config/providers.test.ts +++ b/src/config/providers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { OPENCODE_GO_BASE_URL } from "../../packages/opencode-go/src/index.js"; import type { ProviderCatalogEntry } from "./index.js"; -import { buildProviderEntry } from "./providers.js"; +import { buildProviderEntry, resolveDefaultModel } from "./providers.js"; const baseCatalog: ProviderCatalogEntry[] = [ { @@ -269,3 +269,27 @@ describe("buildProviderEntry OpenCode Go baseURL pin", () => { expect(result.entry.opencodeGo).toBe(true); }); }); + +describe("resolveDefaultModel", () => { + test("returns defaultModel when present and non-empty", () => { + expect(resolveDefaultModel({ defaultModel: "gpt-4o", models: ["gpt-4o", "gpt-4o-mini"] })).toBe( + "gpt-4o", + ); + }); + + test("falls back to models[0] when defaultModel is absent", () => { + expect(resolveDefaultModel({ models: ["gpt-4o", "gpt-4o-mini"] })).toBe("gpt-4o"); + }); + + test("falls back to models[0] when defaultModel is empty", () => { + expect(resolveDefaultModel({ defaultModel: "", models: ["gpt-4o"] })).toBe("gpt-4o"); + }); + + test("returns undefined for an undefined entry", () => { + expect(resolveDefaultModel(undefined)).toBeUndefined(); + }); + + test("returns undefined when entry has no models and no defaultModel", () => { + expect(resolveDefaultModel({ models: [] })).toBeUndefined(); + }); +}); diff --git a/src/config/providers.ts b/src/config/providers.ts index 638baf67f..f762adf9d 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -19,6 +19,16 @@ export type ProviderSubmission = { opencodeGo?: boolean; }; +// Precedence for the active/backup model of a provider: defaultModel wins +// when present and non-empty, otherwise the first configured model. +export function resolveDefaultModel( + entry: { defaultModel?: string; models: readonly string[] } | undefined, +): string | undefined { + const defaultModel = entry?.defaultModel; + if (defaultModel !== undefined && defaultModel.length > 0) return defaultModel; + return entry?.models[0]; +} + export type ProviderEntryResult = | { ok: true; entry: ProviderCatalogEntry; catalog: ProviderCatalogEntry[]; selectedModel: string } | { ok: false; error: string }; @@ -83,7 +93,7 @@ export function buildProviderEntry( const catalog = currentCatalog .filter((p) => p.name !== submission.name && p.name !== submission.originalName) .concat(entry); - const selectedModel = entry.defaultModel ?? entry.models[0]; + const selectedModel = resolveDefaultModel(entry); if (selectedModel === undefined) { return { ok: false, error: "Provider must include at least one model" }; } diff --git a/src/config/settings.ts b/src/config/settings.ts index ef78c4428..9c9ab0677 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -8,6 +8,7 @@ import { type } from "arktype"; import { SETTINGS_DIR_NAME } from "../branding.js"; import { REASONING_EFFORTS, isReasoningEffort, type ReasoningEffort } from "../provider/reasoning-effort.js"; import { isSessionMode, type SessionMode } from "./session-mode.js"; +import { resolveDefaultModel } from "./providers.js"; import { OPENCODE_GO_BASE_URL, isOpenCodeGoProvider, @@ -1071,7 +1072,7 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider { const baseURL = go ? OPENCODE_GO_BASE_URL : selected?.baseURL; const apiKey = selected?.apiKey; const keyless = selected?.keyless === true; - const model = cli.model ?? local?.model ?? selected?.defaultModel ?? selected?.models[0]; + const model = cli.model ?? local?.model ?? resolveDefaultModel(selected); // A provider name was selected (from local file or defaultProvider) but is not // actually configured — distinguish this from "nothing configured at all" so