diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 49aebe2736..b51e645e6a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -874,6 +874,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | | `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | | `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | +| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. | | `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | | `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | | `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | diff --git a/open-sse/services/emergencyFallback.ts b/open-sse/services/emergencyFallback.ts index 349b5f83ce..3819848291 100644 --- a/open-sse/services/emergencyFallback.ts +++ b/open-sse/services/emergencyFallback.ts @@ -7,6 +7,9 @@ * * 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. */ export interface EmergencyFallbackConfig { @@ -63,6 +66,11 @@ export interface NoFallbackDecision { export type FallbackResult = FallbackDecision | NoFallbackDecision; +export function isEmergencyFallbackEnvEnabled(): boolean { + const raw = process.env.OMNIROUTE_EMERGENCY_FALLBACK; + return raw !== "false" && raw !== "0"; +} + export function shouldUseFallback( status: number, errorBody: string, @@ -70,6 +78,12 @@ export function shouldUseFallback( 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" }; } diff --git a/tests/unit/services/emergency-fallback.test.ts b/tests/unit/services/emergency-fallback.test.ts new file mode 100644 index 0000000000..bf43dd5d59 --- /dev/null +++ b/tests/unit/services/emergency-fallback.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + shouldUseFallback, + isEmergencyFallbackEnvEnabled, + EMERGENCY_FALLBACK_CONFIG, +} from "../../../open-sse/services/emergencyFallback.ts"; + +function withEnv(value: string | undefined, fn: () => void) { + const previous = process.env.OMNIROUTE_EMERGENCY_FALLBACK; + if (value === undefined) { + delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; + } else { + process.env.OMNIROUTE_EMERGENCY_FALLBACK = value; + } + try { + fn(); + } finally { + if (previous === undefined) { + delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; + } else { + process.env.OMNIROUTE_EMERGENCY_FALLBACK = previous; + } + } +} + +test("emergency fallback stays enabled when the env switch is unset (default behavior)", () => { + withEnv(undefined, () => { + assert.equal(isEmergencyFallbackEnvEnabled(), true); + const decision = shouldUseFallback(402, "", false); + assert.equal(decision.shouldFallback, true); + if (decision.shouldFallback) { + assert.equal(decision.provider, EMERGENCY_FALLBACK_CONFIG.provider); + assert.equal(decision.model, EMERGENCY_FALLBACK_CONFIG.model); + } + }); +}); + +test("budget keywords trigger fallback when the env switch is unset", () => { + withEnv(undefined, () => { + const decision = shouldUseFallback(429, "All accounts quota exceeded", false); + assert.equal(decision.shouldFallback, true); + }); +}); + +test("OMNIROUTE_EMERGENCY_FALLBACK=false disables the 402 redirect", () => { + withEnv("false", () => { + assert.equal(isEmergencyFallbackEnvEnabled(), false); + const decision = shouldUseFallback(402, "", false); + assert.equal(decision.shouldFallback, false); + assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/); + }); +}); + +test("OMNIROUTE_EMERGENCY_FALLBACK=0 disables the budget-keyword redirect", () => { + withEnv("0", () => { + const decision = shouldUseFallback(429, "quota exceeded for account", false); + assert.equal(decision.shouldFallback, false); + assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/); + }); +}); + +test("explicit truthy values keep the fallback enabled", () => { + withEnv("true", () => { + assert.equal(isEmergencyFallbackEnvEnabled(), true); + assert.equal(shouldUseFallback(402, "", false).shouldFallback, true); + }); +}); + +test("env switch does not override config.enabled=false", () => { + withEnv("true", () => { + const decision = shouldUseFallback(402, "", false, { + ...EMERGENCY_FALLBACK_CONFIG, + enabled: false, + }); + assert.equal(decision.shouldFallback, false); + }); +}); + +test("tool-bearing requests are still skipped regardless of env switch", () => { + withEnv(undefined, () => { + const decision = shouldUseFallback(402, "", true); + assert.equal(decision.shouldFallback, false); + assert.match(decision.reason, /tools/); + }); +});