Skip to content

feat: add cross-provider fallback routing with request/response #2

Open
anikethgojedev wants to merge 1 commit intomainfrom
users/aniketh/feature-add
Open

feat: add cross-provider fallback routing with request/response #2
anikethgojedev wants to merge 1 commit intomainfrom
users/aniketh/feature-add

Conversation

@anikethgojedev
Copy link
Copy Markdown
Collaborator

Make sure to check before you merge.

@Ruthwik000 Ruthwik000 requested a review from Copilot April 7, 2026 09:37
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +61 to +62
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}key=${apiKey}`;
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}key=${apiKey}`;
const parsedUrl = new URL(url);
parsedUrl.searchParams.set('key', apiKey);
return parsedUrl.toString();

Copilot uses AI. Check for mistakes.
Comment on lines +191 to +194
return new Response(JSON.stringify(transformed), {
status: response.status,
statusText: response.statusText,
headers: { 'Content-Type': 'application/json' },
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 uses AI. Check for mistakes.
Comment on lines +336 to +343
return {
url: targetUrl,
init: {
method: 'POST',
headers,
body: JSON.stringify(transformedBody),
},
};
Copy link

Copilot AI Apr 7, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +101
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,
},
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +38
case 'gemini':
const candidate = response.candidates?.[0];
return {
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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': { ... }).

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +67
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,
};
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/index.ts
* @param keys - Object mapping provider names to API keys
*/
export function registerApiKeys(keys: ApiKeyConfig): void {
if (!keys || typeof keys !== 'object') {
Copy link

Copilot AI Apr 7, 2026

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.

Suggested change
if (!keys || typeof keys !== 'object') {
if (!keys || typeof keys !== 'object' || Array.isArray(keys)) {

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants