-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add cross-provider fallback routing with request/response #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| /** | ||
| * Example 8: Cross-Provider Fallback | ||
| * | ||
| * Demonstrates automatic fallback across different LLM providers. | ||
| * If GPT-4o fails, automatically retries with Claude, then Gemini — | ||
| * all transparent to the caller with a unified budget. | ||
| * | ||
| * Prerequisites: | ||
| * npm install tokenfirewall | ||
| * Set environment variables: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY | ||
| */ | ||
|
|
||
| const { | ||
| createBudgetGuard, | ||
| createModelRouter, | ||
| registerApiKeys, | ||
| patchGlobalFetch, | ||
| getBudgetStatus, | ||
| isCrossProviderEnabled, | ||
| } = require("tokenfirewall"); | ||
|
|
||
| // 1. Register API keys for all the providers you want to fallback between | ||
| registerApiKeys({ | ||
| openai: process.env.OPENAI_API_KEY, | ||
| anthropic: process.env.ANTHROPIC_API_KEY, | ||
| gemini: process.env.GEMINI_API_KEY, | ||
| }); | ||
|
|
||
| // 2. Create budget guard — costs are tracked across ALL providers | ||
| createBudgetGuard({ | ||
| monthlyLimit: 50, // $50 USD total budget | ||
| mode: "block", | ||
| }); | ||
|
|
||
| // 3. Create model router with cross-provider fallback chains | ||
| createModelRouter({ | ||
| strategy: "fallback", | ||
| fallbackMap: { | ||
| // If GPT-4o fails → try Claude 3.5 Sonnet → then Gemini 2.5 Pro | ||
| "gpt-4o": ["claude-3-5-sonnet-20241022", "gemini-2.5-pro"], | ||
| // If Claude fails → try GPT-4o-mini → then Gemini | ||
| "claude-3-5-sonnet-20241022": ["gpt-4o-mini", "gemini-2.5-pro"], | ||
| // If Gemini fails → try GPT-4o-mini → then Claude Haiku | ||
| "gemini-2.5-pro": ["gpt-4o-mini", "claude-3-5-haiku-20241022"], | ||
| }, | ||
| maxRetries: 2, | ||
| enableCrossProvider: true, // <-- enable cross-provider fallback | ||
| }); | ||
|
|
||
| // 4. Patch global fetch | ||
| patchGlobalFetch(); | ||
|
|
||
| console.log("Cross-provider enabled:", isCrossProviderEnabled()); | ||
|
|
||
| // 5. Make a normal API call — fallback is fully transparent | ||
| async function main() { | ||
| try { | ||
| const response = await fetch("https://api.openai.com/v1/chat/completions", { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| model: "gpt-4o", | ||
| messages: [{ role: "user", content: "What is the capital of France?" }], | ||
| max_tokens: 100, | ||
| }), | ||
| }); | ||
|
|
||
| const data = await response.json(); | ||
| console.log("\nResponse:", data.choices?.[0]?.message?.content); | ||
|
|
||
| // Budget is tracked across all providers | ||
| const status = getBudgetStatus(); | ||
| console.log("\nBudget status:", status); | ||
|
|
||
| // If GPT-4o failed, the response was automatically: | ||
| // 1. Transformed to Claude/Gemini format | ||
| // 2. Sent to the fallback provider | ||
| // 3. Response transformed back to OpenAI format | ||
| // 4. Returned as if GPT-4o answered — fully transparent! | ||
| } catch (error) { | ||
| console.error("All providers failed:", error.message); | ||
| } | ||
| } | ||
|
|
||
| main(); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,6 +3,11 @@ import { calculateCost } from "../core/costEngine"; | |||||||||||||||||||||
| import { logger } from "../logger"; | ||||||||||||||||||||||
| import { BudgetManager } from "../core/budgetManager"; | ||||||||||||||||||||||
| import { ModelRouter } from "../router/modelRouter"; | ||||||||||||||||||||||
| import { detectProvider, buildProviderUrl, isCrossProviderSwitch } from "../router/providerDetector"; | ||||||||||||||||||||||
| import { apiKeyManager } from "../router/apiKeyManager"; | ||||||||||||||||||||||
| import { buildProviderHeaders, appendApiKeyToUrl } from "../router/providerHeaders"; | ||||||||||||||||||||||
| import { transformRequest } from "../router/requestTransformer"; | ||||||||||||||||||||||
| import { transformResponse } from "../router/responseTransformer"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| let isPatched = false; | ||||||||||||||||||||||
| let budgetManager: BudgetManager | null = null; | ||||||||||||||||||||||
|
|
@@ -98,7 +103,7 @@ async function standardFetch( | |||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Fetch with automatic retry and model switching | ||||||||||||||||||||||
| * Fetch with automatic retry and model switching (including cross-provider) | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| async function fetchWithRetry( | ||||||||||||||||||||||
| input: Parameters<typeof fetch>[0], | ||||||||||||||||||||||
|
|
@@ -127,12 +132,22 @@ async function fetchWithRetry( | |||||||||||||||||||||
| let currentInput = input; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Extract original model and provider from request | ||||||||||||||||||||||
| const { originalModel, provider } = extractModelInfo(input, init); | ||||||||||||||||||||||
| const { originalModel, provider: originalProvider } = extractModelInfo(input, init); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (originalModel) { | ||||||||||||||||||||||
| attemptedModels.push(originalModel); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Parse the original request body for potential cross-provider transformations | ||||||||||||||||||||||
| let originalRequestBody: any = null; | ||||||||||||||||||||||
| if (init?.body) { | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| originalRequestBody = JSON.parse(init.body as string); | ||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||
| // Not JSON | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| while (retryCount <= (modelRouter?.getMaxRetries() || 0)) { | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| // Make the request | ||||||||||||||||||||||
|
|
@@ -157,12 +172,40 @@ async function fetchWithRetry( | |||||||||||||||||||||
| throw new Error(JSON.stringify(errorObj)); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // If this was a cross-provider retry, transform the response back | ||||||||||||||||||||||
| // to the original provider's format for transparency | ||||||||||||||||||||||
| if (originalProvider && retryCount > 0) { | ||||||||||||||||||||||
| const currentModel = extractCurrentModel(currentInput, currentInit); | ||||||||||||||||||||||
| const currentProvider = currentModel ? detectProvider(currentModel) : null; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (currentProvider && currentProvider !== originalProvider) { | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const clonedForTransform = response.clone(); | ||||||||||||||||||||||
| const responseData = await clonedForTransform.json(); | ||||||||||||||||||||||
| const transformed = transformResponse( | ||||||||||||||||||||||
| responseData, | ||||||||||||||||||||||
| currentProvider, // source: the provider that actually responded | ||||||||||||||||||||||
| originalProvider, // target: what the caller expects | ||||||||||||||||||||||
| currentModel || '' | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| return new Response(JSON.stringify(transformed), { | ||||||||||||||||||||||
| status: response.status, | ||||||||||||||||||||||
| statusText: response.statusText, | ||||||||||||||||||||||
| headers: { 'Content-Type': 'application/json' }, | ||||||||||||||||||||||
|
Comment on lines
+191
to
+194
|
||||||||||||||||||||||
| return new Response(JSON.stringify(transformed), { | |
| status: response.status, | |
| statusText: response.statusText, | |
| headers: { 'Content-Type': 'application/json' }, | |
| const transformedHeaders = new Headers(response.headers); | |
| transformedHeaders.set('Content-Type', 'application/json'); | |
| return new Response(JSON.stringify(transformed), { | |
| status: response.status, | |
| statusText: response.statusText, | |
| headers: transformedHeaders, |
Copilot
AI
Apr 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
buildCrossProviderRequest always constructs a fresh RequestInit with method='POST' and only {headers, body}. This drops important init options from the original request (e.g., signal/AbortController, credentials, mode, redirect, keepalive) and can change behavior on retries. Carry over the relevant fields from the original init/request and preserve the original HTTP method where applicable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
registerApiKeys accepts any object, but arrays also satisfy typeof === 'object'. Passing an array would lead to numeric provider names being registered (via Object.entries). Reject arrays explicitly (e.g., Array.isArray(keys)) to enforce the intended mapping shape.