- check-sf-extension-inventory.mjs: expand parseDirectRegisteredCommands()
scan to include 7 more files (guards/inturn.js, notifications/notify.js,
permissions/index.js, ui/usage-bar.js, commands/legacy/audit.js,
commands/legacy/create-extension.js, commands/legacy/create-slash-command.js)
and filter results by BASE_RUNTIME_COMMAND_NAMES to exclude doc-string false
positives ("name" in create-slash-command.js template text)
- extension-manifest.json: remove 'clear' (subcommand of logs/notifications,
never a top-level pi.registerCommand)
- packages/pi-agent-core/src/db/sf-db.ts: fix 23 noVoidTypeReturn errors
- openDatabase: void → boolean (caller uses return value at line 5625)
- claimEscalationOverride: void → boolean (caller checks at escalation.js:243)
- resolveSelfFeedbackEntry: void → boolean (caller checks at self-feedback.js:387)
- copyWorktreeDb: void → boolean (caller checks at reconcileWorktreeDb)
- compactUokMessages: void → {before,after} (caller returns value at message-bus.js:238)
- insertSessionTurn: void → bigint|null (caller uses id at session-recorder.js:104)
- expireStaleMemories: void → number (caller uses count at auto-start.js:1047)
- deleteMemorySourceRow: void → boolean (caller returns value at memory-source-store.js:107)
- deleteMemoryEmbedding: void → boolean (caller returns value at memory-embeddings.js:328)
- updateBacklogItemStatus: remove dead return expression (callers discard value)
- removeBacklogItem: remove dead return expression (callers discard value)
- updateGateCircuitBreaker: remove dead return {total,avgMs,...} (wrong-type
code accidentally merged from getGateLatencyStats, never reachable)
- markUokMessageRead: remove dead return true/false (callers discard value)
- Auto-fix formatting and organizeImports in ~30 source files (biome --write)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
244 lines
7.8 KiB
TypeScript
244 lines
7.8 KiB
TypeScript
// SF Provider Fallback Resolver
|
|
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
|
|
|
/**
|
|
* FallbackResolver - Fresh model reselection when rate/quota limits are hit.
|
|
*
|
|
* When a provider/model becomes unhealthy, this resolver picks a fresh model from
|
|
* the current available registry rather than walking a preconfigured fallback chain.
|
|
*/
|
|
|
|
import type { Api, Model } from "@singularity-forge/ai";
|
|
import type { AuthStorage, UsageLimitErrorType } from "./auth-storage.js";
|
|
import type {
|
|
BeforeModelSelectEvent,
|
|
BeforeModelSelectResult,
|
|
} from "./extensions/types.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;
|
|
}
|
|
|
|
type EmitBeforeModelSelect = (
|
|
event: Omit<BeforeModelSelectEvent, "type">,
|
|
) => Promise<BeforeModelSelectResult | undefined>;
|
|
|
|
export class FallbackResolver {
|
|
/** Current unit context set by autonomous dispatch before each unit runs. */
|
|
private _unitContext: { unitType: string; unitId: string } | null = null;
|
|
|
|
constructor(
|
|
private settingsManager: SettingsManager,
|
|
private authStorage: AuthStorage,
|
|
private modelRegistry: ModelRegistry,
|
|
/** Optional hook emitter — when provided, fires before_model_select so the
|
|
* learning system can influence which replacement model is chosen. */
|
|
private emitBeforeModelSelect?: EmitBeforeModelSelect,
|
|
) {}
|
|
|
|
/**
|
|
* Set the active unit context so fallback outcome records use the correct
|
|
* unit_type/unit_id rather than a generic sentinel.
|
|
*
|
|
* Purpose: autonomous dispatch calls this before running each unit so that
|
|
* any mid-unit model failure is attributed to the right unit type (execute-task,
|
|
* complete-slice, plan-milestone, …) for accurate Bayesian weighting.
|
|
*
|
|
* Consumer: agent-session.ts setFallbackUnitContext, called from SF extension
|
|
* autonomous loop via pi.setFallbackUnitContext().
|
|
*/
|
|
setUnitContext(ctx: { unitType: string; unitId: string } | null): void {
|
|
this._unitContext = ctx;
|
|
}
|
|
|
|
/**
|
|
* Find a fresh replacement for a model that just failed.
|
|
* Ignores fallback chains and reselects from the current available registry.
|
|
*
|
|
* @returns FallbackResult if a replacement is available, null otherwise
|
|
*/
|
|
async findFallback(
|
|
currentModel: Model<Api>,
|
|
errorType: UsageLimitErrorType,
|
|
): Promise<FallbackResult | null> {
|
|
const { enabled } = this.settingsManager.getFallbackSettings();
|
|
if (!enabled) return null;
|
|
|
|
// 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);
|
|
}
|
|
|
|
return this._findAnyAvailableFallback(currentModel, errorType);
|
|
}
|
|
|
|
/**
|
|
* Automatic restoration is disabled when replacement is always reselected
|
|
* from scratch instead of following a chain.
|
|
*/
|
|
async checkForRestoration(
|
|
_currentModel: Model<Api>,
|
|
): Promise<FallbackResult | null> {
|
|
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 provider is request-ready for fallback (authMode-aware)
|
|
if (!this.modelRegistry.isProviderRequestReady(entry.provider)) continue;
|
|
|
|
return {
|
|
model,
|
|
chainName,
|
|
reason: `falling back to ${entry.provider}/${entry.model}`,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Free-selection fallback when no chain contains the current model.
|
|
* Fires before_model_select hook so the learning system can rank candidates
|
|
* by outcome history and Bayesian benchmarks. Falls back to heuristic sort
|
|
* (reasoning match + context window) if the hook is unavailable or returns nothing.
|
|
*/
|
|
private async _findAnyAvailableFallback(
|
|
currentModel: Model<Api>,
|
|
errorType?: UsageLimitErrorType,
|
|
): Promise<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;
|
|
|
|
// Fire before_model_select so the learning system can:
|
|
// 1. Record the current model as failed (reason="fallback")
|
|
// 2. Return the best outcome-weighted replacement
|
|
if (this.emitBeforeModelSelect) {
|
|
try {
|
|
const unitType = this._unitContext?.unitType ?? "execute-task";
|
|
const unitId =
|
|
this._unitContext?.unitId ??
|
|
`fallback:${currentModel.provider}/${currentModel.id}`;
|
|
const result = await this.emitBeforeModelSelect({
|
|
unitType,
|
|
unitId,
|
|
classification: {
|
|
tier: "standard",
|
|
reason: errorType ?? "unknown",
|
|
downgraded: false,
|
|
},
|
|
eligibleModels: candidates.map((m) => `${m.provider}/${m.id}`),
|
|
currentModelId: `${currentModel.provider}/${currentModel.id}`,
|
|
reason: "fallback",
|
|
});
|
|
if (result?.modelId) {
|
|
const slashIdx = result.modelId.indexOf("/");
|
|
if (slashIdx > 0) {
|
|
const provider = result.modelId.slice(0, slashIdx);
|
|
const modelId = result.modelId.slice(slashIdx + 1);
|
|
const preferred = candidates.find(
|
|
(m) => m.provider === provider && m.id === modelId,
|
|
);
|
|
if (preferred) {
|
|
return {
|
|
model: preferred,
|
|
chainName: "learned-fallback",
|
|
reason: `learned routing selected ${result.modelId} (outcome-weighted)`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Hook failure → fall through to heuristic sort
|
|
}
|
|
}
|
|
|
|
// Heuristic: prefer reasoning capability match, then larger 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,
|
|
chainName: "fresh-selection",
|
|
reason: `reselected ${chosen.provider}/${chosen.id} from available models`,
|
|
};
|
|
}
|
|
}
|