Skip to content

Commit 04ffbac

Browse files
committed
Remove automatic provider failover
1 parent cb386a3 commit 04ffbac

17 files changed

Lines changed: 476 additions & 396 deletions

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: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { createAgentToolset, type AgentToolset, type OperatorResult } from "../a
6767
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
6868
import { liveTelemetry } from "../telemetry/singleton.js";
6969
import { createTurnObserver } from "../telemetry/ai-observability.js";
70+
import { terminalProviderFailureMessage } from "../inference-error-message.js";
7071
import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js";
7172
import {
7273
expandExistingPluginMembers,
@@ -120,6 +121,32 @@ export function formatCaughtError(err: unknown): string {
120121
return err instanceof Error ? err.message : String(err);
121122
}
122123

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

301329
const persist = async (
302330
status: "running" | "done" | "failed" | "cancelled",
@@ -651,15 +679,19 @@ export async function runExec(config: Config): Promise<ExecResult> {
651679

652680
// Refresh OAuth tokens before first inference when starting on codex/xai.
653681
if (initialCodexProfile !== undefined) {
654-
const { access } = await getValidCodexToken(initialCodexProfile);
682+
const { access } = await refreshSelectedProviderCredential(() =>
683+
getValidCodexToken(initialCodexProfile),
684+
);
655685
liveSource = { ...liveSource, apiKey: access };
656686
liveSubAgentProvider.current = {
657687
...liveSubAgentProvider.current,
658688
apiKey: access,
659689
};
660690
}
661691
if (initialXaiProfile !== undefined) {
662-
const { access } = await getValidXaiToken(initialXaiProfile);
692+
const { access } = await refreshSelectedProviderCredential(() =>
693+
getValidXaiToken(initialXaiProfile),
694+
);
663695
liveSource = { ...liveSource, apiKey: access };
664696
liveSubAgentProvider.current = {
665697
...liveSubAgentProvider.current,
@@ -785,6 +817,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
785817
let runError: string | undefined;
786818
let sinkStatus: ReturnType<typeof liveSink.getStatus> = "cancelled";
787819
try {
820+
inferenceStarted = true;
788821
// Final OAuth refresh immediately before send (token may have aged during MCP).
789822
if (initialCodexProfile !== undefined) {
790823
const { access } = await getValidCodexToken(initialCodexProfile);
@@ -881,17 +914,24 @@ export async function runExec(config: Config): Promise<ExecResult> {
881914
});
882915

883916
if (!sendCompleted || runError !== undefined || summaryStatus === "failed") {
884-
const message =
917+
const diagnosticMessage =
885918
runError ??
886919
(summaryStatus === "cancelled" ? "run cancelled before completion" : "run failed");
887-
stderr.write(`Error: ${message}\n`);
920+
const userMessage =
921+
summaryStatus === "failed"
922+
? terminalProviderFailureMessage(
923+
config.providerName,
924+
config.settings?.providers[config.providerName]?.name,
925+
)
926+
: diagnosticMessage;
927+
stderr.write(`Error: ${userMessage}\n`);
888928
const persistStatus = summaryStatus === "cancelled" ? "cancelled" : "failed";
889-
await persist(persistStatus, { error: message });
929+
await persist(persistStatus, { error: diagnosticMessage });
890930
return {
891931
exitCode: 1,
892932
sessionId,
893933
text: textOut,
894-
error: message,
934+
error: userMessage,
895935
status: summaryStatus,
896936
durationMs: finishedAt - startedAt,
897937
turnsUsed: runSink.getTurnCount(),
@@ -916,15 +956,16 @@ export async function runExec(config: Config): Promise<ExecResult> {
916956
model: config.model,
917957
};
918958
} 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 });
959+
const diagnosticMessage = formatCaughtError(err);
960+
logger.error("exec failed: {error}", { error: diagnosticMessage });
961+
const userMessage = execUserFailureMessage(config, err, inferenceStarted);
962+
stderr.write(`Error: ${userMessage}\n`);
963+
await persist("failed", { error: diagnosticMessage });
923964
return {
924965
exitCode: 1,
925966
sessionId,
926967
text: textOut,
927-
error: message,
968+
error: userMessage,
928969
status: "failed",
929970
durationMs: Date.now() - startedAt,
930971
turnsUsed: runSink?.getTurnCount() ?? turnsUsed,

src/inference-error-message.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, test } from "bun:test";
22

3-
import { inferenceErrorMessage } from "./inference-error-message.js";
3+
import {
4+
inferenceErrorMessage,
5+
terminalProviderFailureMessage,
6+
} from "./inference-error-message.js";
47

58
const CODEX_BODY = {
69
detail: {
@@ -89,3 +92,25 @@ describe("inferenceErrorMessage", () => {
8992
expect(line.toLowerCase()).toMatch(/log in again|sign in again/);
9093
});
9194
});
95+
96+
describe("terminalProviderFailureMessage", () => {
97+
test("uses the selected provider display label in the terminal guidance", () => {
98+
expect(terminalProviderFailureMessage("openai", "OpenAI")).toBe(
99+
'OpenAI Provider failed. Try again or switch with "/model" and select another.',
100+
);
101+
});
102+
103+
test("falls back to the selected provider id", () => {
104+
expect(terminalProviderFailureMessage("custom-provider")).toBe(
105+
'custom-provider Provider failed. Try again or switch with "/model" and select another.',
106+
);
107+
});
108+
109+
test("uses a safe label when the provider id contains only control sequences", () => {
110+
const message = terminalProviderFailureMessage("\u001b[31m\u001b[0m");
111+
expect(message).toBe(
112+
'Unknown Provider failed. Try again or switch with "/model" and select another.',
113+
);
114+
expect(message).not.toContain("\u001b");
115+
});
116+
});

src/inference-error-message.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
parseCodexUsageLimitError,
1212
} from "./auth/codex/usage-limit-error.js";
1313
import { codexProfileFromProviderName, isCodexProviderName } from "./config/codex-providers.js";
14+
import { stripTerminalControlSequences } from "./util/control-char-strip.js";
1415
import {
1516
gatewayOverloadUserMessage,
1617
isCodexShortRateLimitInferenceError,
@@ -91,6 +92,40 @@ function codexUsageLimitLine(error: InferenceErrorLike): string | undefined {
9192
return undefined;
9293
}
9394

95+
export function terminalProviderFailureMessage(providerId: string, displayLabel?: string): string {
96+
const preferred = displayLabel?.trim() || providerId;
97+
const sanitized = stripTerminalControlSequences(preferred).replace(/\s+/g, " ").trim();
98+
const label = sanitized.length > 0 ? sanitized : "Unknown";
99+
return `${label} Provider failed. Try again or switch with "/model" and select another.`;
100+
}
101+
102+
export type ResolvedProviderFailureError = Error & {
103+
readonly name: "ResolvedProviderFailureError";
104+
readonly diagnosticMessage: string;
105+
};
106+
107+
export function createResolvedProviderFailureError(
108+
providerId: string,
109+
diagnosticMessage: string,
110+
displayLabel?: string,
111+
): ResolvedProviderFailureError {
112+
return Object.assign(new Error(terminalProviderFailureMessage(providerId, displayLabel)), {
113+
name: "ResolvedProviderFailureError" as const,
114+
diagnosticMessage,
115+
});
116+
}
117+
118+
export function isResolvedProviderFailureError(
119+
error: unknown,
120+
): error is ResolvedProviderFailureError {
121+
return (
122+
error instanceof Error &&
123+
error.name === "ResolvedProviderFailureError" &&
124+
"diagnosticMessage" in error &&
125+
typeof error.diagnosticMessage === "string"
126+
);
127+
}
128+
94129
/** One line describing the failure, falling back to the provider's own message. */
95130
export function inferenceErrorMessage(error: InferenceErrorLike): string {
96131
if (isGatewayOverloadInferenceError(error)) return gatewayOverloadUserMessage(error);

src/session/run-sink.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ describe("createRunSink", () => {
174174
expect(failures).toEqual([{ turnIndex: 0, error: "429 rate limit" }]);
175175
});
176176

177-
test("uses unknown attribution when a fallback model fails before usage", () => {
177+
test("uses unknown attribution when a retry model fails before usage", () => {
178178
const { captured, runSink } = attributionHarness();
179179

180180
runSink.sink(event("inference.start", { model: "model-b" }));
@@ -203,14 +203,14 @@ describe("createRunSink", () => {
203203
});
204204
});
205205

206-
test("uses authoritative usage attribution for a failed fallback", () => {
206+
test("uses authoritative usage attribution for a failed retry attempt", () => {
207207
const { captured, runSink } = attributionHarness();
208208

209209
runSink.sink(event("inference.start", { model: "model-b" }));
210210
runSink.sink(
211211
event("inference.usage", {
212212
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
213-
source: { sourceId: "fallback", provider: "provider-b", model: "model-b" },
213+
source: { sourceId: "retry", provider: "provider-b", model: "model-b" },
214214
}),
215215
);
216216
failMessageRun(runSink);

src/session/run-sink.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ export interface RunSinkArgs {
2626
// while the turn index is the collector's current in-flight turn count.
2727
onTurnStarted?: (info: { turnIndex: number; model: string }) => void;
2828
// inference.usage is the first attempt event carrying the runtime-resolved
29-
// provider/model pair. It remains authoritative even when the selected source
30-
// outside the reactor has not changed during fallback.
29+
// provider/model pair. It remains authoritative across retry attempts.
3130
onTurnSourceObserved?: (info: { turnIndex: number; source: LastCycleSource }) => void;
3231
// Continues a resumed session's persisted run.json turn count instead of
3332
// restarting the collector at zero.

0 commit comments

Comments
 (0)