From e6a0806d9d6857c2a3b14c6878bb74187e34ebef Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 19:30:07 -0700 Subject: [PATCH 1/2] Fall back from an unusable selected inference provider A missing or incomplete default or project-local pick used to treat the session as unconfigured even when another provider could run. Explicit --provider still fails closed. --- CHANGELOG.md | 3 + docs/IMPLEMENTATION.md | 6 +- src/config/settings.ts | 160 +++++++++++++++++++++++++++++++---------- src/settings.test.ts | 141 ++++++++++++++++++++++++++++++++++-- 4 files changed, 263 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3c4e36d..df506a5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - Spawned workers enforce the parent permission gate. Unresolved worker approvals deny with a reason that names the permission subject so the parent can grant and retry, without hanging on operator approval. +- A session falls back to the next resolvable provider when the selected + default or project-local provider is missing or incomplete. `--provider` + still errors. ### Changed diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index e250b933f..69798ff5c 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -294,10 +294,12 @@ The `waitForApproval` default is resolved once at the watchdog boundary (`resolv `loadConfig` resolves the active provider down to `{ apiKey, baseURL, model, providerName }` (the same struct the runtime consumes). Per field, highest wins: -- providerName: `--provider` > local file > `defaultProvider` > sole provider -- model: `--model` > local file > provider `defaultModel` > first model +- providerName: `--provider` > local file > `defaultProvider` > sole provider. If that pick is missing or incomplete and `--provider` was not set, resolution walks remaining untried names in this order: local file, `defaultProvider`, `recentModels` provider names (newest first, unique), then remaining catalog keys in insertion order. `--provider` fail-closes: an unknown or unusable CLI provider does not steal a sibling. +- model: `--model` > local file > provider `defaultModel` > first model on the original pick. `local.model` does not follow a fallback sibling; `--model` may still overlay a fallback. A candidate reached via `recentModels` may use that pair's model when `--model` is unset. - baseURL / apiKey: the selected provider only +If no remaining provider is fully resolvable, resolution throws the original pick's missing-fields error. + Credentials and provider definitions come exclusively from the settings files. Environment variables (including `OPENAI_COMPATIBLE_*`) have no influence on provider resolution, and `.env` files are not loaded. OpenAI-compatible `baseURL` values are normalized during provider resolution. A plain base URL such as `https://provider.example.com/v1` is preserved, a trailing slash is removed, and a pasted full chat-completions endpoint such as `https://provider.example.com/v1/chat/completions` is reduced to `https://provider.example.com/v1` before the runtime appends `/chat/completions`. Invalid non-URL values fail with an explicit baseURL error. diff --git a/src/config/settings.ts b/src/config/settings.ts index abedcdeee..a31f6f6b8 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -1226,10 +1226,14 @@ export interface ResolveInput { // Resolve the active provider. Precedence per field (highest first): // providerName: --provider > local > settings.defaultProvider > sole provider -// model: --model > local > provider.defaultModel > provider.models[0] +// If that pick is unusable and --provider was not set, walk local, defaultProvider, +// recentModels provider names (newest-first, unique), then remaining catalog keys. +// model: --model > local (original pick only) > recent pair (recent fallback only) +// > provider.defaultModel > provider.models[0] // baseURL/apiKey: from the selected provider only // Credentials and provider definitions come exclusively from the settings -// catalog; environment variables have no influence on resolution. +// catalog; environment variables have no influence on resolution. Session-only: +// this function does not write settings or mutate input.settings. export function resolveProvider(input: ResolveInput): ResolvedProvider { const { settings, local, cli } = input; const providers = settings?.providers ?? {}; @@ -1241,43 +1245,78 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider { throw new Error(`Provider "${cli.provider}" not found in settings (available: ${available}).`); } - const providerName = cli.provider ?? local?.provider ?? settings?.defaultProvider ?? soleKey; + const originalName = cli.provider ?? local?.provider ?? settings?.defaultProvider ?? soleKey; - const selected = providerName !== undefined ? providers[providerName] : undefined; + const fieldsFor = (name: string | undefined) => { + const selected = name !== undefined ? providers[name] : undefined; + const go = isOpenCodeGoProvider({ + ...(name !== undefined ? { name } : {}), + ...(selected?.opencodeGo === true ? { opencodeGo: true as const } : {}), + ...(selected?.baseURL !== undefined ? { baseURL: selected.baseURL } : {}), + }); + return { + selected, + go, + baseURL: go ? OPENCODE_GO_BASE_URL : selected?.baseURL, + apiKey: selected?.apiKey, + keyless: selected?.keyless === true, + }; + }; - const go = isOpenCodeGoProvider({ - ...(providerName !== undefined ? { name: providerName } : {}), - ...(selected?.opencodeGo === true ? { opencodeGo: true as const } : {}), - ...(selected?.baseURL !== undefined ? { baseURL: selected.baseURL } : {}), + const finish = ( + name: string, + selected: ProviderSettings, + go: boolean, + baseURL: string, + apiKey: string | undefined, + keyless: boolean, + model: string, + ): ResolvedProvider => ({ + providerName: name, + baseURL: go ? OPENCODE_GO_BASE_URL : normalizeOpenAICompatibleBaseURL(baseURL), + apiKey: apiKey ?? "", + model, + ...(keyless ? { keyless: true } : {}), + ...(selected.verified === false ? { verified: false } : {}), }); - const baseURL = go ? OPENCODE_GO_BASE_URL : selected?.baseURL; - const apiKey = selected?.apiKey; - const keyless = selected?.keyless === true; - 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 - // the operator gets an actionable message instead of a generic missing-creds one. - const selectedMissing = - providerName !== undefined && settings !== null && providers[providerName] === undefined; - - const missingApiKey = !keyless && (apiKey === undefined || apiKey.length === 0); - if ( - providerName === undefined || - providerName.length === 0 || - baseURL === undefined || - baseURL.length === 0 || - missingApiKey || - model === undefined || - model.length === 0 - ) { + + const tryCandidate = ( + name: string | undefined, + model: string | undefined, + ): ResolvedProvider | undefined => { + if (name === undefined || name.length === 0) return undefined; + const { selected, go, baseURL, apiKey, keyless } = fieldsFor(name); + if (selected === undefined) return undefined; + const missingApiKey = !keyless && (apiKey === undefined || apiKey.length === 0); + if ( + baseURL === undefined || + baseURL.length === 0 || + missingApiKey || + model === undefined || + model.length === 0 + ) { + return undefined; + } + return finish(name, selected, go, baseURL, apiKey, keyless, model); + }; + + const nonempty = (value: string | undefined): string | undefined => + value !== undefined && value.length > 0 ? value : undefined; + + const throwOriginal = (): never => { + const { selected, baseURL, apiKey, keyless } = fieldsFor(originalName); + const model = + nonempty(cli.model) ?? nonempty(local?.model) ?? resolveDefaultModel(selected); + const selectedMissing = + originalName !== undefined && settings !== null && providers[originalName] === undefined; + const missingApiKey = !keyless && (apiKey === undefined || apiKey.length === 0); const missing: string[] = []; - if (providerName === undefined || providerName.length === 0) missing.push("provider"); + if (originalName === undefined || originalName.length === 0) missing.push("provider"); if (baseURL === undefined || baseURL.length === 0) missing.push("baseURL"); if (missingApiKey) missing.push("apiKey"); if (model === undefined || model.length === 0) missing.push("model"); const detail = selectedMissing - ? ` Selected provider "${providerName}" is not configured in settings (available: ${ + ? ` Selected provider "${originalName}" is not configured in settings (available: ${ Object.keys(providers).join(", ") || "none" }).` : ""; @@ -1286,16 +1325,59 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider { `Configure a provider in ${globalSettingsPath()}. ` + `See docs/IMPLEMENTATION.md.`, ); - } + }; - return { - providerName, - baseURL: go ? OPENCODE_GO_BASE_URL : normalizeOpenAICompatibleBaseURL(baseURL), - apiKey: apiKey ?? "", - model, - ...(keyless ? { keyless: true } : {}), - ...(selected?.verified === false ? { verified: false } : {}), + const original = tryCandidate( + originalName, + nonempty(cli.model) ?? + nonempty(local?.model) ?? + resolveDefaultModel(fieldsFor(originalName).selected), + ); + if (original !== undefined) return original; + if (cli.provider !== undefined) return throwOriginal(); + if (originalName === undefined || originalName.length === 0) return throwOriginal(); + + const tried = new Set(); + if (originalName !== undefined) tried.add(originalName); + + const fallbacks: { name: string; model: string | undefined }[] = []; + const enqueue = (name: string | undefined, model: string | undefined) => { + if (name === undefined || tried.has(name)) return; + tried.add(name); + fallbacks.push({ name, model }); }; + + enqueue( + local?.provider, + nonempty(cli.model) ?? + resolveDefaultModel(local?.provider !== undefined ? providers[local.provider] : undefined), + ); + enqueue( + settings?.defaultProvider, + nonempty(cli.model) ?? + resolveDefaultModel( + settings?.defaultProvider !== undefined ? providers[settings.defaultProvider] : undefined, + ), + ); + for (const ref of settings?.recentModels ?? []) { + if (tried.has(ref.provider)) continue; + tried.add(ref.provider); + const recentModel = ref.model.length > 0 ? ref.model : undefined; + fallbacks.push({ + name: ref.provider, + model: nonempty(cli.model) ?? recentModel ?? resolveDefaultModel(providers[ref.provider]), + }); + } + for (const name of providerKeys) { + enqueue(name, nonempty(cli.model) ?? resolveDefaultModel(providers[name])); + } + + for (const candidate of fallbacks) { + const resolved = tryCandidate(candidate.name, candidate.model); + if (resolved !== undefined) return resolved; + } + + return throwOriginal(); } import type { InferenceSpec } from "../agent/profile-types.js"; diff --git a/src/settings.test.ts b/src/settings.test.ts index ce1737d68..472386099 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -239,27 +239,156 @@ describe("resolveProvider", () => { ).toThrow(/not found/); }); - test("names the offending provider when a local selection is not configured", () => { - expect(() => - resolveProvider({ settings: twoProviders, local: { provider: "zzz" }, cli: {} }), - ).toThrow(/Selected provider "zzz" is not configured/); + test("falls back from a missing local selection to defaultProvider", () => { + const r = resolveProvider({ settings: twoProviders, local: { provider: "zzz" }, cli: {} }); + expect(r.providerName).toBe("a"); + expect(r.apiKey).toBe("a-key"); + expect(r.model).toBe("a-model"); }); - test("names the offending provider when defaultProvider is a typo", () => { + test("falls back from a typo defaultProvider to a resolvable sibling", () => { const settings: Settings = { defaultProvider: "typo", providers: { solo: { baseURL: "https://s/v1", apiKey: "s-key", models: ["s-model"] } }, }; + const r = resolveProvider({ settings, local: null, cli: {} }); + expect(r.providerName).toBe("solo"); + expect(r.apiKey).toBe("s-key"); + expect(r.model).toBe("s-model"); + }); + + test("falls back from an orphaned OAuth local selection to a healthy sibling", () => { + const r = resolveProvider({ + settings: firepass, + local: { provider: "xai/work" }, + cli: {}, + }); + expect(r.providerName).toBe("firepass"); + expect(r.apiKey).toBe("fp-key"); + }); + + test("falls back from a missing-key defaultProvider to a healthy sibling", () => { + const settings: Settings = { + defaultProvider: "broken", + providers: { + broken: { baseURL: "https://broken/v1", apiKey: "", models: ["broken-model"] }, + ...firepass.providers, + }, + }; + const r = resolveProvider({ settings, local: null, cli: {} }); + expect(r.providerName).toBe("firepass"); + expect(r.apiKey).toBe("fp-key"); + }); + + test("throws when an explicit CLI provider is present but unusable even if a sibling is healthy", () => { + const settings: Settings = { + defaultProvider: "firepass", + providers: { + broken: { baseURL: "https://broken/v1", apiKey: "", models: ["broken-model"] }, + ...firepass.providers, + }, + }; + expect(() => resolveProvider({ settings, local: null, cli: { provider: "broken" } })).toThrow( + /missing: apiKey/, + ); + expect(() => resolveProvider({ settings, local: null, cli: { provider: "broken" } })).toThrow( + /Could not resolve an inference provider/, + ); + }); + + test("throws the missing-fields error when no remaining provider is resolvable", () => { + const settings: Settings = { + defaultProvider: "typo", + providers: { + broken: { baseURL: "https://broken/v1", apiKey: "", models: ["broken-model"] }, + }, + }; + expect(() => resolveProvider({ settings, local: null, cli: {} })).toThrow( + /Could not resolve an inference provider \(missing: baseURL, apiKey, model\)/, + ); expect(() => resolveProvider({ settings, local: null, cli: {} })).toThrow( - /Selected provider "typo" is not configured/, + /Selected provider "typo" is not configured in settings \(available: broken\)/, ); }); + test("prefers recentModels provider names over remaining catalog keys", () => { + const settings: Settings = { + defaultProvider: "gone", + providers: { + a: { baseURL: "https://a/v1", apiKey: "a-key", models: ["a-model"] }, + b: { + baseURL: "https://b/v1", + apiKey: "b-key", + models: ["b-model"], + defaultModel: "b-model", + }, + }, + recentModels: [ + { provider: "b", model: "b-recent" }, + { provider: "ignored", model: "ignored-model" }, + ], + }; + const r = resolveProvider({ settings, local: null, cli: {} }); + expect(r.providerName).toBe("b"); + expect(r.model).toBe("b-recent"); + }); + + test("does not apply local.model to a fallback sibling", () => { + const settings: Settings = { + defaultProvider: "b", + providers: { + b: { + baseURL: "https://b/v1", + apiKey: "b-key", + models: ["b-model"], + defaultModel: "b-model", + }, + }, + }; + const fallback = resolveProvider({ + settings, + local: { provider: "gone", model: "pinned-model" }, + cli: {}, + }); + expect(fallback.providerName).toBe("b"); + expect(fallback.model).toBe("b-model"); + + const overlay = resolveProvider({ + settings, + local: { provider: "gone", model: "pinned-model" }, + cli: { model: "cli-model" }, + }); + expect(overlay.providerName).toBe("b"); + expect(overlay.model).toBe("cli-model"); + }); + + test("treats an empty local.model as unset on a healthy original pick", () => { + const r = resolveProvider({ + settings: twoProviders, + local: { model: "" }, + cli: {}, + }); + expect(r.providerName).toBe("a"); + expect(r.model).toBe("a-model"); + }); + test("throws listing every missing field", () => { expect(() => resolveProvider({ settings: null, local: null, cli: {} })).toThrow( /missing: provider, baseURL, apiKey, model/, ); }); + + test("does not pick a catalog sibling when nothing was selected", () => { + const settings: Settings = { + providers: { + a: { baseURL: "https://a/v1", apiKey: "a-key", models: ["a-model"] }, + b: { baseURL: "https://b/v1", apiKey: "b-key", models: ["b-model"] }, + }, + }; + expect(() => resolveProvider({ settings, local: null, cli: {} })).toThrow( + /missing: provider, baseURL, apiKey, model/, + ); + }); }); describe("validators", () => { From d66b32888bb027a968d20941aca32a03d99077dd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 19:43:20 -0700 Subject: [PATCH 2/2] Format the selected-provider fallback change --- src/config/settings.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/config/settings.ts b/src/config/settings.ts index a31f6f6b8..c611d1c7b 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -1305,8 +1305,7 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider { const throwOriginal = (): never => { const { selected, baseURL, apiKey, keyless } = fieldsFor(originalName); - const model = - nonempty(cli.model) ?? nonempty(local?.model) ?? resolveDefaultModel(selected); + const model = nonempty(cli.model) ?? nonempty(local?.model) ?? resolveDefaultModel(selected); const selectedMissing = originalName !== undefined && settings !== null && providers[originalName] === undefined; const missingApiKey = !keyless && (apiKey === undefined || apiKey.length === 0);