From da16a173a8049ae93588a2cc43401ccf7dc44ef9 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:06:45 -0400 Subject: [PATCH] fix(provider-proxy): honor per-account proxy toggles (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.14 — honor per-account proxy toggles + auto-fallback opt-in via PROXY_AUTO_SELECT_ENABLED. --- open-sse/services/proxyAutoSelector.ts | 7 +- open-sse/utils/proxyFetch.ts | 13 ++-- .../dashboard/providers/[id]/page.tsx | 19 ++++- src/lib/db/core.ts | 21 +++++- src/lib/db/providers.ts | 34 +++++---- src/lib/db/settings.ts | 70 +++++++++-------- tests/unit/proxy-registry.test.ts | 75 +++++++++++++++++++ 7 files changed, 183 insertions(+), 56 deletions(-) diff --git a/open-sse/services/proxyAutoSelector.ts b/open-sse/services/proxyAutoSelector.ts index 3082905096..1838bdd3bf 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, +} from "@omniroute/open-sse/utils/proxyFallback.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; // --------------------------------------------------------------------------- // 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 (!isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) return null; if (!targetUrl) return null; let hostname: string; diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 3b3e7501a1..5865ab676b 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -12,6 +12,7 @@ import { } from "./proxyDispatcher.ts"; import tlsClient from "./tlsClient.ts"; import { isProxyReachable } from "@/lib/proxyHealth"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { findWorkingProxy } from "./proxyFallback.ts"; function isTlsFingerprintEnabled() { @@ -122,7 +123,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 +342,7 @@ async function patchedFetch( continue; } // All attempts exhausted — try proxy fallback before native fetch - if (source === "direct") { + if (source === "direct" && isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) { let targetHostname = ""; try { targetHostname = new URL(targetUrl).hostname; @@ -346,10 +350,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 9dfff75e0b..04f3c24dd0 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 944513267a..53bf4aa35f 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, @@ -455,7 +457,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 { @@ -546,6 +553,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"); diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 41fe4183cf..d328c41dc9 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,17 @@ 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") { + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true") return true; + if (normalized === "0" || normalized === "false") return false; + } + 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 +328,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 @@ -460,8 +474,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, @@ -539,18 +553,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, }); @@ -584,6 +588,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); @@ -610,6 +615,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 ff5c5f06c8..6aab3d1bfd 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -644,6 +644,33 @@ 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; + + 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 + // 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 +691,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 +743,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 +758,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 +770,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 +785,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 451312cb67..9dcec7d88b 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -363,6 +363,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/.