Skip to content

Commit dce3df6

Browse files
committed
Remove automatic provider failover
1 parent 475dda2 commit dce3df6

19 files changed

Lines changed: 607 additions & 310 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 & 70 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,31 +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-
const src = buildInferenceSourceForRef(ref, ctx, settings);
182-
if (src === null) continue;
183-
if (seenIds.has(src.id)) continue;
184-
seenIds.add(src.id);
185-
out.push(src);
186-
}
187-
return out;
188-
}
189-
190-
export function prependActiveRef(refs: readonly ProviderRef[], active: ProviderRef): ProviderRef[] {
191-
const without = refs.filter((r) => refKey(r) !== refKey(active));
192-
return [active, ...without];
193-
}
194-
195-
// Builds the primary source for `head` plus one backup per other configured
196-
// provider, so a mid-run failure (bad credentials, dropped connection) has
197-
// somewhere else to go. `head` always wins as defaultSource when it builds.
198147
function buildSourceBundle(args: {
199148
settings: Settings | undefined;
200149
catalog: readonly ProviderCatalogEntry[];
@@ -208,25 +157,13 @@ function buildSourceBundle(args: {
208157
...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}),
209158
};
210159

211-
const refs =
212-
args.settings !== undefined
213-
? prependActiveRef(backupRefsFromSettings(args.settings, [args.head]), args.head)
214-
: [args.head];
215-
216-
const sources = buildSourcesFromRefs(refs, ctx, args.settings);
217-
const defaultId = args.head.provider;
218-
if (sources.length === 0) {
219-
const fallback = buildInferenceSourceForRef(args.head, ctx, args.settings);
220-
if (fallback === null) {
221-
throw new Error(`No inference source for provider "${defaultId}"`);
222-
}
223-
return { sources: [fallback], 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+
);
224165
}
225-
const hasDefault = sources.some((s) => s.id === defaultId);
226-
return {
227-
sources,
228-
defaultSource: hasDefault ? defaultId : (sources[0]?.id ?? defaultId),
229-
};
166+
return { sources: [source], defaultSource: source.id };
230167
}
231168

