From e84f39d97bf2343b1c6887dca5ba240c6e7d14d8 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Sun, 7 Jun 2026 00:22:36 -0400 Subject: [PATCH 1/2] fix(provider-proxy): honor account proxy toggles --- docs/reference/ENVIRONMENT.md | 2 + open-sse/services/proxyAutoSelector.ts | 7 +- open-sse/utils/proxyFallback.ts | 27 +++--- open-sse/utils/proxyFetch.ts | 14 +-- .../dashboard/providers/[id]/page.tsx | 19 +++- src/lib/db/core.ts | 23 ++++- src/lib/db/providers.ts | 34 +++---- src/lib/db/settings.ts | 88 +++++++++++------- tests/unit/proxy-registry.test.ts | 90 +++++++++++++++++++ 9 files changed, 230 insertions(+), 74 deletions(-) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f87d64bd291..49a9f7d0f81 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -276,6 +276,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | | `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | +| `OMNIROUTE_PROXY_AUTO_FALLBACK` | `false` | `open-sse/utils/proxyFallback.ts` | Opt in to automatic proxy-pool fallback after direct connection failures. | | `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | | `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | @@ -285,6 +286,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | **SOCKS5 through SSH tunnel** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | | **Corporate HTTP proxy** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | +| **Proxy-pool fallback** | `OMNIROUTE_PROXY_AUTO_FALLBACK=true` — direct requests may retry through saved proxy-pool entries after network failures | | **Anti-fingerprint** | `ENABLE_TLS_FINGERPRINT=true` — requires `wreq-js` (included) | --- diff --git a/open-sse/services/proxyAutoSelector.ts b/open-sse/services/proxyAutoSelector.ts index 3082905096b..5c1f8a6440f 100644 --- a/open-sse/services/proxyAutoSelector.ts +++ b/open-sse/services/proxyAutoSelector.ts @@ -10,7 +10,11 @@ * handled by the in-memory cache in proxyFallback.ts. */ -import { findWorkingProxy, clearProxyFallbackCache } from "@omniroute/open-sse/utils/proxyFallback.ts"; +import { + findWorkingProxy, + clearProxyFallbackCache, + isProxyAutoFallbackEnabled, +} from "@omniroute/open-sse/utils/proxyFallback.ts"; // --------------------------------------------------------------------------- // Public API @@ -31,6 +35,7 @@ import { findWorkingProxy, clearProxyFallbackCache } from "@omniroute/open-sse/u * @returns A working proxy URL, or null if none was found. */ export async function selectProxyForValidation(targetUrl: string): Promise { + if (!isProxyAutoFallbackEnabled()) return null; if (!targetUrl) return null; let hostname: string; diff --git a/open-sse/utils/proxyFallback.ts b/open-sse/utils/proxyFallback.ts index 8e5b2460723..1a7de16a96d 100644 --- a/open-sse/utils/proxyFallback.ts +++ b/open-sse/utils/proxyFallback.ts @@ -35,6 +35,12 @@ interface ProxyShape { const PROXY_FALLBACK_CACHE = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +export function isProxyAutoFallbackEnabled(): boolean { + const raw = + process.env.OMNIROUTE_PROXY_AUTO_FALLBACK || process.env.ENABLE_PROXY_AUTO_FALLBACK || ""; + return raw === "1" || raw.toLowerCase() === "true"; +} + /** * Clear the in-memory proxy fallback cache. * Useful for testing or admin operations. @@ -51,10 +57,9 @@ export function clearProxyFallbackCache(): void { * Build a full proxy URL string from a proxy record's fields. */ function proxyRecordToUrl(proxy: ProxyShape): string { - const auth = - proxy.username - ? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password || "")}@` - : ""; + const auth = proxy.username + ? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password || "")}@` + : ""; return `${proxy.type}://${auth}${proxy.host}:${proxy.port}`; } @@ -251,9 +256,7 @@ export async function testProxiesAgainstTarget( ); return results.map((r) => - r.status === "fulfilled" - ? r.value - : { proxyUrl: "unknown", ok: false, latencyMs: null } + r.status === "fulfilled" ? r.value : { proxyUrl: "unknown", ok: false, latencyMs: null } ); } @@ -303,9 +306,7 @@ export async function findWorkingProxy( }) ); - const working = results.find( - (r) => r.status === "fulfilled" && r.value.ok - ); + const working = results.find((r) => r.status === "fulfilled" && r.value.ok); if (working && working.status === "fulfilled") { const proxyUrl = working.value.proxyUrl; @@ -339,14 +340,14 @@ export async function findWorkingProxy( * @param _connectionId Optional connection ID (reserved for future use). * @returns A proxy resolution result with level "autoSelect", or null. */ -export async function selectWorkingProxyFallback( - _connectionId?: string -): Promise<{ +export async function selectWorkingProxyFallback(_connectionId?: string): Promise<{ proxy: { type: string; host: string; port: number; username: string; password: string } | null; level: string; levelId: string | null; source: string; } | null> { + if (!isProxyAutoFallbackEnabled()) return null; + const candidates = await getProxyCandidates(); if (candidates.length === 0) return null; diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 3b3e7501a1f..9992a5be937 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -12,7 +12,7 @@ import { } from "./proxyDispatcher.ts"; import tlsClient from "./tlsClient.ts"; import { isProxyReachable } from "@/lib/proxyHealth"; -import { findWorkingProxy } from "./proxyFallback.ts"; +import { findWorkingProxy, isProxyAutoFallbackEnabled } from "./proxyFallback.ts"; function isTlsFingerprintEnabled() { return process.env.ENABLE_TLS_FINGERPRINT === "true"; @@ -122,7 +122,10 @@ function noProxyMatch(targetUrl) { } function isLocalAddress(hostname: string): boolean { - const host = hostname.replace(/^\[/, "").replace(/\]$/, "").replace(/^::ffff:/i, ""); + const host = hostname + .replace(/^\[/, "") + .replace(/\]$/, "") + .replace(/^::ffff:/i, ""); if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host === "::1") { return true; } @@ -338,7 +341,7 @@ async function patchedFetch( continue; } // All attempts exhausted — try proxy fallback before native fetch - if (source === "direct") { + if (source === "direct" && isProxyAutoFallbackEnabled()) { let targetHostname = ""; try { targetHostname = new URL(targetUrl).hostname; @@ -346,10 +349,7 @@ async function patchedFetch( // ignore } if (targetHostname) { - const fallbackProxyUrl = await findWorkingProxy( - targetHostname, - targetUrl - ); + const fallbackProxyUrl = await findWorkingProxy(targetHostname, targetUrl); if (fallbackProxyUrl) { try { const dispatcher = createProxyDispatcher(fallbackProxyUrl); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 9dfff75e0ba..04f3c24dd07 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -186,6 +186,17 @@ function providerText( return fallback; } +function readBooleanToggle(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true") return true; + if (normalized === "0" || normalized === "false") return false; + } + return fallback; +} + function getWebSessionCredentialLabel( t: ProviderMessageTranslator, requirement: WebSessionCredentialRequirement, @@ -4877,9 +4888,9 @@ export default function ProviderDetailPage() { hasProxy={!!connProxyMap[conn.id]?.proxy} proxySource={connProxyMap[conn.id]?.level || null} proxyHost={connProxyMap[conn.id]?.proxy?.host || null} - proxyEnabled={conn.proxyEnabled !== false} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)} - perKeyProxyEnabled={conn.perKeyProxyEnabled === true} + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} /> ))} @@ -5080,9 +5091,9 @@ export default function ProviderDetailPage() { hasProxy={!!connProxyMap[conn.id]?.proxy} proxySource={connProxyMap[conn.id]?.level || null} proxyHost={connProxyMap[conn.id]?.proxy?.host || null} - proxyEnabled={conn.proxyEnabled !== false} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)} - perKeyProxyEnabled={conn.perKeyProxyEnabled === true} + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} /> ))} diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index a365c6ff74f..28c1585fe90 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -203,6 +203,8 @@ const SCHEMA_SQL = ` last_used_at TEXT, "group" TEXT, max_concurrent INTEGER, + proxy_enabled INTEGER NOT NULL DEFAULT 1, + per_key_proxy_enabled INTEGER NOT NULL DEFAULT 0, quota_window_thresholds_json TEXT, rate_limit_overrides_json TEXT, created_at TEXT NOT NULL, @@ -452,7 +454,12 @@ export function rowToCamel(row: unknown): JsonRecord | null { const result: JsonRecord = {}; for (const [k, v] of Object.entries(row as JsonRecord)) { const camelKey = toCamelCase(k); - if (camelKey === "isActive" || camelKey === "rateLimitProtection") { + if ( + camelKey === "isActive" || + camelKey === "rateLimitProtection" || + camelKey === "proxyEnabled" || + camelKey === "perKeyProxyEnabled" + ) { result[camelKey] = v === 1 || v === true; } else if (camelKey === "providerSpecificData" && typeof v === "string") { try { @@ -537,6 +544,18 @@ function ensureProviderConnectionsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE provider_connections ADD COLUMN max_concurrent INTEGER"); console.log("[DB] Added provider_connections.max_concurrent column"); } + if (!columnNames.has("proxy_enabled")) { + db.exec( + "ALTER TABLE provider_connections ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 1" + ); + console.log("[DB] Added provider_connections.proxy_enabled column"); + } + if (!columnNames.has("per_key_proxy_enabled")) { + db.exec( + "ALTER TABLE provider_connections ADD COLUMN per_key_proxy_enabled INTEGER NOT NULL DEFAULT 0" + ); + console.log("[DB] Added provider_connections.per_key_proxy_enabled column"); + } if (!columnNames.has("quota_window_thresholds_json")) { db.exec("ALTER TABLE provider_connections ADD COLUMN quota_window_thresholds_json TEXT"); console.log("[DB] Added provider_connections.quota_window_thresholds_json column"); @@ -1270,7 +1289,7 @@ export function getDbInstance(): SqliteDatabase { ) { throw e; } - preservedCriticalState = captureCriticalDbState(sqliteFile); + preservedCriticalState = captureCriticalDbState(sqliteFile); // SAFETY: Never delete the database — rename to backup so data can be recovered. // The old code would silently destroy all user data on any probe failure. diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index d593e492be1..2e19a9b303b 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -12,6 +12,7 @@ import { } from "./encryption"; import { invalidateDbCache } from "./readCache"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; +import { bumpProxyConfigGeneration } from "./settings"; type JsonRecord = Record; @@ -73,6 +74,13 @@ function withNullableRateLimitOverrides( }; } +function normalizeBooleanColumn(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1; + if (typeof value === "string") return value === "1" || value.toLowerCase() === "true"; + return fallback; +} + // Sanitize the per-connection rate limit overrides map: keep only known // fields with valid numeric values. Called once at each write-path boundary. function sanitizeRateLimitOverrides(value: unknown): Record | null { @@ -316,6 +324,8 @@ export async function createProviderConnection(data: JsonRecord) { isActive: data.isActive !== undefined ? data.isActive : true, createdAt: now, updatedAt: now, + proxyEnabled: normalizeBooleanColumn(data.proxyEnabled, true), + perKeyProxyEnabled: normalizeBooleanColumn(data.perKeyProxyEnabled, false), }; // Optional fields @@ -372,9 +382,7 @@ export async function createProviderConnection(data: JsonRecord) { // Same sanitization for rateLimitOverrides — keep in-memory representation // in sync with what gets persisted. if ("rateLimitOverrides" in connection) { - connection.rateLimitOverrides = sanitizeRateLimitOverrides( - connection.rateLimitOverrides - ); + connection.rateLimitOverrides = sanitizeRateLimitOverrides(connection.rateLimitOverrides); } _insertConnectionRow(db, encryptConnectionFields({ ...connection })); @@ -462,8 +470,8 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) { lastUsedAt: conn.lastUsedAt || null, group: conn.group || null, maxConcurrent: conn.maxConcurrent ?? null, - proxyEnabled: conn.proxyEnabled ?? 1, - perKeyProxyEnabled: conn.perKeyProxyEnabled ?? 0, + proxyEnabled: normalizeBooleanColumn(conn.proxyEnabled, true) ? 1 : 0, + perKeyProxyEnabled: normalizeBooleanColumn(conn.perKeyProxyEnabled, false) ? 1 : 0, quotaWindowThresholdsJson: serializeQuotaWindowThresholds(conn.quotaWindowThresholds), rateLimitOverridesJson: serializeRateLimitOverrides(conn.rateLimitOverrides), createdAt: conn.createdAt, @@ -541,18 +549,8 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) { group: data.group || null, maxConcurrent: data.maxConcurrent ?? null, quotaWindowThresholdsJson: serializeQuotaWindowThresholds(data.quotaWindowThresholds), - proxyEnabled: - typeof data.proxyEnabled === "boolean" - ? data.proxyEnabled - ? 1 - : 0 - : (data.proxyEnabled ?? 1), - perKeyProxyEnabled: - typeof data.perKeyProxyEnabled === "boolean" - ? data.perKeyProxyEnabled - ? 1 - : 0 - : (data.perKeyProxyEnabled ?? 0), + proxyEnabled: normalizeBooleanColumn(data.proxyEnabled, true) ? 1 : 0, + perKeyProxyEnabled: normalizeBooleanColumn(data.perKeyProxyEnabled, false) ? 1 : 0, rateLimitOverridesJson: serializeRateLimitOverrides(data.rateLimitOverrides), updatedAt: now, }); @@ -586,6 +584,7 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { _updateConnectionRow(db, id, encryptConnectionFields({ ...merged })); backupDbFile("pre-write"); invalidateDbCache("connections"); // Bust connections read cache + bumpProxyConfigGeneration(); if (data.priority !== undefined) { const existingRecord = toRecord(existing); @@ -612,6 +611,7 @@ export async function deleteProviderConnection(id: string) { db.prepare("DELETE FROM quota_snapshots WHERE connection_id = ?").run(id); db.prepare("DELETE FROM provider_connections WHERE id = ?").run(id); + bumpProxyConfigGeneration(); const existingRecord = toRecord(existing); const providerId = typeof existingRecord.provider === "string" diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index ff5c5f06c81..8075f4a95db 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -644,6 +644,51 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: return result; } + let connectionRecord: JsonRecord | null = null; + let connectionProvider: string | null = null; + let connectionProxyEnabled = true; + let connectionPerKeyProxyEnabled = false; + + try { + const row = db + .prepare( + "SELECT provider, proxy_enabled, per_key_proxy_enabled FROM provider_connections WHERE id = ?" + ) + .get(connectionId); + if (row) { + connectionRecord = toRecord(row); + connectionProvider = + typeof connectionRecord.provider === "string" ? connectionRecord.provider : null; + connectionProxyEnabled = connectionRecord.proxy_enabled !== 0; + connectionPerKeyProxyEnabled = connectionRecord.per_key_proxy_enabled === 1; + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("no such column")) { + throw error; + } + + // Older DBs may not have the toggle columns yet. Keep legacy behavior enabled + // until migrations/repair add the columns. + const row = db + .prepare("SELECT provider FROM provider_connections WHERE id = ?") + .get(connectionId); + if (row) { + connectionRecord = toRecord(row); + connectionProvider = + typeof connectionRecord.provider === "string" ? connectionRecord.provider : null; + } + } + + // A connection-level Proxy Off is explicit: it must bypass every stored proxy + // source for this connection, including account, provider, global, and automatic + // fallback candidates from the proxy pool. + if (connectionRecord && !connectionProxyEnabled) { + const result: ProxyResolutionResult = { proxy: null, level: "direct", levelId: null }; + cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + return result; + } + // Step 1.5: Check global perKeyProxyEnabled setting let globalPerKeyProxyEnabled = false; try { @@ -664,17 +709,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: // Step 2: API key-level proxy (only if per-key proxy is enabled globally or per-connection) if (apiKeyId) { // Check if per-key proxy is allowed: globally OR per-connection - let perKeyEnabled = globalPerKeyProxyEnabled; - if (!perKeyEnabled && connectionId) { - try { - const perKeyConn = db - .prepare("SELECT per_key_proxy_enabled FROM provider_connections WHERE id = ?") - .get(connectionId) as { per_key_proxy_enabled?: number } | undefined; - perKeyEnabled = perKeyConn?.per_key_proxy_enabled === 1; - } catch { - // Fall through - } - } + const perKeyEnabled = globalPerKeyProxyEnabled || connectionPerKeyProxyEnabled; if (perKeyEnabled) { try { @@ -726,21 +761,14 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: return result; } - // Step 5: Look up the connection's provider and check proxy_enabled - const connection = db - .prepare("SELECT provider, proxy_enabled FROM provider_connections WHERE id = ?") - .get(connectionId); - - if (connection) { - const connectionRecord = toRecord(connection); - const provider = - typeof connectionRecord.provider === "string" ? connectionRecord.provider : null; - // proxy_enabled defaults to 0 (false) when the column is NULL (pre-migration) - const connProxyEnabled = connectionRecord.proxy_enabled === 1; - + // Step 5: Use the connection's provider for provider/combo scoped proxies. + if (connectionRecord) { // Step 6: Provider-level registry (only if proxy_enabled) - if (provider && connProxyEnabled) { - const registryProvider = await resolveProxyForScopeFromRegistry("provider", provider); + if (connectionProvider && connectionProxyEnabled) { + const registryProvider = await resolveProxyForScopeFromRegistry( + "provider", + connectionProvider + ); if (registryProvider?.proxy) { cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryProvider); return registryProvider; @@ -748,7 +776,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: } // Step 7: Legacy combo-level (only if proxy_enabled) - if (connProxyEnabled && config.combos && Object.keys(config.combos).length > 0) { + if (connectionProxyEnabled && config.combos && Object.keys(config.combos).length > 0) { const combos = db.prepare("SELECT id, data FROM combos").all(); for (const comboRow of combos) { const comboRecord = toRecord(comboRow); @@ -760,7 +788,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: const combo = toRecord(JSON.parse(comboRaw)); const comboModels = Array.isArray(combo.models) ? combo.models : []; const usesProvider = comboModels.some( - (entry) => getComboModelProvider(entry) === provider + (entry) => getComboModelProvider(entry) === connectionProvider ); if (usesProvider) { const result = { proxy: config.combos[comboId], level: "combo", levelId: comboId }; @@ -775,11 +803,11 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: } // Step 8: Legacy provider-level (only if proxy_enabled) - if (provider && connProxyEnabled && config.providers?.[provider]) { + if (connectionProvider && connectionProxyEnabled && config.providers?.[connectionProvider]) { const result = { - proxy: config.providers[provider], + proxy: config.providers[connectionProvider], level: "provider", - levelId: provider, + levelId: connectionProvider, }; cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); return result; diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index 451312cb679..4da86e3cb5f 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -7,6 +7,8 @@ import path from "node:path"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-registry-")); process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = "test-secret"; +const ORIGINAL_PROXY_AUTO_FALLBACK = process.env.OMNIROUTE_PROXY_AUTO_FALLBACK; +const ORIGINAL_ENABLE_PROXY_AUTO_FALLBACK = process.env.ENABLE_PROXY_AUTO_FALLBACK; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); @@ -17,6 +19,8 @@ const proxiesRoute = await import("../../src/app/api/settings/proxies/route.ts") async function resetStorage() { delete process.env.INITIAL_PASSWORD; + delete process.env.OMNIROUTE_PROXY_AUTO_FALLBACK; + delete process.env.ENABLE_PROXY_AUTO_FALLBACK; core.resetDbInstance(); apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -26,6 +30,17 @@ async function resetStorage() { test.after(async () => { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_PROXY_AUTO_FALLBACK === undefined) { + delete process.env.OMNIROUTE_PROXY_AUTO_FALLBACK; + } else { + process.env.OMNIROUTE_PROXY_AUTO_FALLBACK = ORIGINAL_PROXY_AUTO_FALLBACK; + } + if (ORIGINAL_ENABLE_PROXY_AUTO_FALLBACK === undefined) { + delete process.env.ENABLE_PROXY_AUTO_FALLBACK; + } else { + process.env.ENABLE_PROXY_AUTO_FALLBACK = ORIGINAL_ENABLE_PROXY_AUTO_FALLBACK; + } }); test("proxy registry blocks delete when proxy is still assigned", async () => { @@ -363,6 +378,81 @@ test("resolveProxyForConnection falls through when apiKey has no proxy_id", asyn assert.equal((resolved as any).proxy.host, "account-fallthrough.local"); }); +test("connection proxy toggle gates account assignments and invalidates cached resolutions", async () => { + await resetStorage(); + + const directConnection = await providersDb.createProviderConnection({ + provider: "proxy-toggle-test-provider", + authType: "apikey", + name: "Direct Account", + apiKey: "sk-direct-account", + }); + const proxiedConnection = await providersDb.createProviderConnection({ + provider: "proxy-toggle-test-provider", + authType: "apikey", + name: "Proxied Account", + apiKey: "sk-proxied-account", + }); + + const poolProxy = await proxiesDb.createProxy({ + name: "Pool Proxy", + type: "http", + host: "pool-proxy.local", + port: 8080, + }); + await proxiesDb.assignProxyToScope("account", (proxiedConnection as any).id, poolProxy.id); + + const directResolved = await settingsDb.resolveProxyForConnection((directConnection as any).id); + assert.equal(directResolved.level, "direct"); + assert.equal(directResolved.proxy, null); + + const proxiedResolved = await settingsDb.resolveProxyForConnection((proxiedConnection as any).id); + assert.equal(proxiedResolved.level, "account"); + assert.equal((proxiedResolved.proxy as any).host, "pool-proxy.local"); + + const disabled = await providersDb.updateProviderConnection((proxiedConnection as any).id, { + proxyEnabled: false, + }); + assert.equal((disabled as any).proxyEnabled, false); + + const disabledResolved = await settingsDb.resolveProxyForConnection( + (proxiedConnection as any).id + ); + assert.equal(disabledResolved.level, "direct"); + assert.equal(disabledResolved.proxy, null); + + const enabled = await providersDb.updateProviderConnection((proxiedConnection as any).id, { + proxyEnabled: true, + }); + assert.equal((enabled as any).proxyEnabled, true); + + const enabledResolved = await settingsDb.resolveProxyForConnection((proxiedConnection as any).id); + assert.equal(enabledResolved.level, "account"); + assert.equal((enabledResolved.proxy as any).host, "pool-proxy.local"); +}); + +test("provider connection proxy toggle fields round-trip as booleans", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Boolean Toggle Account", + apiKey: "sk-toggle-roundtrip", + }); + + const updated = await providersDb.updateProviderConnection((connection as any).id, { + proxyEnabled: false, + perKeyProxyEnabled: true, + }); + const fetched = await providersDb.getProviderConnectionById((connection as any).id); + + assert.equal((updated as any).proxyEnabled, false); + assert.equal((updated as any).perKeyProxyEnabled, true); + assert.equal((fetched as any).proxyEnabled, false); + assert.equal((fetched as any).perKeyProxyEnabled, true); +}); + test("createProxyRegistrySchema accepts type:vercel and source:vercel-relay (schema gap-06)", async () => { // Note: We validate the schema directly using the worktree's absolute path because // tests run with CWD=/OmniRoute, so `@/` aliases resolve to the main branch's src/. From 062a063172bf037f16a60fd39184bd77bd72da2f Mon Sep 17 00:00:00 2001 From: "R.D." Date: Sun, 7 Jun 2026 00:41:42 -0400 Subject: [PATCH 2/2] fix(provider-proxy): address proxy review feedback --- .env.example | 6 +++++ docs/reference/ENVIRONMENT.md | 1 + open-sse/utils/proxyFallback.ts | 11 ++++++--- src/lib/db/providers.ts | 6 ++++- src/lib/db/settings.ts | 40 +++++++++---------------------- tests/unit/proxy-registry.test.ts | 31 ++++++++++++++++-------- 6 files changed, 52 insertions(+), 43 deletions(-) diff --git a/.env.example b/.env.example index ca1fb8f6a0d..6f6994fe6c0 100644 --- a/.env.example +++ b/.env.example @@ -382,6 +382,12 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # ALL_PROXY=socks5://127.0.0.1:7890 # NO_PROXY=localhost,127.0.0.1 +# Opt in to automatic retry through saved proxy-pool entries after direct +# connection failures. Leave disabled to keep unassigned accounts on direct egress. +# Used by: open-sse/utils/proxyFallback.ts +# OMNIROUTE_PROXY_AUTO_FALLBACK=false +# ENABLE_PROXY_AUTO_FALLBACK=false + # TLS fingerprint spoofing (opt-in) — mimics Chrome 124 TLS handshake via wreq-js. # Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google). # Used by: open-sse/executors — replaces Node.js default TLS fingerprint. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 49a9f7d0f81..8e869cabe64 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -277,6 +277,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | | `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | | `OMNIROUTE_PROXY_AUTO_FALLBACK` | `false` | `open-sse/utils/proxyFallback.ts` | Opt in to automatic proxy-pool fallback after direct connection failures. | +| `ENABLE_PROXY_AUTO_FALLBACK` | `false` | `open-sse/utils/proxyFallback.ts` | Legacy alias for `OMNIROUTE_PROXY_AUTO_FALLBACK`. | | `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | | `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | diff --git a/open-sse/utils/proxyFallback.ts b/open-sse/utils/proxyFallback.ts index 1a7de16a96d..0a2760475c3 100644 --- a/open-sse/utils/proxyFallback.ts +++ b/open-sse/utils/proxyFallback.ts @@ -36,9 +36,14 @@ const PROXY_FALLBACK_CACHE = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes export function isProxyAutoFallbackEnabled(): boolean { - const raw = - process.env.OMNIROUTE_PROXY_AUTO_FALLBACK || process.env.ENABLE_PROXY_AUTO_FALLBACK || ""; - return raw === "1" || raw.toLowerCase() === "true"; + const raw = ( + process.env.OMNIROUTE_PROXY_AUTO_FALLBACK || + process.env.ENABLE_PROXY_AUTO_FALLBACK || + "" + ) + .trim() + .toLowerCase(); + return raw === "1" || raw === "true"; } /** diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 2e19a9b303b..251c9155865 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -77,7 +77,11 @@ function withNullableRateLimitOverrides( function normalizeBooleanColumn(value: unknown, fallback: boolean): boolean { if (typeof value === "boolean") return value; if (typeof value === "number") return value === 1; - if (typeof value === "string") return value === "1" || value.toLowerCase() === "true"; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true") return true; + if (normalized === "0" || normalized === "false") return false; + } return fallback; } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 8075f4a95db..6aab3d1bfd1 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -649,35 +649,17 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: let connectionProxyEnabled = true; let connectionPerKeyProxyEnabled = false; - try { - const row = db - .prepare( - "SELECT provider, proxy_enabled, per_key_proxy_enabled FROM provider_connections WHERE id = ?" - ) - .get(connectionId); - if (row) { - connectionRecord = toRecord(row); - connectionProvider = - typeof connectionRecord.provider === "string" ? connectionRecord.provider : null; - connectionProxyEnabled = connectionRecord.proxy_enabled !== 0; - connectionPerKeyProxyEnabled = connectionRecord.per_key_proxy_enabled === 1; - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("no such column")) { - throw error; - } - - // Older DBs may not have the toggle columns yet. Keep legacy behavior enabled - // until migrations/repair add the columns. - const row = db - .prepare("SELECT provider FROM provider_connections WHERE id = ?") - .get(connectionId); - if (row) { - connectionRecord = toRecord(row); - connectionProvider = - typeof connectionRecord.provider === "string" ? connectionRecord.provider : null; - } + const row = db + .prepare( + "SELECT provider, proxy_enabled, per_key_proxy_enabled FROM provider_connections WHERE id = ?" + ) + .get(connectionId); + if (row) { + connectionRecord = toRecord(row); + connectionProvider = + typeof connectionRecord.provider === "string" ? connectionRecord.provider : null; + connectionProxyEnabled = connectionRecord.proxy_enabled !== 0; + connectionPerKeyProxyEnabled = connectionRecord.per_key_proxy_enabled === 1; } // A connection-level Proxy Off is explicit: it must bypass every stored proxy diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index 4da86e3cb5f..5463af71260 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -17,20 +17,12 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const proxiesRoute = await import("../../src/app/api/settings/proxies/route.ts"); -async function resetStorage() { - delete process.env.INITIAL_PASSWORD; +function clearProxyAutoFallbackEnv() { delete process.env.OMNIROUTE_PROXY_AUTO_FALLBACK; delete process.env.ENABLE_PROXY_AUTO_FALLBACK; - core.resetDbInstance(); - apiKeysDb.resetApiKeyState(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); - fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } -test.after(async () => { - core.resetDbInstance(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); - +function restoreProxyAutoFallbackEnv() { if (ORIGINAL_PROXY_AUTO_FALLBACK === undefined) { delete process.env.OMNIROUTE_PROXY_AUTO_FALLBACK; } else { @@ -41,6 +33,25 @@ test.after(async () => { } else { process.env.ENABLE_PROXY_AUTO_FALLBACK = ORIGINAL_ENABLE_PROXY_AUTO_FALLBACK; } +} + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + clearProxyAutoFallbackEnv(); + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.afterEach(() => { + restoreProxyAutoFallbackEnv(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + restoreProxyAutoFallbackEnv(); }); test("proxy registry blocks delete when proxy is still assigned", async () => {