Files
2026-07-13 13:39:12 +08:00

163 lines
4.8 KiB
TypeScript

/**
* Emergency Fallback — Budget Exhaustion Redirect
*
* When a request fails due to budget exhaustion (HTTP 402 or budget keywords
* in the error body), optionally redirect to a free-tier model
* (default provider/model: nvidia + openai/gpt-oss-120b at $0.00/M tokens).
*
* Inspired by ClawRouter: "gpt-oss-120b costs nothing and serves as
* automatic fallback when wallet is empty."
*
* Operators can disable the redirect entirely with
* `OMNIROUTE_EMERGENCY_FALLBACK=false` (or `0`). Default remains enabled.
*/
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
const EMERGENCY_FALLBACK_FLAG_KEY = "OMNIROUTE_EMERGENCY_FALLBACK";
const EMERGENCY_FALLBACK_FLAG_CACHE_MS = 500;
type FeatureFlagResolver = (key: string) => boolean;
let emergencyFallbackFlagCache: { value: boolean; expiresAt: number } | null = null;
let emergencyFallbackFeatureFlagResolver: FeatureFlagResolver = isFeatureFlagEnabled;
export interface EmergencyFallbackConfig {
enabled: boolean;
provider: string;
model: string;
triggerOn402: boolean;
triggerOnBudgetKeywords: boolean;
budgetKeywords: string[];
/** Skip fallback for tool requests (gpt-oss-120b may not support structured tool calling) */
skipForToolRequests: boolean;
maxOutputTokens: number;
}
export const EMERGENCY_FALLBACK_CONFIG: EmergencyFallbackConfig = {
enabled: true,
provider: "nvidia",
model: "openai/gpt-oss-120b",
triggerOn402: true,
triggerOnBudgetKeywords: true,
budgetKeywords: [
"insufficient funds",
"insufficient_funds",
"budget exceeded",
"budget_exceeded",
"quota exceeded",
"quota_exceeded",
"billing",
"payment required",
"out of credits",
"no credits",
"credit limit",
"spending limit",
"saldo insuficiente",
"limite de gastos",
"cota excedida",
],
skipForToolRequests: true,
maxOutputTokens: 4096,
};
export interface FallbackDecision {
shouldFallback: true;
reason: string;
provider: string;
model: string;
maxOutputTokens: number;
}
export interface NoFallbackDecision {
shouldFallback: false;
reason: string;
}
export type FallbackResult = FallbackDecision | NoFallbackDecision;
function isEmergencyFallbackRawEnvEnabled(): boolean {
const raw = process.env.OMNIROUTE_EMERGENCY_FALLBACK;
return raw !== "false" && raw !== "0";
}
export function resetEmergencyFallbackEnvCache(): void {
emergencyFallbackFlagCache = null;
}
export function setEmergencyFallbackFeatureFlagResolverForTest(
resolver: FeatureFlagResolver | null
): void {
emergencyFallbackFeatureFlagResolver = resolver ?? isFeatureFlagEnabled;
resetEmergencyFallbackEnvCache();
}
export function isEmergencyFallbackEnvEnabled(): boolean {
const now = Date.now();
if (emergencyFallbackFlagCache && emergencyFallbackFlagCache.expiresAt > now) {
return emergencyFallbackFlagCache.value;
}
let value: boolean;
try {
value = emergencyFallbackFeatureFlagResolver(EMERGENCY_FALLBACK_FLAG_KEY);
} catch (error) {
console.warn(
"[emergencyFallback] Feature flag resolution failed; falling back to raw env:",
error instanceof Error ? error.message : error
);
value = isEmergencyFallbackRawEnvEnabled();
}
emergencyFallbackFlagCache = {
value,
expiresAt: now + EMERGENCY_FALLBACK_FLAG_CACHE_MS,
};
return value;
}
export function shouldUseFallback(
status: number,
errorBody: string,
requestHasTools: boolean,
config: EmergencyFallbackConfig = EMERGENCY_FALLBACK_CONFIG
): FallbackResult {
if (!config.enabled) return { shouldFallback: false, reason: "emergency fallback disabled" };
if (!isEmergencyFallbackEnvEnabled()) {
return {
shouldFallback: false,
reason: "emergency fallback disabled via OMNIROUTE_EMERGENCY_FALLBACK",
};
}
if (config.skipForToolRequests && requestHasTools) {
return { shouldFallback: false, reason: "skipped: request has tools" };
}
if (config.triggerOn402 && status === 402) {
return {
shouldFallback: true,
reason: `HTTP 402 → emergency fallback to ${config.provider}/${config.model}`,
provider: config.provider,
model: config.model,
maxOutputTokens: config.maxOutputTokens,
};
}
if (config.triggerOnBudgetKeywords && errorBody) {
const lowerBody = errorBody.toLowerCase();
const matched = config.budgetKeywords.find((kw) => lowerBody.includes(kw.toLowerCase()));
if (matched) {
return {
shouldFallback: true,
reason: `Budget error detected ('${matched}') → emergency fallback to ${config.provider}/${config.model}`,
provider: config.provider,
model: config.model,
maxOutputTokens: config.maxOutputTokens,
};
}
}
return { shouldFallback: false, reason: "no budget error detected" };
}
export function isFallbackDecision(result: FallbackResult): result is FallbackDecision {
return result.shouldFallback === true;
}