feat: add cross-provider fallback routing with request/response #2
feat: add cross-provider fallback routing with request/response #2anikethgojedev wants to merge 1 commit intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds cross-provider fallback routing so the fetch interceptor can retry failed LLM calls against a different provider, including request/response shape conversion and per-provider auth handling.
Changes:
- Introduces cross-provider routing primitives: provider detection, endpoint resolution, provider-specific headers, and API key registration/lookup.
- Adds request/response transformers to translate between OpenAI/Anthropic/Gemini-compatible shapes during fallback.
- Extends fetch retry logic and public API surface (
registerApiKeys,isCrossProviderEnabled) to enable and configure cross-provider fallback.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/router/types.ts | Adds ApiKeyConfig and new router options for cross-provider fallback. |
| src/router/apiKeyManager.ts | Implements an in-memory API key registry for providers. |
| src/router/providerDetector.ts | Detects provider from model prefix and builds provider-specific endpoints/URLs. |
| src/router/providerHeaders.ts | Builds auth headers and appends Gemini API keys to URLs. |
| src/router/requestTransformer.ts | Converts request bodies between provider formats for fallback requests. |
| src/router/responseTransformer.ts | Converts fallback provider responses back to the caller’s expected format. |
| src/router/modelRouter.ts | Wires cross-provider enablement + optional key registration into the router. |
| src/interceptors/fetchInterceptor.ts | Implements cross-provider retry path (new URL/headers/body + response re-shaping). |
| src/index.ts | Exposes registerApiKeys + isCrossProviderEnabled, re-exports ApiKeyConfig. |
| examples/8-cross-provider-fallback.js | Adds an example showing how to configure and use cross-provider fallback. |
| package-lock.json | Updates lockfile metadata/version and adds engines info. |
Comments suppressed due to low confidence (1)
src/interceptors/fetchInterceptor.ts:476
- In updateRequestModel (Gemini branch), the new Request no longer copies the original request's cache setting into the RequestInit. This changes caching semantics compared to the original request. Include cache (and keep this constructor's copied fields consistent with the original request).
newInput = new Request(newUrl, {
method: input.method,
headers: input.headers,
body: init?.body || null,
mode: input.mode,
credentials: input.credentials,
redirect: input.redirect,
referrer: input.referrer,
integrity: input.integrity
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const separator = url.includes('?') ? '&' : '?'; | ||
| return `${url}${separator}key=${apiKey}`; |
There was a problem hiding this comment.
appendApiKeyToUrl concatenates the API key directly into the URL without URL-encoding it (and without using URLSearchParams). If the key contains reserved characters, this can produce an invalid URL or incorrect request. Build the URL via the URL API / URLSearchParams and encode the key value.
| const separator = url.includes('?') ? '&' : '?'; | |
| return `${url}${separator}key=${apiKey}`; | |
| const parsedUrl = new URL(url); | |
| parsedUrl.searchParams.set('key', apiKey); | |
| return parsedUrl.toString(); |
| return new Response(JSON.stringify(transformed), { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers: { 'Content-Type': 'application/json' }, |
There was a problem hiding this comment.
When returning a transformed cross-provider response, the new Response is created with only a "Content-Type" header. This drops all original response headers (rate-limit headers, request IDs, etc.), which can break callers that rely on them. Preserve the original response headers and only override/ensure the content-type as needed.
| 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, |
| return { | ||
| url: targetUrl, | ||
| init: { | ||
| method: 'POST', | ||
| headers, | ||
| body: JSON.stringify(transformedBody), | ||
| }, | ||
| }; |
There was a problem hiding this comment.
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.
| case 'anthropic': | ||
| return { | ||
| id: common.id, | ||
| type: 'message', | ||
| role: 'assistant', | ||
| content: [{ | ||
| type: 'text', | ||
| text: common.choices?.[0]?.message?.content || '', | ||
| }], | ||
| model: common.model, | ||
| stop_reason: common.choices?.[0]?.finish_reason === 'stop' ? 'end_turn' : common.choices?.[0]?.finish_reason, | ||
| usage: { | ||
| input_tokens: common.usage?.prompt_tokens || 0, | ||
| output_tokens: common.usage?.completion_tokens || 0, | ||
| }, | ||
| }; | ||
|
|
||
| case 'gemini': | ||
| return { | ||
| candidates: [{ | ||
| content: { | ||
| parts: [{ | ||
| text: common.choices?.[0]?.message?.content || '', | ||
| }], | ||
| role: 'model', | ||
| }, | ||
| finishReason: 'STOP', | ||
| }], | ||
| usageMetadata: { | ||
| promptTokenCount: common.usage?.prompt_tokens || 0, | ||
| candidatesTokenCount: common.usage?.completion_tokens || 0, | ||
| totalTokenCount: common.usage?.total_tokens || 0, | ||
| }, |
There was a problem hiding this comment.
When converting OpenAI-shaped responses to provider-specific formats, finish/stop reasons are not mapped correctly: Gemini is hard-coded to finishReason: 'STOP', and Anthropic stop_reason passes through values like 'length' that Anthropic doesn't use. Map OpenAI finish_reason values (e.g., 'stop'/'length'/'content_filter') to the closest valid target-provider enums to avoid misreporting termination reasons.
| case 'gemini': | ||
| const candidate = response.candidates?.[0]; | ||
| return { |
There was a problem hiding this comment.
This switch case declares a block-scoped variable (const candidate) without wrapping the case body in braces. This is inconsistent with the other transformer switches and is prone to 'no-case-declarations' / future refactor issues. Wrap the gemini case in a block (case 'gemini': { ... }).
| case 'gemini': { | ||
| const messages: Message[] = []; | ||
| if (Array.isArray(request.contents)) { | ||
| for (const content of request.contents) { | ||
| const role = content.role === 'model' ? 'assistant' : 'user'; | ||
| const text = Array.isArray(content.parts) | ||
| ? content.parts.map((p: any) => p.text || '').join('') | ||
| : ''; | ||
| messages.push({ role, content: text }); | ||
| } | ||
| } | ||
| return { | ||
| messages, | ||
| model: '', | ||
| temperature: request.generationConfig?.temperature, | ||
| max_tokens: request.generationConfig?.maxOutputTokens, | ||
| top_p: request.generationConfig?.topP, | ||
| }; |
There was a problem hiding this comment.
Gemini requests can include a systemInstruction (system prompt) field; normalizeToCommon currently ignores it, so cross-provider fallbacks starting from Gemini can silently lose the system instruction. Convert systemInstruction into a leading system message in the common format (and handle both string and structured forms).
| * @param keys - Object mapping provider names to API keys | ||
| */ | ||
| export function registerApiKeys(keys: ApiKeyConfig): void { | ||
| if (!keys || typeof keys !== 'object') { |
There was a problem hiding this comment.
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.
| if (!keys || typeof keys !== 'object') { | |
| if (!keys || typeof keys !== 'object' || Array.isArray(keys)) { |
Make sure to check before you merge.