2026-04-15 14:54:20 +02:00
|
|
|
// SF Provider Fallback Resolver
|
2026-03-14 15:45:44 -05:00
|
|
|
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-07 18:17:41 +02:00
|
|
|
* FallbackResolver - Fresh model reselection when rate/quota limits are hit.
|
2026-03-14 15:45:44 -05:00
|
|
|
*
|
2026-05-07 18:17:41 +02:00
|
|
|
* When a provider/model becomes unhealthy, this resolver picks a fresh model from
|
|
|
|
|
* the current available registry rather than walking a preconfigured fallback chain.
|
2026-03-14 15:45:44 -05:00
|
|
|
*/
|
|
|
|
|
|
2026-04-15 22:56:33 +02:00
|
|
|
import type { Api, Model } from "@singularity-forge/pi-ai";
|
2026-03-14 15:45:44 -05:00
|
|
|
import type { AuthStorage, UsageLimitErrorType } from "./auth-storage.js";
|
|
|
|
|
import type { ModelRegistry } from "./model-registry.js";
|
2026-05-05 14:31:16 +02:00
|
|
|
import type {
|
|
|
|
|
FallbackChainEntry,
|
|
|
|
|
SettingsManager,
|
|
|
|
|
} from "./settings-manager.js";
|
2026-03-14 15:45:44 -05:00
|
|
|
|
|
|
|
|
export interface FallbackResult {
|
|
|
|
|
model: Model<Api>;
|
|
|
|
|
chainName: string;
|
|
|
|
|
reason: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class FallbackResolver {
|
|
|
|
|
constructor(
|
|
|
|
|
private settingsManager: SettingsManager,
|
|
|
|
|
private authStorage: AuthStorage,
|
|
|
|
|
private modelRegistry: ModelRegistry,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-07 18:17:41 +02:00
|
|
|
* Find a fresh replacement for a model that just failed.
|
|
|
|
|
* Ignores fallback chains and reselects from the current available registry.
|
2026-03-14 15:45:44 -05:00
|
|
|
*
|
2026-05-07 18:17:41 +02:00
|
|
|
* @returns FallbackResult if a replacement is available, null otherwise
|
2026-03-14 15:45:44 -05:00
|
|
|
*/
|
|
|
|
|
async findFallback(
|
|
|
|
|
currentModel: Model<Api>,
|
|
|
|
|
errorType: UsageLimitErrorType,
|
|
|
|
|
): Promise<FallbackResult | null> {
|
2026-05-07 18:17:41 +02:00
|
|
|
const { enabled } = this.settingsManager.getFallbackSettings();
|
2026-03-14 15:45:44 -05:00
|
|
|
if (!enabled) return null;
|
|
|
|
|
|
2026-05-04 14:46:50 +02:00
|
|
|
// Mark the current provider as exhausted at the provider level.
|
|
|
|
|
// Skip for quota_exhausted — quotas are typically per-model (e.g.
|
|
|
|
|
// google-gemini-cli's Code Assist per-model limits), so other models
|
|
|
|
|
// from the same provider may still be available.
|
|
|
|
|
if (errorType !== "quota_exhausted") {
|
|
|
|
|
this.authStorage.markProviderExhausted(currentModel.provider, errorType);
|
|
|
|
|
}
|
2026-03-14 15:45:44 -05:00
|
|
|
|
2026-05-04 09:47:30 +02:00
|
|
|
return this._findAnyAvailableFallback(currentModel);
|
2026-03-14 15:45:44 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-07 18:17:41 +02:00
|
|
|
* Automatic restoration is disabled when replacement is always reselected
|
|
|
|
|
* from scratch instead of following a chain.
|
2026-03-14 15:45:44 -05:00
|
|
|
*/
|
2026-05-05 14:31:16 +02:00
|
|
|
async checkForRestoration(
|
2026-05-07 18:17:41 +02:00
|
|
|
_currentModel: Model<Api>,
|
2026-05-05 14:31:16 +02:00
|
|
|
): Promise<FallbackResult | null> {
|
2026-03-14 15:45:44 -05:00
|
|
|
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;
|
|
|
|
|
|
feat(core): support for 'non-api-key' provider extensions like Claude Code CLI (#2382)
* feat(core): add generic native post-install hooks for package install
* feat(core): add before/after install/remove lifecycle hooks
* refactor(core): remove postInstall alias from lifecycle hook fallback
* feat(core): complete authMode support for keyless providers
The initial authMode implementation fixed model-registry, sdk, and
fallback-resolver but missed agent-session.ts (6 callsites) and
compaction-orchestrator.ts (2 callsites) that block externalCli
providers at runtime.
Architecture: separate readiness gating from credential retrieval.
- isProviderRequestReady(): authMode-aware readiness check
- getApiKey()/getApiKeyForProvider(): return undefined for
externalCli/none providers instead of triggering auth errors
- All 8 callsites in agent-session and compaction-orchestrator
now gate on readiness, not key presence
- Downstream signatures (compaction, branch-summarization) accept
apiKey: string | undefined
- Replaced hardcoded ollama exception in discoverModels with
isProviderRequestReady
Zero behavioral change for classic apiKey/oauth providers.
* feat(core): add isReady callback for provider readiness verification
Extensions can now provide an isReady() callback when registering any
provider. isProviderRequestReady() calls it before default auth checks,
allowing providers to verify actual reachability (CLI authenticated,
API key valid, service online) rather than relying solely on credential
presence.
* test(core): expand authMode test coverage
Cover all four auth modes (apiKey, oauth, externalCli, none),
isReady callback behavior, getProviderAuthMode defaults,
isProviderRequestReady for each mode, getAvailable filtering,
and getApiKey early-return for keyless providers.
* chore: remove provider-api-bridge files from this branch
These files implement GSD core → provider-api wiring (deps + tool
registry) and belong in a separate PR. Reverts register-extension.ts
to upstream state.
2026-03-24 21:50:12 +00:00
|
|
|
// Check if provider is request-ready for fallback (authMode-aware)
|
|
|
|
|
if (!this.modelRegistry.isProviderRequestReady(entry.provider)) continue;
|
2026-03-14 15:45:44 -05:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
model,
|
|
|
|
|
chainName,
|
|
|
|
|
reason: `falling back to ${entry.provider}/${entry.model}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-05-04 09:47:30 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Free-selection fallback when no chain contains the current model.
|
|
|
|
|
* Picks any available model from the registry with a different provider.
|
|
|
|
|
* Prefers models with reasoning capability if the current model has it.
|
|
|
|
|
*/
|
|
|
|
|
private _findAnyAvailableFallback(
|
|
|
|
|
currentModel: Model<Api>,
|
|
|
|
|
): FallbackResult | null {
|
|
|
|
|
const allModels = this.modelRegistry.getAvailable();
|
|
|
|
|
const candidates = allModels.filter((m) => {
|
|
|
|
|
// Exclude same provider — credential rotation was already tried
|
|
|
|
|
if (m.provider === currentModel.provider) return false;
|
|
|
|
|
// Exclude exhausted providers
|
|
|
|
|
if (!this.authStorage.isProviderAvailable(m.provider)) return false;
|
|
|
|
|
// Exclude models without auth
|
|
|
|
|
if (!this.modelRegistry.isProviderRequestReady(m.provider)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (candidates.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
// Sort: prefer models with matching reasoning capability, then by context window
|
|
|
|
|
candidates.sort((a, b) => {
|
|
|
|
|
const aReasoningMatch = a.reasoning === currentModel.reasoning ? 1 : 0;
|
|
|
|
|
const bReasoningMatch = b.reasoning === currentModel.reasoning ? 1 : 0;
|
|
|
|
|
if (aReasoningMatch !== bReasoningMatch) {
|
|
|
|
|
return bReasoningMatch - aReasoningMatch;
|
|
|
|
|
}
|
|
|
|
|
return (b.contextWindow ?? 0) - (a.contextWindow ?? 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const chosen = candidates[0];
|
|
|
|
|
return {
|
|
|
|
|
model: chosen,
|
2026-05-07 18:17:41 +02:00
|
|
|
chainName: "fresh-selection",
|
|
|
|
|
reason: `reselected ${chosen.provider}/${chosen.id} from available models`,
|
2026-05-04 09:47:30 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-03-14 15:45:44 -05:00
|
|
|
}
|