mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
* feat(mitm): dynamically inject configured models into Antigravity model catalog - Add /v1internal:fetchAvailableModels to ANTIGRAVITY_TARGET.endpointPatterns in src/mitm/targets/antigravity.ts - Implement catalog interception and dynamic model merging in AntigravityHandler.intercept() (src/mitm/handlers/antigravity.ts) - Merge operator's configured combos/models dynamically from the repository into Google Cloud Code's upstream catalog - Prepend injected models to agentModelSorts recommended group while preserving native models and upstream structure - Add unit tests covering target endpoint pattern declaration, catalog merging, dynamic combo retrieval, and error propagation in tests/unit/mitm-handler-antigravity.test.ts Resolves #13959 * test(mitm): isolate DATA_DIR and clean up the combo row in the antigravity catalog test The DB-backed test ("dynamic catalog pulls configured combos from database repository") creates a real combo row via src/lib/db/combos.ts, whose module- level DATA_DIR const resolves once at import time. The PR's own documented Validation command (`node --import tsx/esm tests/unit/mitm-handler-antigravity.test.ts`) runs without the `--test` flag, so the existing #10428 eval-probe/test-context guard in resolveWritableDataDir() never triggers and DATA_DIR falls through to the real ~/.omniroute home database — writing a permanent test-combo row into it every run. Set DATA_DIR to an isolated temp dir at the top of the file (before the combos.ts import), reset the DB singleton and clean up the temp dir in test.after(), and wrap the combo creation in try/finally so the created row is deleted even on assertion failure. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(mitm): prevent model name collision and filter inactive combos in antigravity catalog * feat(antigravity): integrate native auto groups, fallback to groq, and add bridge proxy - Inject OmniRoute native auto groups (auto/best-fast, auto/best-coding, auto/best-reasoning, auto/best-free, etc.) into Antigravity IDE & CLI /model selector - Add bin/antigravity-bridge.mjs with selective proxy routing to isolate native Gemini quota (zero Google token leakage) - Implement transparent self-healing model remapping to prevent upstream 410 model_shutdown errors on deprecated models - Update emergencyFallback provider from nvidia to groq/openai/gpt-oss-120b for resilient 0.02s failover * test(antigravity): add unit test suite for antigravity bridge routing and model self-healing - Add tests/unit/antigravity-bridge-routing.test.ts covering zero quota leakage for native Gemini models - Validate OmniRoute auto group routing and display name interception - Validate retired upstream model self-healing (preventing HTTP 410 crashes) - Export helper methods from bin/antigravity-bridge.mjs with isMain guard --------- Co-authored-by: Stavan <stavan794@gmail> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: steve25060 <steve25060@users.noreply.github.com>
163 lines
4.8 KiB
TypeScript
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: "groq",
|
|
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;
|
|
}
|