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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
159 changes: 120 additions & 39 deletions src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {};
Expand All @@ -1241,43 +1245,77 @@ 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"
}).`
: "";
Expand All @@ -1286,16 +1324,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<string>();
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";
Expand Down
141 changes: 135 additions & 6 deletions src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading