Skip to content

Commit 04ab6e0

Browse files
Remove automatic provider failover (#747)
* Remove automatic provider failover * Harden provider failure handling * Keep credential failures on login copy Auth and OAuth-refresh send failures were still suggesting /model. Keep that copy on login, drop the fused task coverage after spawn/wait became the only path, and document the no-failover behavior. * Format the spawn auth-failure reason
1 parent 3c47902 commit 04ab6e0

20 files changed

Lines changed: 683 additions & 517 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3131

3232
- Cancelling a `task` or `wait_agents` worker reports wait status `interrupted`,
3333
not `failed`.
34+
- Inference no longer fails over to a backup provider. A selected-provider
35+
failure stays on that provider; switch with `/model`.
3436

3537
### Fixed
3638

@@ -46,6 +48,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
4648
when a followup is already in flight.
4749
- `interrupt_agent` flips the wait mailbox so soft interrupt unblocks
4850
`wait_agents` while the background run is still in flight.
51+
- Credential-refresh and auth send failures tell the user to log in again
52+
instead of suggesting `/model`.
4953

5054
## [0.3.11] - 2026-08-31
5155

docs/TELEMETRY.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,11 @@ settles an unresolved turn once as an unsampled terminal failure. Attribution
169169
uses the latest `inference.usage` source, the first lifecycle payload carrying
170170
the runtime-resolved provider/model pair for an attempt. Each `inference.start`
171171
clears that authoritative source and records the newly attempted model. If a
172-
fallback fails before usage exposes its source, telemetry retains that actual
173-
model but uses the fixed `unknown` provider/source bucket rather than attributing
174-
it to the previously selected provider. When the attempted model still matches
175-
the selected source, that full source remains valid. Therefore a parent turn
176-
emits at most one terminal `$ai_generation`, including retry and failover paths.
172+
retry fails before usage exposes its source, telemetry retains that attempted
173+
model but uses the fixed `unknown` provider/source bucket. When the attempted
174+
model still matches the selected source, that full source remains valid.
175+
Therefore a parent turn emits at most one terminal `$ai_generation`, including
176+
retry paths.
177177

178178
## What's never collected
179179

src/config/inference-sources.ts

Lines changed: 7 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -13,46 +13,20 @@ import type { Settings } from "./settings.js";
1313
import { resolveSessionEffort, type ReasoningEffort } from "../provider/reasoning-effort.js";
1414
import { SOURCE_MAX_TOKENS } from "./index.js";
1515
import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js";
16-
import { resolveDefaultModel } from "./providers.js";
1716

1817
export interface BuildSourceContext {
1918
sessionId: string;
2019
reasoningEffort?: ReasoningEffort;
2120
catalog: readonly ProviderCatalogEntry[];
2221
}
2322

24-
// A resolved provider+model, with optional reasoningEffort — the unit both
25-
// the primary source and its backups are built from.
23+
// A resolved provider+model with optional reasoning effort.
2624
export interface ProviderRef {
2725
provider: string;
2826
model: string;
2927
reasoningEffort?: ReasoningEffort;
3028
}
3129

32-
function refKey(ref: ProviderRef): string {
33-
return `${ref.provider}\0${ref.model}`;
34-
}
35-
36-
// Every other configured provider, one model each, so a primary source that
37-
// fails to build (bad credentials, missing baseURL) still has somewhere to
38-
// fall back to. Order follows settings.providers; providers already covered
39-
// by `existing` are skipped.
40-
function backupRefsFromSettings(
41-
settings: Settings,
42-
existing: readonly ProviderRef[],
43-
): ProviderRef[] {
44-
const seenProviders = new Set(existing.map((r) => r.provider));
45-
const tail: ProviderRef[] = [];
46-
for (const [provider, p] of Object.entries(settings.providers)) {
47-
if (seenProviders.has(provider)) continue;
48-
const model = resolveDefaultModel(p);
49-
if (model === undefined || model.length === 0) continue;
50-
seenProviders.add(provider);
51-
tail.push({ provider, model });
52-
}
53-
return tail;
54-
}
55-
5630
function catalogEntry(
5731
catalog: readonly ProviderCatalogEntry[],
5832
provider: string,
@@ -170,38 +144,6 @@ export function buildInferenceSourceForRef(
170144
};
171145
}
172146

173-
export function buildSourcesFromRefs(
174-
refs: readonly ProviderRef[],
175-
ctx: BuildSourceContext,
176-
settings: Settings | undefined,
177-
): InferenceSource[] {
178-
const out: InferenceSource[] = [];
179-
const seenIds = new Set<string>();
180-
for (const ref of refs) {
181-
let src: InferenceSource | null;
182-
try {
183-
src = buildInferenceSourceForRef(ref, ctx, settings);
184-
} catch {
185-
// A leftover sibling URL (e.g. Custom `/api/tags`) must not take down the
186-
// whole bundle. Head failure is re-checked in `buildSourceBundle`.
187-
continue;
188-
}
189-
if (src === null) continue;
190-
if (seenIds.has(src.id)) continue;
191-
seenIds.add(src.id);
192-
out.push(src);
193-
}
194-
return out;
195-
}
196-
197-
export function prependActiveRef(refs: readonly ProviderRef[], active: ProviderRef): ProviderRef[] {
198-
const without = refs.filter((r) => refKey(r) !== refKey(active));
199-
return [active, ...without];
200-
}
201-
202-
// Builds the primary source for `head` plus one backup per other configured
203-
// provider, so a mid-run failure (bad credentials, dropped connection) has
204-
// somewhere else to go. `head` always wins as defaultSource when it builds.
205147
function buildSourceBundle(args: {
206148
settings: Settings | undefined;
207149
catalog: readonly ProviderCatalogEntry[];
@@ -215,30 +157,13 @@ function buildSourceBundle(args: {
215157
...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}),
216158
};
217159

218-
const refs =
219-
args.settings !== undefined
220-
? prependActiveRef(backupRefsFromSettings(args.settings, [args.head]), args.head)
221-
: [args.head];
222-
223-
const sources = buildSourcesFromRefs(refs, ctx, args.settings);
224-
const defaultId = args.head.provider;
225-
const hasDefault = sources.some((s) => s.id === defaultId);
226-
if (!hasDefault) {
227-
let fallback: InferenceSource | null;
228-
try {
229-
fallback = buildInferenceSourceForRef(args.head, ctx, args.settings);
230-
} catch (error) {
231-
throw new Error(`No inference source for provider "${defaultId}"`, { cause: error });
232-
}
233-
if (fallback === null) {
234-
throw new Error(`No inference source for provider "${defaultId}"`);
235-
}
236-
return { sources: [fallback, ...sources], defaultSource: fallback.id };
160+
const source = buildInferenceSourceForRef(args.head, ctx, args.settings);
161+
if (source === null) {
162+
throw new Error(
163+
`Unable to build inference source for selected provider "${args.head.provider}"`,
164+
);
237165
}
238-
return {
239-
sources,
240-
defaultSource: defaultId,
241-
};
166+
return { sources: [source], defaultSource: source.id };
242167
}
243168

244169
export function buildMainSessionSources(args: {

src/exec/runner.ts

Lines changed: 70 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,7 @@ import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing";
1313
import { getLogger } from "@intx/log";
1414
import { createOptimizedContextStore } from "../session/optimized-context-store.js";
1515
import { type } from "arktype";
16-
import {
17-
buildCodexSource,
18-
buildOpenAISource,
19-
buildXaiSource,
20-
type Config,
21-
} from "../config/index.js";
16+
import { type Config } from "../config/index.js";
2217
import {
2318
loadLocalSettings,
2419
resolveLocalSettingsPath,
@@ -67,6 +62,11 @@ import { createAgentToolset, type AgentToolset, type OperatorResult } from "../a
6762
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
6863
import { liveTelemetry } from "../telemetry/singleton.js";
6964
import { createTurnObserver } from "../telemetry/ai-observability.js";
65+
import {
66+
CREDENTIAL_FAILURE_USER_MESSAGE,
67+
isResolvedProviderFailureError,
68+
terminalProviderFailureMessage,
69+
} from "../inference-error-message.js";
7070
import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js";
7171
import {
7272
expandExistingPluginMembers,
@@ -120,6 +120,35 @@ export function formatCaughtError(err: unknown): string {
120120
return err instanceof Error ? err.message : String(err);
121121
}
122122

123+
const SELECTED_PROVIDER_FAILURE = "SelectedProviderFailure";
124+
125+
export async function refreshSelectedProviderCredential<T>(refresh: () => Promise<T>): Promise<T> {
126+
try {
127+
return await refresh();
128+
} catch (cause) {
129+
const error = new Error(formatCaughtError(cause), { cause });
130+
error.name = SELECTED_PROVIDER_FAILURE;
131+
throw error;
132+
}
133+
}
134+
135+
export function execUserFailureMessage(
136+
config: Config,
137+
err: unknown,
138+
providerFailureObserved: boolean,
139+
): string {
140+
if (err instanceof Error && err.name === SELECTED_PROVIDER_FAILURE) {
141+
return CREDENTIAL_FAILURE_USER_MESSAGE;
142+
}
143+
if (providerFailureObserved || isResolvedProviderFailureError(err)) {
144+
return terminalProviderFailureMessage(
145+
config.providerName,
146+
config.settings?.providers[config.providerName]?.name,
147+
);
148+
}
149+
return formatCaughtError(err);
150+
}
151+
123152
/**
124153
* Headless analogue of TUI `runtime-shutdown`: abort live workers, then close
125154
* the primary agent and dispose the toolset. `cancelAll` is fire-and-forget —
@@ -297,6 +326,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
297326
let finalized = false;
298327
let turnsUsed = 0;
299328
let runSink: RunSink | null = null;
329+
let providerFailureObserved = false;
300330

301331
const persist = async (
302332
status: "running" | "done" | "failed" | "cancelled",
@@ -586,56 +616,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
586616

587617
const initialCodexProfile = codexProfileFromProviderName(config.providerName);
588618
const initialXaiProfile = xaiProfileFromProviderName(config.providerName);
589-
const initialCodexAccountId = config.providers.find(
590-
(p) => p.name === config.providerName,
591-
)?.codexAccountId;
592-
593-
const buildOpenAICompatibleInitialSource = (): InferenceSource =>
594-
buildOpenAISource({
595-
id: config.providerName,
596-
baseURL: config.baseURL,
597-
apiKey: config.apiKey,
598-
model: config.model,
599-
...(config.reasoningEffort !== undefined
600-
? { reasoningEffort: config.reasoningEffort }
601-
: {}),
602-
});
603-
604-
const buildSessionSources = (): { sources: InferenceSource[]; defaultSource: string } =>
605-
buildSessionSourcesFromConfig(config, sessionId);
606-
607-
const initialBundle = buildSessionSources();
619+
const initialBundle = buildSessionSourcesFromConfig(config, sessionId);
608620
const liveSources = initialBundle.sources;
609621
const liveDefaultSource = initialBundle.defaultSource;
610-
611-
const buildInitialSourceFallback = (): InferenceSource =>
612-
initialCodexProfile !== undefined
613-
? buildCodexSource({
614-
id: config.providerName,
615-
apiKey: config.apiKey,
616-
model: config.model,
617-
sessionId,
618-
...(initialCodexAccountId !== undefined ? { accountId: initialCodexAccountId } : {}),
619-
...(config.reasoningEffort !== undefined
620-
? { reasoningEffort: config.reasoningEffort }
621-
: {}),
622-
})
623-
: initialXaiProfile !== undefined
624-
? buildXaiSource({
625-
id: config.providerName,
626-
apiKey: config.apiKey,
627-
model: config.model,
628-
sessionId,
629-
...(config.reasoningEffort !== undefined
630-
? { reasoningEffort: config.reasoningEffort }
631-
: {}),
632-
})
633-
: buildOpenAICompatibleInitialSource();
634-
635-
let liveSource: InferenceSource =
636-
liveSources.find((s) => s.id === liveDefaultSource) ??
637-
liveSources[0] ??
638-
buildInitialSourceFallback();
622+
const selectedSource = liveSources[0];
623+
if (selectedSource === undefined) {
624+
throw new Error("Selected inference source was not assembled");
625+
}
626+
let liveSource: InferenceSource = selectedSource;
639627

640628
// Refresh pinned Codex instructions before first inference, same as the
641629
// TUI path. Best-effort: a network failure falls back to the disk cache
@@ -651,15 +639,19 @@ export async function runExec(config: Config): Promise<ExecResult> {
651639

652640
// Refresh OAuth tokens before first inference when starting on codex/xai.
653641
if (initialCodexProfile !== undefined) {
654-
const { access } = await getValidCodexToken(initialCodexProfile);
642+
const { access } = await refreshSelectedProviderCredential(() =>
643+
getValidCodexToken(initialCodexProfile),
644+
);
655645
liveSource = { ...liveSource, apiKey: access };
656646
liveSubAgentProvider.current = {
657647
...liveSubAgentProvider.current,
658648
apiKey: access,
659649
};
660650
}
661651
if (initialXaiProfile !== undefined) {
662-
const { access } = await getValidXaiToken(initialXaiProfile);
652+
const { access } = await refreshSelectedProviderCredential(() =>
653+
getValidXaiToken(initialXaiProfile),
654+
);
663655
liveSource = { ...liveSource, apiKey: access };
664656
liveSubAgentProvider.current = {
665657
...liveSubAgentProvider.current,
@@ -762,6 +754,11 @@ export async function runExec(config: Config): Promise<ExecResult> {
762754
// its partial output in partial.jsonl instead of vanishing.
763755
const cycleRecorder = createCycleTextRecorder(() => workdir);
764756
const sink = (event: ReactorEmittedEvent): void => {
757+
if (event.type === "inference.start" || event.type === "inference.done") {
758+
providerFailureObserved = false;
759+
} else if (event.type === "inference.error") {
760+
providerFailureObserved = true;
761+
}
765762
liveSink.sink(event);
766763
cycleRecorder.handleEvent(event);
767764
if (event.type === "inference.text.delta") {
@@ -881,17 +878,24 @@ export async function runExec(config: Config): Promise<ExecResult> {
881878
});
882879

883880
if (!sendCompleted || runError !== undefined || summaryStatus === "failed") {
884-
const message =
881+
const diagnosticMessage =
885882
runError ??
886883
(summaryStatus === "cancelled" ? "run cancelled before completion" : "run failed");
887-
stderr.write(`Error: ${message}\n`);
884+
const userMessage =
885+
summaryStatus === "failed"
886+
? terminalProviderFailureMessage(
887+
config.providerName,
888+
config.settings?.providers[config.providerName]?.name,
889+
)
890+
: diagnosticMessage;
891+
stderr.write(`Error: ${userMessage}\n`);
888892
const persistStatus = summaryStatus === "cancelled" ? "cancelled" : "failed";
889-
await persist(persistStatus, { error: message });
893+
await persist(persistStatus, { error: diagnosticMessage });
890894
return {
891895
exitCode: 1,
892896
sessionId,
893897
text: textOut,
894-
error: message,
898+
error: userMessage,
895899
status: summaryStatus,
896900
durationMs: finishedAt - startedAt,
897901
turnsUsed: runSink.getTurnCount(),
@@ -916,15 +920,16 @@ export async function runExec(config: Config): Promise<ExecResult> {
916920
model: config.model,
917921
};
918922
} catch (err) {
919-
const message = err instanceof Error ? err.message : String(err);
920-
logger.error("exec failed: {error}", { error: message });
921-
stderr.write(`Error: ${message}\n`);
922-
await persist("failed", { error: message });
923+
const diagnosticMessage = formatCaughtError(err);
924+
logger.error("exec failed: {error}", { error: diagnosticMessage });
925+
const userMessage = execUserFailureMessage(config, err, providerFailureObserved);
926+
stderr.write(`Error: ${userMessage}\n`);
927+
await persist("failed", { error: diagnosticMessage });
923928
return {
924929
exitCode: 1,
925930
sessionId,
926931
text: textOut,
927-
error: message,
932+
error: userMessage,
928933
status: "failed",
929934
durationMs: Date.now() - startedAt,
930935
turnsUsed: runSink?.getTurnCount() ?? turnsUsed,

0 commit comments

Comments
 (0)