singularity-forge/packages/pi-coding-agent/src/core/fallback-resolver.ts
Flux Labs adca6901ec feat: add cross-provider fallback when rate/quota limits are hit (#125)
When all credentials for a provider are exhausted, the system now
automatically falls back to the next available provider in a
user-configured fallback chain. Higher-priority providers are
restored automatically when their backoff expires.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 15:45:44 -05:00

165 lines
5 KiB
TypeScript

// GSD Provider Fallback Resolver
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
/**
* FallbackResolver - Cross-provider fallback when rate/quota limits are hit.
*
* When a provider's credentials are all exhausted, this resolver finds the next
* available provider+model from a user-configured fallback chain. It also handles
* restoration: checking if a higher-priority provider has recovered before each request.
*/
import type { Api, Model } from "@gsd/pi-ai";
import type { AuthStorage, UsageLimitErrorType } from "./auth-storage.js";
import type { ModelRegistry } from "./model-registry.js";
import type { FallbackChainEntry, SettingsManager } from "./settings-manager.js";
export interface FallbackResult {
model: Model<Api>;
chainName: string;
reason: string;
}
export class FallbackResolver {
constructor(
private settingsManager: SettingsManager,
private authStorage: AuthStorage,
private modelRegistry: ModelRegistry,
) {}
/**
* Find the next available fallback for a model that just failed.
* Searches all chains for entries matching the current model's provider+id,
* then returns the next available entry with lower priority (higher number).
*
* @returns FallbackResult if a fallback is available, null otherwise
*/
async findFallback(
currentModel: Model<Api>,
errorType: UsageLimitErrorType,
): Promise<FallbackResult | null> {
const { enabled, chains } = this.settingsManager.getFallbackSettings();
if (!enabled) return null;
// Mark the current provider as exhausted at the provider level
this.authStorage.markProviderExhausted(currentModel.provider, errorType);
// Search all chains for one containing the current model
for (const [chainName, entries] of Object.entries(chains)) {
const currentIndex = entries.findIndex(
(e) => e.provider === currentModel.provider && e.model === currentModel.id,
);
if (currentIndex === -1) continue;
// Try entries after the current one (already sorted by priority)
const result = await this._findAvailableInChain(chainName, entries, currentIndex + 1);
if (result) return result;
// Wrap around: try entries before the current one
const wrapResult = await this._findAvailableInChain(chainName, entries, 0, currentIndex);
if (wrapResult) return wrapResult;
}
return null;
}
/**
* Check if a higher-priority provider in the chain has recovered.
* Called before each LLM request to restore the best available provider.
*
* @returns FallbackResult if a better provider is available, null if current is best
*/
async checkForRestoration(currentModel: Model<Api>): Promise<FallbackResult | null> {
const { enabled, chains } = this.settingsManager.getFallbackSettings();
if (!enabled) return null;
for (const [chainName, entries] of Object.entries(chains)) {
const currentIndex = entries.findIndex(
(e) => e.provider === currentModel.provider && e.model === currentModel.id,
);
if (currentIndex === -1) continue;
// Only check entries with higher priority (lower index = higher priority)
if (currentIndex === 0) continue; // Already at highest priority
const result = await this._findAvailableInChain(chainName, entries, 0, currentIndex);
if (result) {
return {
...result,
reason: `${result.model.provider}/${result.model.id} recovered, restoring from fallback`,
};
}
}
return null;
}
/**
* Get the best available model from a named chain.
* Useful for initial model selection.
*/
async getBestAvailable(chainName: string): Promise<FallbackResult | null> {
const { enabled, chains } = this.settingsManager.getFallbackSettings();
if (!enabled) return null;
const entries = chains[chainName];
if (!entries || entries.length === 0) return null;
return this._findAvailableInChain(chainName, entries, 0);
}
/**
* Find the chain(s) a model belongs to.
*/
findChainsForModel(provider: string, modelId: string): string[] {
const { chains } = this.settingsManager.getFallbackSettings();
const result: string[] = [];
for (const [chainName, entries] of Object.entries(chains)) {
if (entries.some((e) => e.provider === provider && e.model === modelId)) {
result.push(chainName);
}
}
return result;
}
/**
* Search a chain for the first available entry starting from startIndex.
*/
private async _findAvailableInChain(
chainName: string,
entries: FallbackChainEntry[],
startIndex: number,
endIndex?: number,
): Promise<FallbackResult | null> {
const end = endIndex ?? entries.length;
for (let i = startIndex; i < end; i++) {
const entry = entries[i];
// Check provider-level backoff
if (!this.authStorage.isProviderAvailable(entry.provider)) {
continue;
}
// Check if model exists in registry
const model = this.modelRegistry.find(entry.provider, entry.model);
if (!model) continue;
// Check if API key is available
const hasAuth = this.authStorage.hasAuth(entry.provider);
if (!hasAuth) continue;
return {
model,
chainName,
reason: `falling back to ${entry.provider}/${entry.model}`,
};
}
return null;
}
}