232169
export function buildMainSessionSources(args: {

src/exec/runner.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { createAgentToolset, type AgentToolset, type OperatorResult } from "../a
6363
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
6464
import { liveTelemetry } from "../telemetry/singleton.js";
6565
import { createTurnObserver } from "../telemetry/ai-observability.js";
66+
import { terminalProviderFailureMessage } from "../inference-error-message.js";
6667
import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js";
6768
import {
6869
expandExistingPluginMembers,
@@ -116,6 +117,32 @@ export function formatCaughtError(err: unknown): string {
116117
return err instanceof Error ? err.message : String(err);
117118
}
118119

120+
const SELECTED_PROVIDER_FAILURE = "SelectedProviderFailure";
121+
122+
export async function refreshSelectedProviderCredential<T>(refresh: () => Promise<T>): Promise<T> {
123+
try {
124+
return await refresh();
125+
} catch (cause) {
126+
const error = new Error(formatCaughtError(cause), { cause });
127+
error.name = SELECTED_PROVIDER_FAILURE;
128+
throw error;
129+
}
130+
}
131+
132+
export function execUserFailureMessage(
133+
config: Config,
134+
err: unknown,
135+
inferenceStarted: boolean,
136+
): string {
137+
if (inferenceStarted || (err instanceof Error && err.name === SELECTED_PROVIDER_FAILURE)) {
138+
return terminalProviderFailureMessage(
139+
config.providerName,
140+
config.settings?.providers[config.providerName]?.name,
141+
);
142+
}
143+
return formatCaughtError(err);
144+
}
145+
119146
/**
120147
* Exec-primary director overlay. Omit / skywalker keep the product default
121148
* (`loadSessionChatPrompt` + advertised session tools). Any other closed-fleet
@@ -252,6 +279,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
252279
let finalized = false;
253280
let turnsUsed = 0;
254281
let runSink: RunSink | null = null;
282+
let inferenceStarted = false;
255283

256284
const persist = async (
257285
status: "running" | "done" | "failed" | "cancelled",
@@ -605,15 +633,19 @@ export async function runExec(config: Config): Promise<ExecResult> {
605633

606634
// Refresh OAuth tokens before first inference when starting on codex/xai.
607635
if (initialCodexProfile !== undefined) {
608-
const { access } = await getValidCodexToken(initialCodexProfile);
636+
const { access } = await refreshSelectedProviderCredential(() =>
637+
getValidCodexToken(initialCodexProfile),
638+
);
609639
liveSource = { ...liveSource, apiKey: access };
610640
liveSubAgentProvider.current = {
611641
...liveSubAgentProvider.current,
612642
apiKey: access,
613643
};
614644
}
615645
if (initialXaiProfile !== undefined) {
616-
const { access } = await getValidXaiToken(initialXaiProfile);
646+
const { access } = await refreshSelectedProviderCredential(() =>
647+
getValidXaiToken(initialXaiProfile),
648+
);
617649
liveSource = { ...liveSource, apiKey: access };
618650
liveSubAgentProvider.current = {
619651
...liveSubAgentProvider.current,
@@ -739,6 +771,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
739771
let runError: string | undefined;
740772
let sinkStatus: ReturnType<typeof liveSink.getStatus> = "cancelled";
741773
try {
774+
inferenceStarted = true;
742775
// Final OAuth refresh immediately before send (token may have aged during MCP).
743776
if (initialCodexProfile !== undefined) {
744777
const { access } = await getValidCodexToken(initialCodexProfile);
@@ -835,17 +868,24 @@ export async function runExec(config: Config): Promise<ExecResult> {
835868
});
836869

837870
if (!sendCompleted || runError !== undefined || summaryStatus === "failed") {
838-
const message =
871+
const diagnosticMessage =
839872
runError ??
840873
(summaryStatus === "cancelled" ? "run cancelled before completion" : "run failed");
841-
stderr.write(`Error: ${message}\n`);
874+
const userMessage =
875+
summaryStatus === "failed"
876+
? terminalProviderFailureMessage(
877+
config.providerName,
878+
config.settings?.providers[config.providerName]?.name,
879+
)
880+
: diagnosticMessage;
881+
stderr.write(`Error: ${userMessage}\n`);
842882
const persistStatus = summaryStatus === "cancelled" ? "cancelled" : "failed";
843-
await persist(persistStatus, { error: message });
883+
await persist(persistStatus, { error: diagnosticMessage });
844884
return {
845885
exitCode: 1,
846886
sessionId,
847887
text: textOut,
848-
error: message,
888+
error: userMessage,
849889
status: summaryStatus,
850890
durationMs: finishedAt - startedAt,
851891
turnsUsed: runSink.getTurnCount(),
@@ -870,15 +910,16 @@ export async function runExec(config: Config): Promise<ExecResult> {
870910
model: config.model,
871911
};
872912
} catch (err) {
873-
const message = err instanceof Error ? err.message : String(err);
874-
logger.error("exec failed: {error}", { error: message });
875-
stderr.write(`Error: ${message}\n`);
876-
await persist("failed", { error: message });
913+
const diagnosticMessage = formatCaughtError(err);
914+
logger.error("exec failed: {error}", { error: diagnosticMessage });
915+
const userMessage = execUserFailureMessage(config, err, inferenceStarted);
916+
stderr.write(`Error: ${userMessage}\n`);
917+
await persist("failed", { error: diagnosticMessage });
877918
return {
878919
exitCode: 1,
879920
sessionId,
880921
text: textOut,
881-
error: message,
922+
error: userMessage,
882923
status: "failed",
883924
durationMs: Date.now() - startedAt,
884925
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)