Skip to content

Commit e6a0806

Browse files
committed
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.
1 parent d8b69f1 commit e6a0806

4 files changed

Lines changed: 263 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3939
- Spawned workers enforce the parent permission gate. Unresolved worker
4040
approvals deny with a reason that names the permission subject so the parent
4141
can grant and retry, without hanging on operator approval.
42+
- A session falls back to the next resolvable provider when the selected
43+
default or project-local provider is missing or incomplete. `--provider`
44+
still errors.
4245

4346
### Changed
4447

docs/IMPLEMENTATION.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,12 @@ The `waitForApproval` default is resolved once at the watchdog boundary (`resolv
294294

295295
`loadConfig` resolves the active provider down to `{ apiKey, baseURL, model, providerName }` (the same struct the runtime consumes). Per field, highest wins:
296296

297-
- providerName: `--provider` > local file > `defaultProvider` > sole provider
298-
- model: `--model` > local file > provider `defaultModel` > first model
297+
- 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.
298+
- 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.
299299
- baseURL / apiKey: the selected provider only
300300

301+
If no remaining provider is fully resolvable, resolution throws the original pick's missing-fields error.
302+
301303
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.
302304

303305
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.

src/config/settings.ts

Lines changed: 121 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,10 +1226,14 @@ export interface ResolveInput {
12261226

12271227
// Resolve the active provider. Precedence per field (highest first):
12281228
// providerName: --provider > local > settings.defaultProvider > sole provider
1229-
// model: --model > local > provider.defaultModel > provider.models[0]
1229+
// If that pick is unusable and --provider was not set, walk local, defaultProvider,
1230+
// recentModels provider names (newest-first, unique), then remaining catalog keys.
1231+
// model: --model > local (original pick only) > recent pair (recent fallback only)
1232+
// > provider.defaultModel > provider.models[0]
12301233
// baseURL/apiKey: from the selected provider only
12311234
// Credentials and provider definitions come exclusively from the settings
1232-
// catalog; environment variables have no influence on resolution.
1235+
// catalog; environment variables have no influence on resolution. Session-only:
1236+
// this function does not write settings or mutate input.settings.
12331237
export function resolveProvider(input: ResolveInput): ResolvedProvider {
12341238
const { settings, local, cli } = input;
12351239
const providers = settings?.providers ?? {};
@@ -1241,43 +1245,78 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider {
12411245
throw new Error(`Provider "${cli.provider}" not found in settings (available: ${available}).`);
12421246
}
12431247

1244-
const providerName = cli.provider ?? local?.provider ?? settings?.defaultProvider ?? soleKey;
1248+
const originalName = cli.provider ?? local?.provider ?? settings?.defaultProvider ?? soleKey;
12451249

1246-
const selected = providerName !== undefined ? providers[providerName] : undefined;
1250+
const fieldsFor = (name: string | undefined) => {
1251+
const selected = name !== undefined ? providers[name] : undefined;
1252+
const go = isOpenCodeGoProvider({
1253+
...(name !== undefined ? { name } : {}),
1254+
...(selected?.opencodeGo === true ? { opencodeGo: true as const } : {}),
1255+
...(selected?.baseURL !== undefined ? { baseURL: selected.baseURL } : {}),
1256+
});
1257+
return {
1258+
selected,
1259+
go,
1260+
baseURL: go ? OPENCODE_GO_BASE_URL : selected?.baseURL,
1261+
apiKey: selected?.apiKey,
1262+
keyless: selected?.keyless === true,
1263+
};
1264+
};
12471265

1248-
const go = isOpenCodeGoProvider({
1249-
...(providerName !== undefined ? { name: providerName } : {}),
1250-
...(selected?.opencodeGo === true ? { opencodeGo: true as const } : {}),
1251-
...(selected?.baseURL !== undefined ? { baseURL: selected.baseURL } : {}),
1266+
const finish = (
1267+
name: string,
1268+
selected: ProviderSettings,
1269+
go: boolean,
1270+
baseURL: string,
1271+
apiKey: string | undefined,
1272+
keyless: boolean,
1273+
model: string,
1274+
): ResolvedProvider => ({
1275+
providerName: name,
1276+
baseURL: go ? OPENCODE_GO_BASE_URL : normalizeOpenAICompatibleBaseURL(baseURL),
1277+
apiKey: apiKey ?? "",
1278+
model,
1279+
...(keyless ? { keyless: true } : {}),
1280+
...(selected.verified === false ? { verified: false } : {}),
12521281
});
1253-
const baseURL = go ? OPENCODE_GO_BASE_URL : selected?.baseURL;
1254-
const apiKey = selected?.apiKey;
1255-
const keyless = selected?.keyless === true;
1256-
const model = cli.model ?? local?.model ?? resolveDefaultModel(selected);
1257-
1258-
// A provider name was selected (from local file or defaultProvider) but is not
1259-
// actually configured — distinguish this from "nothing configured at all" so
1260-
// the operator gets an actionable message instead of a generic missing-creds one.
1261-
const selectedMissing =
1262-
providerName !== undefined && settings !== null && providers[providerName] === undefined;
1263-
1264-
const missingApiKey = !keyless && (apiKey === undefined || apiKey.length === 0);
1265-
if (
1266-
providerName === undefined ||
1267-
providerName.length === 0 ||
1268-
baseURL === undefined ||
1269-
baseURL.length === 0 ||
1270-
missingApiKey ||
1271-
model === undefined ||
1272-
model.length === 0
1273-
) {
1282+
1283+
const tryCandidate = (
1284+
name: string | undefined,
1285+
model: string | undefined,
1286+
): ResolvedProvider | undefined => {
1287+
if (name === undefined || name.length === 0) return undefined;
1288+
const { selected, go, baseURL, apiKey, keyless } = fieldsFor(name);
1289+
if (selected === undefined) return undefined;
1290+
const missingApiKey = !keyless && (apiKey === undefined || apiKey.length === 0);
1291+
if (
1292+
baseURL === undefined ||
1293+
baseURL.length === 0 ||
1294+
missingApiKey ||
1295+
model === undefined ||
1296+
model.length === 0
1297+
) {
1298+
return undefined;
1299+
}
1300+
return finish(name, selected, go, baseURL, apiKey, keyless, model);
1301+
};
1302+
1303+
const nonempty = (value: string | undefined): string | undefined =>
1304+
value !== undefined && value.length > 0 ? value : undefined;
1305+
1306+
const throwOriginal = (): never => {
1307+
const { selected, baseURL, apiKey, keyless } = fieldsFor(originalName);
1308+
const model =
1309+
nonempty(cli.model) ?? nonempty(local?.model) ?? resolveDefaultModel(selected);
1310+
const selectedMissing =
1311+
originalName !== undefined && settings !== null && providers[originalName] === undefined;
1312+
const missingApiKey = !keyless && (apiKey === undefined || apiKey.length === 0);
12741313
const missing: string[] = [];
1275-
if (providerName === undefined || providerName.length === 0) missing.push("provider");
1314+
if (originalName === undefined || originalName.length === 0) missing.push("provider");
12761315
if (baseURL === undefined || baseURL.length === 0) missing.push("baseURL");
12771316
if (missingApiKey) missing.push("apiKey");
12781317
if (model === undefined || model.length === 0) missing.push("model");
12791318
const detail = selectedMissing
1280-
? ` Selected provider "${providerName}" is not configured in settings (available: ${
1319+
? ` Selected provider "${originalName}" is not configured in settings (available: ${
12811320
Object.keys(providers).join(", ") || "none"
12821321
}).`
12831322
: "";
@@ -1286,16 +1325,59 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider {
12861325
`Configure a provider in ${globalSettingsPath()}. ` +
12871326
`See docs/IMPLEMENTATION.md.`,
12881327
);
1289-
}
1328+
};
12901329

1291-
return {
1292-
providerName,
1293-
baseURL: go ? OPENCODE_GO_BASE_URL : normalizeOpenAICompatibleBaseURL(baseURL),
1294-
apiKey: apiKey ?? "",
1295-
model,
1296-
...(keyless ? { keyless: true } : {}),
1297-
...(selected?.verified === false ? { verified: false } : {}),
1330+
const original = tryCandidate(
1331+
originalName,
1332+
nonempty(cli.model) ??
1333+
nonempty(local?.model) ??
1334+
resolveDefaultModel(fieldsFor(originalName).selected),
1335+
);
1336+
if (original !== undefined) return original;
1337+
if (cli.provider !== undefined) return throwOriginal();
1338+
if (originalName === undefined || originalName.length === 0) return throwOriginal();
1339+
1340+
const tried = new Set<string>();
1341+
if (originalName !== undefined) tried.add(originalName);
1342+
1343+
const fallbacks: { name: string; model: string | undefined }[] = [];
1344+
const enqueue = (name: string | undefined, model: string | undefined) => {
1345+
if (name === undefined || tried.has(name)) return;
1346+
tried.add(name);
1347+
fallbacks.push({ name, model });
12981348
};
1349+
1350+
enqueue(
1351+
local?.provider,
1352+
nonempty(cli.model) ??
1353+
resolveDefaultModel(local?.provider !== undefined ? providers[local.provider] : undefined),
1354+
);
1355+
enqueue(
1356+
settings?.defaultProvider,
1357+
nonempty(cli.model) ??
1358+
resolveDefaultModel(
1359+
settings?.defaultProvider !== undefined ? providers[settings.defaultProvider] : undefined,
1360+
),
1361+
);
1362+
for (const ref of settings?.recentModels ?? []) {
1363+
if (tried.has(ref.provider)) continue;
1364+
tried.add(ref.provider);
1365+
const recentModel = ref.model.length > 0 ? ref.model : undefined;
1366+
fallbacks.push({
1367+
name: ref.provider,
1368+
model: nonempty(cli.model) ?? recentModel ?? resolveDefaultModel(providers[ref.provider]),
1369+
});
1370+
}
1371+
for (const name of providerKeys) {
1372+
enqueue(name, nonempty(cli.model) ?? resolveDefaultModel(providers[name]));
1373+
}
1374+
1375+
for (const candidate of fallbacks) {
1376+
const resolved = tryCandidate(candidate.name, candidate.model);
1377+
if (resolved !== undefined) return resolved;
1378+
}
1379+
1380+
return throwOriginal();
12991381
}
13001382

13011383
import type { InferenceSpec } from "../agent/profile-types.js";

src/settings.test.ts

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,27 +239,156 @@ describe("resolveProvider", () => {
239239
).toThrow(/not found/);
240240
});
241241

