Skip to content

Commit 800edae

Browse files
committed
feat: add fallbackProviders + buildFallbackChain to generateText/streamText/agent
Multi-provider fallback chain: when primary provider returns 402/429/5xx or network error, automatically tries next provider in the chain. buildFallbackChain() auto-discovers available providers from env vars. isRetryableError() exported for downstream consumers. agent() sessions propagate fallbackProviders to all generate/stream calls.
1 parent 3a85747 commit 800edae

5 files changed

Lines changed: 269 additions & 9 deletions

File tree

src/api/agent.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111
import {
1212
generateText,
13+
type FallbackProviderEntry,
1314
type GenerateTextOptions,
1415
type GenerateTextResult,
1516
type Message,
@@ -44,6 +45,23 @@ export interface AgentOptions extends BaseAgentConfig {
4445
* - `string` — inject a custom CoT instruction when tools are present.
4546
*/
4647
chainOfThought?: boolean | string;
48+
/**
49+
* Ordered list of fallback providers to try when the primary provider
50+
* fails with a retryable error (HTTP 402/429/5xx, network errors).
51+
*
52+
* Applied to every `generate()`, `stream()`, and `session.send()` /
53+
* `session.stream()` call made through this agent.
54+
*
55+
* @see {@link GenerateTextOptions.fallbackProviders}
56+
*/
57+
fallbackProviders?: FallbackProviderEntry[];
58+
/**
59+
* Callback invoked when a fallback provider is about to be tried.
60+
*
61+
* @param error - The error that triggered the fallback.
62+
* @param fallbackProvider - The provider identifier being tried next.
63+
*/
64+
onFallback?: (error: Error, fallbackProvider: string) => void;
4765
}
4866

4967
/**
@@ -196,6 +214,8 @@ export function agent(opts: AgentOptions): Agent {
196214
apiKey: opts.apiKey,
197215
baseUrl: opts.baseUrl,
198216
usageLedger: effectiveLedger,
217+
fallbackProviders: opts.fallbackProviders,
218+
onFallback: opts.onFallback,
199219
};
200220

201221
const agentInstance: Agent = {

src/api/generateText.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,19 @@ export interface Plan {
114114
* Options for a {@link generateText} call.
115115
* Either `prompt` or `messages` (or both) must be provided.
116116
*/
117+
/**
118+
* A fallback provider entry specifying an alternative provider (and optionally
119+
* model) to try when the primary provider fails with a retryable error.
120+
*
121+
* @see {@link GenerateTextOptions.fallbackProviders}
122+
*/
123+
export interface FallbackProviderEntry {
124+
/** Provider identifier (e.g. `"openai"`, `"anthropic"`, `"openrouter"`). */
125+
provider: string;
126+
/** Model identifier override. When omitted, the provider's default text model is used. */
127+
model?: string;
128+
}
129+
117130
export interface GenerateTextOptions {
118131
/**
119132
* Provider name. When supplied without `model`, the default text model for
@@ -182,6 +195,38 @@ export interface GenerateTextOptions {
182195
* Set to `false` or omit to skip planning entirely (the default).
183196
*/
184197
planning?: boolean | PlanningConfig;
198+
/**
199+
* Ordered list of fallback providers to try when the primary provider fails
200+
* with a retryable error (HTTP 402/429/5xx, network errors, auth failures).
201+
*
202+
* Each entry specifies a provider and an optional model override. When the
203+
* model is omitted, the provider's default text model (from
204+
* {@link PROVIDER_DEFAULTS}) is used.
205+
*
206+
* Providers are tried left-to-right; the first successful response wins.
207+
* When all fallbacks are exhausted, the last error is re-thrown.
208+
*
209+
* @example
210+
* ```ts
211+
* const result = await generateText({
212+
* provider: 'anthropic',
213+
* prompt: 'Hello',
214+
* fallbackProviders: [
215+
* { provider: 'openai', model: 'gpt-4o-mini' },
216+
* { provider: 'openrouter' },
217+
* ],
218+
* });
219+
* ```
220+
*/
221+
fallbackProviders?: FallbackProviderEntry[];
222+
/**
223+
* Callback invoked when a fallback provider is about to be tried after the
224+
* primary (or a previous fallback) failed. Useful for logging or metrics.
225+
*
226+
* @param error - The error that triggered the fallback.
227+
* @param fallbackProvider - The provider identifier being tried next.
228+
*/
229+
onFallback?: (error: Error, fallbackProvider: string) => void;
185230
}
186231

187232
/**
@@ -370,6 +415,85 @@ function formatPlanForPrompt(plan: Plan): string {
370415
return `Follow this plan:\n${lines.join('\n')}`;
371416
}
372417

418+
// ---------------------------------------------------------------------------
419+
// Fallback helpers
420+
// ---------------------------------------------------------------------------
421+
422+
/**
423+
* HTTP status codes and network error patterns that indicate a transient or
424+
* provider-level failure worth retrying with a different provider.
425+
*
426+
* Matched status codes:
427+
* - `401` / `403` — authentication / authorization failure (key expired or wrong provider).
428+
* - `402` — payment required (quota exhausted).
429+
* - `429` — rate limit exceeded.
430+
* - `500` / `502` / `503` / `504` — server-side errors.
431+
*
432+
* Matched network errors:
433+
* - `fetch failed` — generic fetch rejection (DNS, TLS, etc.).
434+
* - `ECONNREFUSED` / `ETIMEDOUT` / `ENOTFOUND` — socket-level failures.
435+
*
436+
* @param error - The error to inspect.
437+
* @returns `true` when the error is likely transient and a different provider
438+
* might succeed; `false` for deterministic user-input errors.
439+
*
440+
* @internal
441+
*/
442+
export function isRetryableError(error: unknown): boolean {
443+
if (!(error instanceof Error)) return false;
444+
const msg = error.message;
445+
// HTTP status codes that warrant a provider switch
446+
if (/\b(402|429|500|502|503|504|401|403)\b/.test(msg)) return true;
447+
// Network-level failures
448+
if (/fetch failed|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(msg)) return true;
449+
return false;
450+
}
451+
452+
/**
453+
* Auto-discovers available LLM providers from well-known environment variables
454+
* and builds an ordered fallback chain.
455+
*
456+
* Each entry in the returned array contains a provider identifier and an
457+
* optional cheap model suitable for fallback use. Providers are ordered by
458+
* general availability and cost-effectiveness:
459+
* 1. OpenAI (`gpt-4o-mini`)
460+
* 2. Anthropic (`claude-haiku-4-5-20251001`)
461+
* 3. OpenRouter (default model)
462+
* 4. Gemini (`gemini-2.5-flash`)
463+
*
464+
* @param excludeProvider - Provider to omit from the chain (typically the
465+
* primary provider that already failed).
466+
* @returns An array of `{ provider, model? }` entries ready for use as
467+
* {@link GenerateTextOptions.fallbackProviders}.
468+
*
469+
* @example
470+
* ```ts
471+
* // Primary is anthropic — build fallback chain from remaining providers
472+
* const chain = buildFallbackChain('anthropic');
473+
* // => [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'openrouter' }, ...]
474+
* ```
475+
*/
476+
export function buildFallbackChain(
477+
excludeProvider?: string,
478+
): FallbackProviderEntry[] {
479+
const chain: FallbackProviderEntry[] = [];
480+
481+
if (process.env.OPENAI_API_KEY && excludeProvider !== 'openai') {
482+
chain.push({ provider: 'openai', model: 'gpt-4o-mini' });
483+
}
484+
if (process.env.ANTHROPIC_API_KEY && excludeProvider !== 'anthropic') {
485+
chain.push({ provider: 'anthropic', model: 'claude-haiku-4-5-20251001' });
486+
}
487+
if (process.env.OPENROUTER_API_KEY && excludeProvider !== 'openrouter') {
488+
chain.push({ provider: 'openrouter' });
489+
}
490+
if (process.env.GEMINI_API_KEY && excludeProvider !== 'gemini') {
491+
chain.push({ provider: 'gemini' });
492+
}
493+
494+
return chain;
495+
}
496+
373497
function buildHelperToolExecutionContext(
374498
source: 'generateText',
375499
runId: string,
@@ -702,6 +826,49 @@ export async function generateText(opts: GenerateTextOptions): Promise<GenerateT
702826
};
703827
});
704828
} catch (error) {
829+
// ── Fallback chain ────────────────────────────────────────────────
830+
// When the primary provider fails with a retryable error and
831+
// fallbackProviders are configured, try each fallback in order.
832+
// The first successful response wins; if all fail, the last error
833+
// is re-thrown.
834+
if (
835+
opts.fallbackProviders?.length &&
836+
isRetryableError(error)
837+
) {
838+
let lastError = error;
839+
for (const fb of opts.fallbackProviders) {
840+
try {
841+
opts.onFallback?.(
842+
lastError instanceof Error ? lastError : new Error(String(lastError)),
843+
fb.provider,
844+
);
845+
// Build a new options object targeting the fallback provider,
846+
// stripping the fallbackProviders to prevent recursive fallback.
847+
const fallbackResult = await generateText({
848+
...opts,
849+
provider: fb.provider,
850+
model: fb.model,
851+
// Clear explicit keys/URLs so resolution uses env vars for the
852+
// fallback provider rather than the primary's overrides.
853+
apiKey: undefined,
854+
baseUrl: undefined,
855+
fallbackProviders: undefined,
856+
onFallback: undefined,
857+
});
858+
metricStatus = 'ok';
859+
metricUsage = fallbackResult.usage;
860+
metricProviderId = fallbackResult.provider;
861+
metricModelId = fallbackResult.model;
862+
return fallbackResult;
863+
} catch (fbError) {
864+
lastError = fbError;
865+
}
866+
}
867+
// All fallbacks exhausted — fall through to throw
868+
metricStatus = 'error';
869+
throw lastError;
870+
}
871+
705872
metricStatus = 'error';
706873
throw error;
707874
} finally {

src/api/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ export type { AgentOSToolResultInput } from './types/AgentOSToolResultInput.js';
1616
export type { AgentOSPendingExternalToolRequest } from './types/AgentOSPendingExternalToolRequest.js';
1717

1818
// --- High-level generation functions ---
19-
export { generateText, type GenerateTextOptions, type GenerateTextResult } from './generateText.js';
19+
export {
20+
generateText,
21+
isRetryableError,
22+
buildFallbackChain,
23+
type GenerateTextOptions,
24+
type GenerateTextResult,
25+
type FallbackProviderEntry,
26+
} from './generateText.js';
2027
export { streamText } from './streamText.js';
2128
export { generateObject } from './generateObject.js';
2229
export { streamObject } from './streamObject.js';

src/api/streamText.ts

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { attachUsageAttributes, toTurnMetricUsage } from './observability.js';
1313
import { adaptTools } from './runtime/toolAdapter.js';
1414
import {
1515
createPlan,
16+
isRetryableError,
1617
resolveChainOfThought,
1718
type GenerateTextOptions,
1819
type Plan,
@@ -431,14 +432,78 @@ export function streamText(opts: GenerateTextOptions): StreamTextResult {
431432
resolveUsage!(usage);
432433
resolveToolCalls!(allToolCalls);
433434
} catch (err: any) {
434-
metricStatus = 'error';
435435
const error = err instanceof Error ? err : new Error(String(err));
436-
const part: StreamPart = { type: 'error', error };
437-
parts.push(part);
438-
yield part;
439-
resolveText!(finalText);
440-
resolveUsage!(usage);
441-
resolveToolCalls!(allToolCalls);
436+
437+
// ── Fallback chain for streaming ──────────────────────────────
438+
// When the primary provider fails with a retryable error and
439+
// fallbackProviders are configured, delegate to a new streamText
440+
// call targeting the next available fallback. All parts from the
441+
// fallback stream are yielded transparently to the consumer.
442+
if (opts.fallbackProviders?.length && isRetryableError(error)) {
443+
let lastFallbackError: Error = error;
444+
let fallbackSucceeded = false;
445+
446+
for (const fb of opts.fallbackProviders) {
447+
try {
448+
opts.onFallback?.(lastFallbackError, fb.provider);
449+
const fallbackResult = streamText({
450+
...opts,
451+
provider: fb.provider,
452+
model: fb.model,
453+
apiKey: undefined,
454+
baseUrl: undefined,
455+
fallbackProviders: undefined,
456+
onFallback: undefined,
457+
});
458+
459+
// Pipe all parts from the fallback stream to the consumer
460+
for await (const fbPart of fallbackResult.fullStream) {
461+
parts.push(fbPart);
462+
yield fbPart;
463+
}
464+
465+
// Resolve aggregated promises from the fallback stream
466+
finalText = await fallbackResult.text;
467+
const fbUsage = await fallbackResult.usage;
468+
usage.promptTokens += fbUsage.promptTokens;
469+
usage.completionTokens += fbUsage.completionTokens;
470+
usage.totalTokens += fbUsage.totalTokens;
471+
if (typeof fbUsage.costUSD === 'number') {
472+
usage.costUSD = (usage.costUSD ?? 0) + fbUsage.costUSD;
473+
}
474+
475+
const fbToolCalls = await fallbackResult.toolCalls;
476+
allToolCalls.push(...fbToolCalls);
477+
478+
fallbackSucceeded = true;
479+
break;
480+
} catch (fbErr: any) {
481+
lastFallbackError = fbErr instanceof Error ? fbErr : new Error(String(fbErr));
482+
}
483+
}
484+
485+
if (fallbackSucceeded) {
486+
resolveText!(finalText);
487+
resolveUsage!(usage);
488+
resolveToolCalls!(allToolCalls);
489+
} else {
490+
metricStatus = 'error';
491+
const errorPart: StreamPart = { type: 'error', error: lastFallbackError };
492+
parts.push(errorPart);
493+
yield errorPart;
494+
resolveText!(finalText);
495+
resolveUsage!(usage);
496+
resolveToolCalls!(allToolCalls);
497+
}
498+
} else {
499+
metricStatus = 'error';
500+
const part: StreamPart = { type: 'error', error };
501+
parts.push(part);
502+
yield part;
503+
resolveText!(finalText);
504+
resolveUsage!(usage);
505+
resolveToolCalls!(allToolCalls);
506+
}
442507
} finally {
443508
rootSpan?.setAttribute('agentos.api.tool_calls', allToolCalls.length);
444509
if (metricStatus === 'error') {

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,11 @@ export {
187187
} from './core/config/extensionSecrets.js';
188188

189189
// --- High-Level API (AI SDK style) ---
190-
export { generateText } from './api/generateText.js';
190+
export { generateText, isRetryableError, buildFallbackChain } from './api/generateText.js';
191191
export type {
192192
GenerateTextOptions,
193193
GenerateTextResult,
194+
FallbackProviderEntry,
194195
Message,
195196
ToolCallRecord,
196197
TokenUsage,

0 commit comments

Comments
 (0)