242-
test("names the offending provider when a local selection is not configured", () => {
243-
expect(() =>
244-
resolveProvider({ settings: twoProviders, local: { provider: "zzz" }, cli: {} }),
245-
).toThrow(/Selected provider "zzz" is not configured/);
242+
test("falls back from a missing local selection to defaultProvider", () => {
243+
const r = resolveProvider({ settings: twoProviders, local: { provider: "zzz" }, cli: {} });
244+
expect(r.providerName).toBe("a");
245+
expect(r.apiKey).toBe("a-key");
246+
expect(r.model).toBe("a-model");
246247
});
247248

248-
test("names the offending provider when defaultProvider is a typo", () => {
249+
test("falls back from a typo defaultProvider to a resolvable sibling", () => {
249250
const settings: Settings = {
250251
defaultProvider: "typo",
251252
providers: { solo: { baseURL: "https://s/v1", apiKey: "s-key", models: ["s-model"] } },
252253
};
254+
const r = resolveProvider({ settings, local: null, cli: {} });
255+
expect(r.providerName).toBe("solo");
256+
expect(r.apiKey).toBe("s-key");
257+
expect(r.model).toBe("s-model");
258+
});
259+
260+
test("falls back from an orphaned OAuth local selection to a healthy sibling", () => {
261+
const r = resolveProvider({
262+
settings: firepass,
263+
local: { provider: "xai/work" },
264+
cli: {},
265+
});
266+
expect(r.providerName).toBe("firepass");
267+
expect(r.apiKey).toBe("fp-key");
268+
});
269+
270+
test("falls back from a missing-key defaultProvider to a healthy sibling", () => {
271+
const settings: Settings = {
272+
defaultProvider: "broken",
273+
providers: {
274+
broken: { baseURL: "https://broken/v1", apiKey: "", models: ["broken-model"] },
275+
...firepass.providers,
276+
},
277+
};
278+
const r = resolveProvider({ settings, local: null, cli: {} });
279+
expect(r.providerName).toBe("firepass");
280+
expect(r.apiKey).toBe("fp-key");
281+
});
282+
283+
test("throws when an explicit CLI provider is present but unusable even if a sibling is healthy", () => {
284+
const settings: Settings = {
285+
defaultProvider: "firepass",
286+
providers: {
287+
broken: { baseURL: "https://broken/v1", apiKey: "", models: ["broken-model"] },
288+
...firepass.providers,
289+
},
290+
};
291+
expect(() => resolveProvider({ settings, local: null, cli: { provider: "broken" } })).toThrow(
292+
/missing: apiKey/,
293+
);
294+
expect(() => resolveProvider({ settings, local: null, cli: { provider: "broken" } })).toThrow(
295+
/Could not resolve an inference provider/,
296+
);
297+
});
298+
299+
test("throws the missing-fields error when no remaining provider is resolvable", () => {
300+
const settings: Settings = {
301+
defaultProvider: "typo",
302+
providers: {
303+
broken: { baseURL: "https://broken/v1", apiKey: "", models: ["broken-model"] },
304+
},
305+
};
306+
expect(() => resolveProvider({ settings, local: null, cli: {} })).toThrow(
307+
/Could not resolve an inference provider \(missing: baseURL, apiKey, model\)/,
308+
);
253309
expect(() => resolveProvider({ settings, local: null, cli: {} })).toThrow(
254-
/Selected provider "typo" is not configured/,
310+
/Selected provider "typo" is not configured in settings \(available: broken\)/,
255311
);
256312
});
257313

314+
test("prefers recentModels provider names over remaining catalog keys", () => {
315+
const settings: Settings = {
316+
defaultProvider: "gone",
317+
providers: {
318+
a: { baseURL: "https://a/v1", apiKey: "a-key", models: ["a-model"] },
319+
b: {
320+
baseURL: "https://b/v1",
321+
apiKey: "b-key",
322+
models: ["b-model"],
323+
defaultModel: "b-model",
324+
},
325+
},
326+
recentModels: [
327+
{ provider: "b", model: "b-recent" },
328+
{ provider: "ignored", model: "ignored-model" },
329+
],
330+
};
331+
const r = resolveProvider({ settings, local: null, cli: {} });
332+
expect(r.providerName).toBe("b");
333+
expect(r.model).toBe("b-recent");
334+
});
335+
336+
test("does not apply local.model to a fallback sibling", () => {
337+
const settings: Settings = {
338+
defaultProvider: "b",
339+
providers: {
340+
b: {
341+
baseURL: "https://b/v1",
342+
apiKey: "b-key",
343+
models: ["b-model"],
344+
defaultModel: "b-model",
345+
},
346+
},
347+
};
348+
const fallback = resolveProvider({
349+
settings,
350+
local: { provider: "gone", model: "pinned-model" },
351+
cli: {},
352+
});
353+
expect(fallback.providerName).toBe("b");
354+
expect(fallback.model).toBe("b-model");
355+
356+
const overlay = resolveProvider({
357+
settings,
358+
local: { provider: "gone", model: "pinned-model" },
359+
cli: { model: "cli-model" },
360+
});
361+
expect(overlay.providerName).toBe("b");
362+
expect(overlay.model).toBe("cli-model");
363+
});
364+
365+
test("treats an empty local.model as unset on a healthy original pick", () => {
366+
const r = resolveProvider({
367+
settings: twoProviders,
368+
local: { model: "" },
369+
cli: {},
370+
});
371+
expect(r.providerName).toBe("a");
372+
expect(r.model).toBe("a-model");
373+
});
374+
258375
test("throws listing every missing field", () => {
259376
expect(() => resolveProvider({ settings: null, local: null, cli: {} })).toThrow(
260377
/missing: provider, baseURL, apiKey, model/,
261378
);
262379
});
380+
381+
test("does not pick a catalog sibling when nothing was selected", () => {
382+
const settings: Settings = {
383+
providers: {
384+
a: { baseURL: "https://a/v1", apiKey: "a-key", models: ["a-model"] },
385+
b: { baseURL: "https://b/v1", apiKey: "b-key", models: ["b-model"] },
386+
},
387+
};
388+
expect(() => resolveProvider({ settings, local: null, cli: {} })).toThrow(
389+
/missing: provider, baseURL, apiKey, model/,
390+
);
391+
});
263392
});
264393

265394
describe("validators", () => {

0 commit comments

Comments
 (0)